diff --git a/pkg/armrpc/rest/results.go b/pkg/armrpc/rest/results.go index d60993136d..8611455413 100644 --- a/pkg/armrpc/rest/results.go +++ b/pkg/armrpc/rest/results.go @@ -375,27 +375,27 @@ type BadRequestResponse struct { } // NewLinkedResourceUpdateErrorResponse represents a HTTP 400 with an error message when user updates environment id and application id. -func NewLinkedResourceUpdateErrorResponse(resourceID resources.ID, oldProp *rpv1.BasicResourceProperties, newProp *rpv1.BasicResourceProperties) Response { +func NewLinkedResourceUpdateErrorResponse(resourceID resources.ID, oldScope rpv1.BasicResourcePropertiesAdapter, newScope rpv1.BasicResourcePropertiesAdapter) Response { newAppEnv := "" - if newProp.Application != "" { - name := newProp.Application - if rid, err := resources.ParseResource(newProp.Application); err == nil { + if newScope.ApplicationID() != "" { + name := newScope.ApplicationID() + if rid, err := resources.ParseResource(newScope.ApplicationID()); err == nil { name = rid.Name() } newAppEnv += fmt.Sprintf("'%s' application", name) } - if newProp.Environment != "" { + if newScope.EnvironmentID() != "" { if newAppEnv != "" { newAppEnv += " and " } - name := newProp.Environment - if rid, err := resources.ParseResource(newProp.Environment); err == nil { + name := newScope.EnvironmentID() + if rid, err := resources.ParseResource(newScope.EnvironmentID()); err == nil { name = rid.Name() } newAppEnv += fmt.Sprintf("'%s' environment", name) } - message := fmt.Sprintf(LinkedResourceUpdateErrorFormat, resourceID.Name(), resourceID.Name(), newAppEnv, oldProp.Application, oldProp.Environment, resourceID.Name()) + message := fmt.Sprintf(LinkedResourceUpdateErrorFormat, resourceID.Name(), resourceID.Name(), newAppEnv, oldScope.ApplicationID(), oldScope.EnvironmentID(), resourceID.Name()) return &BadRequestResponse{ Body: v1.ErrorResponse{ Error: v1.ErrorDetails{ diff --git a/pkg/corerp/backend/deployment/deploymentprocessor.go b/pkg/corerp/backend/deployment/deploymentprocessor.go index 1e4de863c2..aeb57dc6ed 100644 --- a/pkg/corerp/backend/deployment/deploymentprocessor.go +++ b/pkg/corerp/backend/deployment/deploymentprocessor.go @@ -118,7 +118,7 @@ func (dp *deploymentProcessor) Render(ctx context.Context, resourceID resources. c := app.Properties.Status.Compute // Override environment-scope namespace with application-scope kubernetes namespace. - if c != nil && c.Kind == rpv1.KubernetesComputeKind { + if c.Kind == rpv1.KubernetesComputeKind { envOptions.Namespace = c.KubernetesCompute.Namespace } diff --git a/pkg/corerp/datamodel/application.go b/pkg/corerp/datamodel/application.go index 0eac1217ae..16d199faa8 100644 --- a/pkg/corerp/datamodel/application.go +++ b/pkg/corerp/datamodel/application.go @@ -50,7 +50,7 @@ func (c *Application) OutputResources() []rpv1.OutputResource { } // ResourceMetadata returns the BasicResourceProperties of the Application instance. -func (h *Application) ResourceMetadata() *rpv1.BasicResourceProperties { +func (h *Application) ResourceMetadata() rpv1.BasicResourcePropertiesAdapter { return &h.Properties.BasicResourceProperties } diff --git a/pkg/corerp/datamodel/container.go b/pkg/corerp/datamodel/container.go index dd67ff83f4..fc76af7144 100644 --- a/pkg/corerp/datamodel/container.go +++ b/pkg/corerp/datamodel/container.go @@ -54,7 +54,7 @@ func (c *ContainerResource) OutputResources() []rpv1.OutputResource { } // ResourceMetadata returns the BasicResourceProperties of the ContainerResource instance. -func (h *ContainerResource) ResourceMetadata() *rpv1.BasicResourceProperties { +func (h *ContainerResource) ResourceMetadata() rpv1.BasicResourcePropertiesAdapter { return &h.Properties.BasicResourceProperties } diff --git a/pkg/corerp/datamodel/extender.go b/pkg/corerp/datamodel/extender.go index b8e1592015..cb48b6d540 100644 --- a/pkg/corerp/datamodel/extender.go +++ b/pkg/corerp/datamodel/extender.go @@ -48,7 +48,7 @@ func (r *Extender) OutputResources() []rpv1.OutputResource { } // ResourceMetadata returns the BasicResourceProperties of the Extender resource. -func (r *Extender) ResourceMetadata() *rpv1.BasicResourceProperties { +func (r *Extender) ResourceMetadata() rpv1.BasicResourcePropertiesAdapter { return &r.Properties.BasicResourceProperties } @@ -59,13 +59,18 @@ func (r *Extender) ResourceTypeName() string { // Recipe returns the ResourceRecipe associated with the Extender if the ResourceProvisioning is not set to Manual, // otherwise it returns nil. -func (r *Extender) Recipe() *portableresources.ResourceRecipe { +func (r *Extender) GetRecipe() *portableresources.ResourceRecipe { if r.Properties.ResourceProvisioning == portableresources.ResourceProvisioningManual { return nil } return &r.Properties.ResourceRecipe } +// SetRecipe sets the recipe information. +func (r *Extender) SetRecipe(recipe *portableresources.ResourceRecipe) { + r.Properties.ResourceRecipe = *recipe +} + // ExtenderProperties represents the properties of Extender resource. type ExtenderProperties struct { rpv1.BasicResourceProperties diff --git a/pkg/corerp/datamodel/gateway.go b/pkg/corerp/datamodel/gateway.go index de8a0f804b..e8af591d96 100644 --- a/pkg/corerp/datamodel/gateway.go +++ b/pkg/corerp/datamodel/gateway.go @@ -56,7 +56,7 @@ func (g *Gateway) OutputResources() []rpv1.OutputResource { } // ResourceMetadata returns the BasicResourceProperties of the Gateway instance. -func (h *Gateway) ResourceMetadata() *rpv1.BasicResourceProperties { +func (h *Gateway) ResourceMetadata() rpv1.BasicResourcePropertiesAdapter { return &h.Properties.BasicResourceProperties } diff --git a/pkg/corerp/datamodel/secretstore.go b/pkg/corerp/datamodel/secretstore.go index 86f9bdc6df..7d7565455a 100644 --- a/pkg/corerp/datamodel/secretstore.go +++ b/pkg/corerp/datamodel/secretstore.go @@ -83,7 +83,7 @@ func (s *SecretStore) OutputResources() []rpv1.OutputResource { } // ResourceMetadata returns the BasicResourceProperties of the SecretStore instance. -func (s *SecretStore) ResourceMetadata() *rpv1.BasicResourceProperties { +func (s *SecretStore) ResourceMetadata() rpv1.BasicResourcePropertiesAdapter { return &s.Properties.BasicResourceProperties } @@ -144,6 +144,6 @@ func (s *SecretStoreListSecrets) OutputResources() []rpv1.OutputResource { } // ResourceMetadata returns nil for SecretStoreListSecrets. -func (s *SecretStoreListSecrets) ResourceMetadata() *rpv1.BasicResourceProperties { +func (s *SecretStoreListSecrets) ResourceMetadata() rpv1.BasicResourcePropertiesAdapter { return nil } diff --git a/pkg/corerp/datamodel/volume.go b/pkg/corerp/datamodel/volume.go index e7aee0521b..2014f8f7b5 100644 --- a/pkg/corerp/datamodel/volume.go +++ b/pkg/corerp/datamodel/volume.go @@ -59,7 +59,7 @@ func (h *VolumeResource) OutputResources() []rpv1.OutputResource { } // ResourceMetadata returns the BasicResourceProperties of the VolumeResource instance. -func (h *VolumeResource) ResourceMetadata() *rpv1.BasicResourceProperties { +func (h *VolumeResource) ResourceMetadata() rpv1.BasicResourcePropertiesAdapter { return &h.Properties.BasicResourceProperties } diff --git a/pkg/corerp/frontend/controller/applications/updatefilter.go b/pkg/corerp/frontend/controller/applications/updatefilter.go index 80efb97f37..5f602f840a 100644 --- a/pkg/corerp/frontend/controller/applications/updatefilter.go +++ b/pkg/corerp/frontend/controller/applications/updatefilter.go @@ -116,7 +116,7 @@ func CreateAppScopedNamespace(ctx context.Context, newResource, oldResource *dat if oldResource != nil { c := oldResource.Properties.Status.Compute - if c != nil && c.Kind == rpv1.KubernetesComputeKind && c.KubernetesCompute.Namespace != kubeNamespace { + if c.Kind == rpv1.KubernetesComputeKind && c.KubernetesCompute.Namespace != kubeNamespace { return rest.NewBadRequestResponse(fmt.Sprintf("Updating an application's Kubernetes namespace from '%s' to '%s' requires the application to be deleted and redeployed. Please delete your application and try again.", c.KubernetesCompute.Namespace, kubeNamespace)), nil } } diff --git a/pkg/corerp/frontend/controller/secretstores/kubernetes.go b/pkg/corerp/frontend/controller/secretstores/kubernetes.go index 0468b8246a..adfb349ca0 100644 --- a/pkg/corerp/frontend/controller/secretstores/kubernetes.go +++ b/pkg/corerp/frontend/controller/secretstores/kubernetes.go @@ -205,7 +205,7 @@ func UpsertSecret(ctx context.Context, newResource, old *datamodel.SecretStore, } // resource property cannot be empty for global scoped resource. - if newResource.Properties.BasicResourceProperties.IsGlobalScopedResource() && ref == "" { + if rpv1.IsGlobalScopedResource(&newResource.Properties.BasicResourceProperties) && ref == "" { return rest.NewBadRequestResponse("$.properties.resource cannot be empty for global scoped resource."), nil } @@ -241,9 +241,10 @@ func UpsertSecret(ctx context.Context, newResource, old *datamodel.SecretStore, if apierrors.IsNotFound(err) { // If resource in incoming request references resource, then the resource must exist for a application/environment scoped resource. // For global scoped resource create the kubernetes resource if not exists. - if ref != "" && !newResource.Properties.BasicResourceProperties.IsGlobalScopedResource() { + if ref != "" && !rpv1.IsGlobalScopedResource(&newResource.Properties.BasicResourceProperties) { return rest.NewBadRequestResponse(fmt.Sprintf("'%s' referenced resource does not exist.", ref)), nil } + app, _ := resources.ParseResource(newResource.Properties.Application) ksecret = &corev1.Secret{ ObjectMeta: metav1.ObjectMeta{ diff --git a/pkg/corerp/setup/setup.go b/pkg/corerp/setup/setup.go index cb709ef4b8..c1d1ef8d47 100644 --- a/pkg/corerp/setup/setup.go +++ b/pkg/corerp/setup/setup.go @@ -208,7 +208,7 @@ func SetupNamespace(recipeControllerConfig *controllerconfig.RecipeControllerCon rp_frontend.PrepareRadiusResource[*datamodel.Extender], }, AsyncJobController: func(options asyncctrl.Options) (asyncctrl.Controller, error) { - return pr_ctrl.NewCreateOrUpdateResource[*datamodel.Extender, datamodel.Extender](options, &ext_processor.Processor{}, recipeControllerConfig.Engine, recipeControllerConfig.ResourceClient, recipeControllerConfig.ConfigLoader) + return pr_ctrl.NewCreateOrUpdateResource[*datamodel.Extender, datamodel.Extender](options, &ext_processor.Processor{}, recipeControllerConfig.Engine, recipeControllerConfig.ConfigLoader) }, AsyncOperationTimeout: ext_ctrl.AsyncCreateOrUpdateExtenderTimeout, AsyncOperationRetryAfter: AsyncOperationRetryAfter, @@ -218,7 +218,7 @@ func SetupNamespace(recipeControllerConfig *controllerconfig.RecipeControllerCon rp_frontend.PrepareRadiusResource[*datamodel.Extender], }, AsyncJobController: func(options asyncctrl.Options) (asyncctrl.Controller, error) { - return pr_ctrl.NewCreateOrUpdateResource[*datamodel.Extender, datamodel.Extender](options, &ext_processor.Processor{}, recipeControllerConfig.Engine, recipeControllerConfig.ResourceClient, recipeControllerConfig.ConfigLoader) + return pr_ctrl.NewCreateOrUpdateResource[*datamodel.Extender, datamodel.Extender](options, &ext_processor.Processor{}, recipeControllerConfig.Engine, recipeControllerConfig.ConfigLoader) }, AsyncOperationTimeout: ext_ctrl.AsyncCreateOrUpdateExtenderTimeout, AsyncOperationRetryAfter: AsyncOperationRetryAfter, diff --git a/pkg/daprrp/datamodel/daprconfigurationstore.go b/pkg/daprrp/datamodel/daprconfigurationstore.go index ff9bcf4042..384e00de74 100644 --- a/pkg/daprrp/datamodel/daprconfigurationstore.go +++ b/pkg/daprrp/datamodel/daprconfigurationstore.go @@ -46,7 +46,7 @@ func (r *DaprConfigurationStore) OutputResources() []rpv1.OutputResource { } // ResourceMetadata returns the BasicResourceProperties of the Dapr ConfigurationStore resource i.e. application resources metadata. -func (r *DaprConfigurationStore) ResourceMetadata() *rpv1.BasicResourceProperties { +func (r *DaprConfigurationStore) ResourceMetadata() rpv1.BasicResourcePropertiesAdapter { return &r.Properties.BasicResourceProperties } @@ -56,13 +56,18 @@ func (r *DaprConfigurationStore) ResourceTypeName() string { } // Recipe returns the recipe information of the resource. Returns nil if recipe execution is disabled. -func (r *DaprConfigurationStore) Recipe() *portableresources.ResourceRecipe { +func (r *DaprConfigurationStore) GetRecipe() *portableresources.ResourceRecipe { if r.Properties.ResourceProvisioning == portableresources.ResourceProvisioningManual { return nil } return &r.Properties.Recipe } +// SetRecipe sets the recipe information. +func (r *DaprConfigurationStore) SetRecipe(recipe *portableresources.ResourceRecipe) { + r.Properties.Recipe = *recipe +} + // DaprConfigurationStoreProperties represents the properties of Dapr ConfigurationStore resource. type DaprConfigurationStoreProperties struct { rpv1.BasicResourceProperties diff --git a/pkg/daprrp/datamodel/daprpubsubbroker.go b/pkg/daprrp/datamodel/daprpubsubbroker.go index 8e22537c6e..4dcb1f5f6f 100644 --- a/pkg/daprrp/datamodel/daprpubsubbroker.go +++ b/pkg/daprrp/datamodel/daprpubsubbroker.go @@ -46,7 +46,7 @@ func (r *DaprPubSubBroker) OutputResources() []rpv1.OutputResource { } // ResourceMetadata returns the BasicResourceProperties of the Dapr PubSubBroker resource i.e. application resources metadata. -func (r *DaprPubSubBroker) ResourceMetadata() *rpv1.BasicResourceProperties { +func (r *DaprPubSubBroker) ResourceMetadata() rpv1.BasicResourcePropertiesAdapter { return &r.Properties.BasicResourceProperties } @@ -56,13 +56,18 @@ func (r *DaprPubSubBroker) ResourceTypeName() string { } // Recipe returns the recipe information of the resource. Returns nil if recipe execution is disabled. -func (r *DaprPubSubBroker) Recipe() *portableresources.ResourceRecipe { +func (r *DaprPubSubBroker) GetRecipe() *portableresources.ResourceRecipe { if r.Properties.ResourceProvisioning == portableresources.ResourceProvisioningManual { return nil } return &r.Properties.Recipe } +// SetRecipe sets the recipe information. +func (r *DaprPubSubBroker) SetRecipe(recipe *portableresources.ResourceRecipe) { + r.Properties.Recipe = *recipe +} + // DaprPubSubBrokerProperties represents the properties of Dapr PubSubBroker resource. type DaprPubSubBrokerProperties struct { rpv1.BasicResourceProperties diff --git a/pkg/daprrp/datamodel/daprsecretstore.go b/pkg/daprrp/datamodel/daprsecretstore.go index 3e4da47f12..b2940e52a0 100644 --- a/pkg/daprrp/datamodel/daprsecretstore.go +++ b/pkg/daprrp/datamodel/daprsecretstore.go @@ -46,7 +46,7 @@ func (r *DaprSecretStore) OutputResources() []rpv1.OutputResource { } // ResourceMetadata returns the BasicResourceProperties of the DaprSecretStore resource i.e. application resources metadata. -func (r *DaprSecretStore) ResourceMetadata() *rpv1.BasicResourceProperties { +func (r *DaprSecretStore) ResourceMetadata() rpv1.BasicResourcePropertiesAdapter { return &r.Properties.BasicResourceProperties } @@ -68,9 +68,14 @@ type DaprSecretStoreProperties struct { // Recipe returns the Recipe from the DaprSecretStore Properties if ResourceProvisioning is not set to Manual, // otherwise it returns nil. -func (r *DaprSecretStore) Recipe() *portableresources.ResourceRecipe { +func (r *DaprSecretStore) GetRecipe() *portableresources.ResourceRecipe { if r.Properties.ResourceProvisioning == portableresources.ResourceProvisioningManual { return nil } return &r.Properties.Recipe } + +// SetRecipe sets the recipe information. +func (r *DaprSecretStore) SetRecipe(recipe *portableresources.ResourceRecipe) { + r.Properties.Recipe = *recipe +} diff --git a/pkg/daprrp/datamodel/daprstatestore.go b/pkg/daprrp/datamodel/daprstatestore.go index cae08c2d5f..ab04d9cbf0 100644 --- a/pkg/daprrp/datamodel/daprstatestore.go +++ b/pkg/daprrp/datamodel/daprstatestore.go @@ -46,7 +46,7 @@ func (r *DaprStateStore) OutputResources() []rpv1.OutputResource { } // ResourceMetadata returns the BasicResourceProperties of the DaprStateStore resource i.e. application resources metadata. -func (r *DaprStateStore) ResourceMetadata() *rpv1.BasicResourceProperties { +func (r *DaprStateStore) ResourceMetadata() rpv1.BasicResourcePropertiesAdapter { return &r.Properties.BasicResourceProperties } @@ -56,13 +56,18 @@ func (r *DaprStateStore) ResourceTypeName() string { } // Recipe returns the recipe information of the resource. It returns nil if the ResourceProvisioning is set to manual. -func (r *DaprStateStore) Recipe() *portableresources.ResourceRecipe { +func (r *DaprStateStore) GetRecipe() *portableresources.ResourceRecipe { if r.Properties.ResourceProvisioning == portableresources.ResourceProvisioningManual { return nil } return &r.Properties.Recipe } +// SetRecipe sets the recipe information. +func (r *DaprStateStore) SetRecipe(recipe *portableresources.ResourceRecipe) { + r.Properties.Recipe = *recipe +} + // DaprStateStoreProperties represents the properties of DaprStateStore resource. type DaprStateStoreProperties struct { rpv1.BasicResourceProperties diff --git a/pkg/daprrp/setup/setup.go b/pkg/daprrp/setup/setup.go index 4470884c83..da6b7ee613 100644 --- a/pkg/daprrp/setup/setup.go +++ b/pkg/daprrp/setup/setup.go @@ -53,7 +53,7 @@ func SetupNamespace(recipeControllerConfig *controllerconfig.RecipeControllerCon rp_frontend.PrepareRadiusResource[*datamodel.DaprPubSubBroker], }, AsyncJobController: func(options asyncctrl.Options) (asyncctrl.Controller, error) { - return pr_ctrl.NewCreateOrUpdateResource[*datamodel.DaprPubSubBroker, datamodel.DaprPubSubBroker](options, &pubsub_proc.Processor{Client: options.KubeClient}, recipeControllerConfig.Engine, recipeControllerConfig.ResourceClient, recipeControllerConfig.ConfigLoader) + return pr_ctrl.NewCreateOrUpdateResource[*datamodel.DaprPubSubBroker, datamodel.DaprPubSubBroker](options, &pubsub_proc.Processor{Client: options.KubeClient}, recipeControllerConfig.Engine, recipeControllerConfig.ConfigLoader) }, AsyncOperationTimeout: dapr_ctrl.AsyncCreateOrUpdateDaprPubSubBrokerTimeout, AsyncOperationRetryAfter: AsyncOperationRetryAfter, @@ -63,7 +63,7 @@ func SetupNamespace(recipeControllerConfig *controllerconfig.RecipeControllerCon rp_frontend.PrepareRadiusResource[*datamodel.DaprPubSubBroker], }, AsyncJobController: func(options asyncctrl.Options) (asyncctrl.Controller, error) { - return pr_ctrl.NewCreateOrUpdateResource[*datamodel.DaprPubSubBroker, datamodel.DaprPubSubBroker](options, &pubsub_proc.Processor{Client: options.KubeClient}, recipeControllerConfig.Engine, recipeControllerConfig.ResourceClient, recipeControllerConfig.ConfigLoader) + return pr_ctrl.NewCreateOrUpdateResource[*datamodel.DaprPubSubBroker, datamodel.DaprPubSubBroker](options, &pubsub_proc.Processor{Client: options.KubeClient}, recipeControllerConfig.Engine, recipeControllerConfig.ConfigLoader) }, AsyncOperationTimeout: dapr_ctrl.AsyncCreateOrUpdateDaprPubSubBrokerTimeout, AsyncOperationRetryAfter: AsyncOperationRetryAfter, @@ -86,7 +86,7 @@ func SetupNamespace(recipeControllerConfig *controllerconfig.RecipeControllerCon rp_frontend.PrepareRadiusResource[*datamodel.DaprStateStore], }, AsyncJobController: func(options asyncctrl.Options) (asyncctrl.Controller, error) { - return pr_ctrl.NewCreateOrUpdateResource[*datamodel.DaprStateStore, datamodel.DaprStateStore](options, &statestore_proc.Processor{Client: options.KubeClient}, recipeControllerConfig.Engine, recipeControllerConfig.ResourceClient, recipeControllerConfig.ConfigLoader) + return pr_ctrl.NewCreateOrUpdateResource[*datamodel.DaprStateStore, datamodel.DaprStateStore](options, &statestore_proc.Processor{Client: options.KubeClient}, recipeControllerConfig.Engine, recipeControllerConfig.ConfigLoader) }, AsyncOperationTimeout: dapr_ctrl.AsyncCreateOrUpdateDaprStateStoreTimeout, AsyncOperationRetryAfter: AsyncOperationRetryAfter, @@ -96,7 +96,7 @@ func SetupNamespace(recipeControllerConfig *controllerconfig.RecipeControllerCon rp_frontend.PrepareRadiusResource[*datamodel.DaprStateStore], }, AsyncJobController: func(options asyncctrl.Options) (asyncctrl.Controller, error) { - return pr_ctrl.NewCreateOrUpdateResource[*datamodel.DaprStateStore, datamodel.DaprStateStore](options, &statestore_proc.Processor{Client: options.KubeClient}, recipeControllerConfig.Engine, recipeControllerConfig.ResourceClient, recipeControllerConfig.ConfigLoader) + return pr_ctrl.NewCreateOrUpdateResource[*datamodel.DaprStateStore, datamodel.DaprStateStore](options, &statestore_proc.Processor{Client: options.KubeClient}, recipeControllerConfig.Engine, recipeControllerConfig.ConfigLoader) }, AsyncOperationTimeout: dapr_ctrl.AsyncCreateOrUpdateDaprStateStoreTimeout, AsyncOperationRetryAfter: AsyncOperationRetryAfter, @@ -119,7 +119,7 @@ func SetupNamespace(recipeControllerConfig *controllerconfig.RecipeControllerCon rp_frontend.PrepareRadiusResource[*datamodel.DaprSecretStore], }, AsyncJobController: func(options asyncctrl.Options) (asyncctrl.Controller, error) { - return pr_ctrl.NewCreateOrUpdateResource[*datamodel.DaprSecretStore, datamodel.DaprSecretStore](options, &secretstore_proc.Processor{Client: options.KubeClient}, recipeControllerConfig.Engine, recipeControllerConfig.ResourceClient, recipeControllerConfig.ConfigLoader) + return pr_ctrl.NewCreateOrUpdateResource[*datamodel.DaprSecretStore, datamodel.DaprSecretStore](options, &secretstore_proc.Processor{Client: options.KubeClient}, recipeControllerConfig.Engine, recipeControllerConfig.ConfigLoader) }, AsyncOperationTimeout: dapr_ctrl.AsyncCreateOrUpdateDaprSecretStoreTimeout, AsyncOperationRetryAfter: AsyncOperationRetryAfter, @@ -129,7 +129,7 @@ func SetupNamespace(recipeControllerConfig *controllerconfig.RecipeControllerCon rp_frontend.PrepareRadiusResource[*datamodel.DaprSecretStore], }, AsyncJobController: func(options asyncctrl.Options) (asyncctrl.Controller, error) { - return pr_ctrl.NewCreateOrUpdateResource[*datamodel.DaprSecretStore, datamodel.DaprSecretStore](options, &secretstore_proc.Processor{Client: options.KubeClient}, recipeControllerConfig.Engine, recipeControllerConfig.ResourceClient, recipeControllerConfig.ConfigLoader) + return pr_ctrl.NewCreateOrUpdateResource[*datamodel.DaprSecretStore, datamodel.DaprSecretStore](options, &secretstore_proc.Processor{Client: options.KubeClient}, recipeControllerConfig.Engine, recipeControllerConfig.ConfigLoader) }, AsyncOperationTimeout: dapr_ctrl.AsyncCreateOrUpdateDaprSecretStoreTimeout, AsyncOperationRetryAfter: AsyncOperationRetryAfter, @@ -152,7 +152,7 @@ func SetupNamespace(recipeControllerConfig *controllerconfig.RecipeControllerCon rp_frontend.PrepareRadiusResource[*datamodel.DaprConfigurationStore], }, AsyncJobController: func(options asyncctrl.Options) (asyncctrl.Controller, error) { - return pr_ctrl.NewCreateOrUpdateResource[*datamodel.DaprConfigurationStore, datamodel.DaprConfigurationStore](options, &configurationstores_proc.Processor{Client: options.KubeClient}, recipeControllerConfig.Engine, recipeControllerConfig.ResourceClient, recipeControllerConfig.ConfigLoader) + return pr_ctrl.NewCreateOrUpdateResource[*datamodel.DaprConfigurationStore, datamodel.DaprConfigurationStore](options, &configurationstores_proc.Processor{Client: options.KubeClient}, recipeControllerConfig.Engine, recipeControllerConfig.ConfigLoader) }, AsyncOperationTimeout: dapr_ctrl.AsyncCreateOrUpdateDaprConfigurationStoreTimeout, AsyncOperationRetryAfter: AsyncOperationRetryAfter, @@ -162,7 +162,7 @@ func SetupNamespace(recipeControllerConfig *controllerconfig.RecipeControllerCon rp_frontend.PrepareRadiusResource[*datamodel.DaprConfigurationStore], }, AsyncJobController: func(options asyncctrl.Options) (asyncctrl.Controller, error) { - return pr_ctrl.NewCreateOrUpdateResource[*datamodel.DaprConfigurationStore, datamodel.DaprConfigurationStore](options, &configurationstores_proc.Processor{Client: options.KubeClient}, recipeControllerConfig.Engine, recipeControllerConfig.ResourceClient, recipeControllerConfig.ConfigLoader) + return pr_ctrl.NewCreateOrUpdateResource[*datamodel.DaprConfigurationStore, datamodel.DaprConfigurationStore](options, &configurationstores_proc.Processor{Client: options.KubeClient}, recipeControllerConfig.Engine, recipeControllerConfig.ConfigLoader) }, AsyncOperationTimeout: dapr_ctrl.AsyncCreateOrUpdateDaprConfigurationStoreTimeout, AsyncOperationRetryAfter: AsyncOperationRetryAfter, diff --git a/pkg/datastoresrp/datamodel/mongodatabase.go b/pkg/datastoresrp/datamodel/mongodatabase.go index ffe03fb5b2..8b4a286cca 100644 --- a/pkg/datastoresrp/datamodel/mongodatabase.go +++ b/pkg/datastoresrp/datamodel/mongodatabase.go @@ -112,19 +112,24 @@ func (r *MongoDatabase) OutputResources() []rpv1.OutputResource { } // ResourceMetadata returns the BasicResourceProperties of the Mongo database instance i.e. application resource metadata. -func (r *MongoDatabase) ResourceMetadata() *rpv1.BasicResourceProperties { +func (r *MongoDatabase) ResourceMetadata() rpv1.BasicResourcePropertiesAdapter { return &r.Properties.BasicResourceProperties } // Recipe returns the ResourceRecipe associated with the Mongo database instance, or nil if the // ResourceProvisioning is set to Manual. -func (r *MongoDatabase) Recipe() *portableresources.ResourceRecipe { +func (r *MongoDatabase) GetRecipe() *portableresources.ResourceRecipe { if r.Properties.ResourceProvisioning == portableresources.ResourceProvisioningManual { return nil } return &r.Properties.Recipe } +// SetRecipe sets the recipe information. +func (r *MongoDatabase) SetRecipe(recipe *portableresources.ResourceRecipe) { + r.Properties.Recipe = *recipe +} + // ResourceTypeName returns the resource type for Mongo database resource. func (mongoSecrets *MongoDatabaseSecrets) ResourceTypeName() string { return ds_ctrl.MongoDatabasesResourceType diff --git a/pkg/datastoresrp/datamodel/rediscache.go b/pkg/datastoresrp/datamodel/rediscache.go index 0ac7c6adb4..cb55d74173 100644 --- a/pkg/datastoresrp/datamodel/rediscache.go +++ b/pkg/datastoresrp/datamodel/rediscache.go @@ -50,7 +50,7 @@ func (r *RedisCache) OutputResources() []rpv1.OutputResource { } // ResourceMetadata returns the BasicResourceProperties of the Redis cache resource. -func (r *RedisCache) ResourceMetadata() *rpv1.BasicResourceProperties { +func (r *RedisCache) ResourceMetadata() rpv1.BasicResourcePropertiesAdapter { return &r.Properties.BasicResourceProperties } @@ -61,13 +61,18 @@ func (r *RedisCache) ResourceTypeName() string { // Recipe returns the ResourceRecipe from the Redis cache Properties if ResourceProvisioning is not set to Manual, // otherwise it returns nil. -func (r *RedisCache) Recipe() *portableresources.ResourceRecipe { +func (r *RedisCache) GetRecipe() *portableresources.ResourceRecipe { if r.Properties.ResourceProvisioning == portableresources.ResourceProvisioningManual { return nil } return &r.Properties.Recipe } +// SetRecipe sets the recipe information. +func (r *RedisCache) SetRecipe(recipe *portableresources.ResourceRecipe) { + r.Properties.Recipe = *recipe +} + // IsEmpty checks if the RedisCacheSecrets instance is empty or not. func (redisSecrets *RedisCacheSecrets) IsEmpty() bool { return redisSecrets == nil || *redisSecrets == RedisCacheSecrets{} diff --git a/pkg/datastoresrp/datamodel/sqldatabase.go b/pkg/datastoresrp/datamodel/sqldatabase.go index dd037650f9..4b3a1da063 100644 --- a/pkg/datastoresrp/datamodel/sqldatabase.go +++ b/pkg/datastoresrp/datamodel/sqldatabase.go @@ -29,13 +29,18 @@ import ( // Recipe returns the ResourceRecipe associated with the SQL database instance if the ResourceProvisioning is not // set to Manual, otherwise it returns nil. -func (sql *SqlDatabase) Recipe() *portableresources.ResourceRecipe { +func (sql *SqlDatabase) GetRecipe() *portableresources.ResourceRecipe { if sql.Properties.ResourceProvisioning == portableresources.ResourceProvisioningManual { return nil } return &sql.Properties.Recipe } +// SetRecipe sets the recipe information. +func (r *SqlDatabase) SetRecipe(recipe *portableresources.ResourceRecipe) { + r.Properties.Recipe = *recipe +} + // SqlDatabase represents SQL database portable resource. type SqlDatabase struct { v1.BaseResource @@ -59,7 +64,7 @@ func (r *SqlDatabase) OutputResources() []rpv1.OutputResource { } // ResourceMetadata returns the BasicResourceProperties of the SQL database resource. -func (r *SqlDatabase) ResourceMetadata() *rpv1.BasicResourceProperties { +func (r *SqlDatabase) ResourceMetadata() rpv1.BasicResourcePropertiesAdapter { return &r.Properties.BasicResourceProperties } diff --git a/pkg/datastoresrp/setup/setup.go b/pkg/datastoresrp/setup/setup.go index 2569871959..3b736a6190 100644 --- a/pkg/datastoresrp/setup/setup.go +++ b/pkg/datastoresrp/setup/setup.go @@ -55,7 +55,7 @@ func SetupNamespace(recipeControllerConfig *controllerconfig.RecipeControllerCon rp_frontend.PrepareRadiusResource[*datamodel.RedisCache], }, AsyncJobController: func(options asyncctrl.Options) (asyncctrl.Controller, error) { - return pr_ctrl.NewCreateOrUpdateResource[*datamodel.RedisCache, datamodel.RedisCache](options, &rds_proc.Processor{}, recipeControllerConfig.Engine, recipeControllerConfig.ResourceClient, recipeControllerConfig.ConfigLoader) + return pr_ctrl.NewCreateOrUpdateResource[*datamodel.RedisCache, datamodel.RedisCache](options, &rds_proc.Processor{}, recipeControllerConfig.Engine, recipeControllerConfig.ConfigLoader) }, AsyncOperationTimeout: ds_ctrl.AsyncCreateOrUpdateRedisCacheTimeout, AsyncOperationRetryAfter: AsyncOperationRetryAfter, @@ -65,7 +65,7 @@ func SetupNamespace(recipeControllerConfig *controllerconfig.RecipeControllerCon rp_frontend.PrepareRadiusResource[*datamodel.RedisCache], }, AsyncJobController: func(options asyncctrl.Options) (asyncctrl.Controller, error) { - return pr_ctrl.NewCreateOrUpdateResource[*datamodel.RedisCache, datamodel.RedisCache](options, &rds_proc.Processor{}, recipeControllerConfig.Engine, recipeControllerConfig.ResourceClient, recipeControllerConfig.ConfigLoader) + return pr_ctrl.NewCreateOrUpdateResource[*datamodel.RedisCache, datamodel.RedisCache](options, &rds_proc.Processor{}, recipeControllerConfig.Engine, recipeControllerConfig.ConfigLoader) }, AsyncOperationTimeout: ds_ctrl.AsyncCreateOrUpdateRedisCacheTimeout, AsyncOperationRetryAfter: AsyncOperationRetryAfter, @@ -93,7 +93,7 @@ func SetupNamespace(recipeControllerConfig *controllerconfig.RecipeControllerCon rp_frontend.PrepareRadiusResource[*datamodel.MongoDatabase], }, AsyncJobController: func(options asyncctrl.Options) (asyncctrl.Controller, error) { - return pr_ctrl.NewCreateOrUpdateResource[*datamodel.MongoDatabase, datamodel.MongoDatabase](options, &mongo_proc.Processor{}, recipeControllerConfig.Engine, recipeControllerConfig.ResourceClient, recipeControllerConfig.ConfigLoader) + return pr_ctrl.NewCreateOrUpdateResource[*datamodel.MongoDatabase, datamodel.MongoDatabase](options, &mongo_proc.Processor{}, recipeControllerConfig.Engine, recipeControllerConfig.ConfigLoader) }, AsyncOperationTimeout: ds_ctrl.AsyncCreateOrUpdateMongoDatabaseTimeout, AsyncOperationRetryAfter: AsyncOperationRetryAfter, @@ -103,7 +103,7 @@ func SetupNamespace(recipeControllerConfig *controllerconfig.RecipeControllerCon rp_frontend.PrepareRadiusResource[*datamodel.MongoDatabase], }, AsyncJobController: func(options asyncctrl.Options) (asyncctrl.Controller, error) { - return pr_ctrl.NewCreateOrUpdateResource[*datamodel.MongoDatabase, datamodel.MongoDatabase](options, &mongo_proc.Processor{}, recipeControllerConfig.Engine, recipeControllerConfig.ResourceClient, recipeControllerConfig.ConfigLoader) + return pr_ctrl.NewCreateOrUpdateResource[*datamodel.MongoDatabase, datamodel.MongoDatabase](options, &mongo_proc.Processor{}, recipeControllerConfig.Engine, recipeControllerConfig.ConfigLoader) }, AsyncOperationTimeout: ds_ctrl.AsyncCreateOrUpdateMongoDatabaseTimeout, AsyncOperationRetryAfter: AsyncOperationRetryAfter, @@ -131,7 +131,7 @@ func SetupNamespace(recipeControllerConfig *controllerconfig.RecipeControllerCon rp_frontend.PrepareRadiusResource[*datamodel.SqlDatabase], }, AsyncJobController: func(options asyncctrl.Options) (asyncctrl.Controller, error) { - return pr_ctrl.NewCreateOrUpdateResource[*datamodel.SqlDatabase, datamodel.SqlDatabase](options, &sql_proc.Processor{}, recipeControllerConfig.Engine, recipeControllerConfig.ResourceClient, recipeControllerConfig.ConfigLoader) + return pr_ctrl.NewCreateOrUpdateResource[*datamodel.SqlDatabase, datamodel.SqlDatabase](options, &sql_proc.Processor{}, recipeControllerConfig.Engine, recipeControllerConfig.ConfigLoader) }, AsyncOperationTimeout: ds_ctrl.AsyncCreateOrUpdateSqlDatabaseTimeout, AsyncOperationRetryAfter: AsyncOperationRetryAfter, @@ -141,7 +141,7 @@ func SetupNamespace(recipeControllerConfig *controllerconfig.RecipeControllerCon rp_frontend.PrepareRadiusResource[*datamodel.SqlDatabase], }, AsyncJobController: func(options asyncctrl.Options) (asyncctrl.Controller, error) { - return pr_ctrl.NewCreateOrUpdateResource[*datamodel.SqlDatabase, datamodel.SqlDatabase](options, &sql_proc.Processor{}, recipeControllerConfig.Engine, recipeControllerConfig.ResourceClient, recipeControllerConfig.ConfigLoader) + return pr_ctrl.NewCreateOrUpdateResource[*datamodel.SqlDatabase, datamodel.SqlDatabase](options, &sql_proc.Processor{}, recipeControllerConfig.Engine, recipeControllerConfig.ConfigLoader) }, AsyncOperationTimeout: ds_ctrl.AsyncCreateOrUpdateSqlDatabaseTimeout, AsyncOperationRetryAfter: AsyncOperationRetryAfter, diff --git a/pkg/dynamicrp/backend/dynamicresource_controller.go b/pkg/dynamicrp/backend/dynamicresource_controller.go index 0563d4dc6b..ef1a911326 100644 --- a/pkg/dynamicrp/backend/dynamicresource_controller.go +++ b/pkg/dynamicrp/backend/dynamicresource_controller.go @@ -19,9 +19,14 @@ package backend import ( "context" "fmt" + "strings" v1 "github.com/radius-project/radius/pkg/armrpc/api/v1" ctrl "github.com/radius-project/radius/pkg/armrpc/asyncoperation/controller" + "github.com/radius-project/radius/pkg/recipes/configloader" + "github.com/radius-project/radius/pkg/recipes/engine" + "github.com/radius-project/radius/pkg/ucp/api/v20231001preview" + "github.com/radius-project/radius/pkg/ucp/datamodel" "github.com/radius-project/radius/pkg/ucp/resources" ) @@ -30,12 +35,19 @@ import ( // This controller will use the capabilities and the operation to determine the correct controller to use. type DynamicResourceController struct { ctrl.BaseController + + ucp *v20231001preview.ClientFactory + engine engine.Engine + configurationLoader configloader.ConfigurationLoader } // NewDynamicResourceController creates a new DynamicResourcePutController. -func NewDynamicResourceController(opts ctrl.Options) (ctrl.Controller, error) { +func NewDynamicResourceController(opts ctrl.Options, ucp *v20231001preview.ClientFactory, engine engine.Engine, configurationLoader configloader.ConfigurationLoader) (ctrl.Controller, error) { return &DynamicResourceController{ - BaseController: ctrl.NewBaseAsyncController(opts), + BaseController: ctrl.NewBaseAsyncController(opts), + ucp: ucp, + engine: engine, + configurationLoader: configurationLoader, }, nil } @@ -44,7 +56,7 @@ func (c *DynamicResourceController) Run(ctx context.Context, request *ctrl.Reque // This is where we have the opportunity to branch out to different controllers based on: // - The operation type. (eg: PUT, DELETE, etc) // - The capabilities of the resource type. (eg: Does it support recipes?) - controller, err := c.selectController(request) + controller, err := c.selectController(ctx, request) if err != nil { return ctrl.Result{}, fmt.Errorf("failed to create controller: %w", err) } @@ -53,7 +65,7 @@ func (c *DynamicResourceController) Run(ctx context.Context, request *ctrl.Reque } -func (c *DynamicResourceController) selectController(request *ctrl.Request) (ctrl.Controller, error) { +func (c *DynamicResourceController) selectController(ctx context.Context, request *ctrl.Request) (ctrl.Controller, error) { ot, ok := v1.ParseOperationType(request.OperationType) if !ok { return nil, fmt.Errorf("invalid operation type: %q", request.OperationType) @@ -64,6 +76,11 @@ func (c *DynamicResourceController) selectController(request *ctrl.Request) (ctr return nil, fmt.Errorf("invalid resource ID: %q", request.ResourceID) } + resourceType, err := c.fetchResourceType(ctx, id) + if err != nil { + return nil, fmt.Errorf("failed to fetch resource type: %w", err) + } + options := ctrl.Options{ DatabaseClient: c.DatabaseClient(), ResourceType: id.Type(), @@ -71,10 +88,45 @@ func (c *DynamicResourceController) selectController(request *ctrl.Request) (ctr switch ot.Method { case v1.OperationDelete: + if hasCapability(resourceType, datamodel.CapabilitySupportsRecipes) { + return NewRecipeDeleteController(options, c.engine, c.configurationLoader) + } + return NewInertDeleteController(options) + case v1.OperationPut: + if hasCapability(resourceType, datamodel.CapabilitySupportsRecipes) { + return NewRecipePutController(options, c.engine, c.configurationLoader) + } + return NewInertPutController(options) + default: return nil, fmt.Errorf("unsupported operation type: %q", request.OperationType) } } + +func (c *DynamicResourceController) fetchResourceType(ctx context.Context, id resources.ID) (*v20231001preview.ResourceTypeResource, error) { + unqualifiedTypeName := strings.TrimPrefix(id.Type(), id.ProviderNamespace()+resources.SegmentSeparator) + response, err := c.ucp.NewResourceTypesClient().Get( + ctx, + id.ScopeSegments()[0].Name, + id.ProviderNamespace(), + unqualifiedTypeName, + nil) + if err != nil { + return nil, err + } + + return &response.ResourceTypeResource, nil +} + +func hasCapability(resourceType *v20231001preview.ResourceTypeResource, capability string) bool { + for _, c := range resourceType.Properties.Capabilities { + if c != nil && *c == capability { + return true + } + } + + return false +} diff --git a/pkg/dynamicrp/backend/dynamicresource_controller_test.go b/pkg/dynamicrp/backend/dynamicresource_controller_test.go index 7726165746..f0cdca2647 100644 --- a/pkg/dynamicrp/backend/dynamicresource_controller_test.go +++ b/pkg/dynamicrp/backend/dynamicresource_controller_test.go @@ -17,17 +17,37 @@ limitations under the License. package backend import ( + "context" + "fmt" + "net/http" "testing" + armpolicy "github.com/Azure/azure-sdk-for-go/sdk/azcore/arm/policy" + azfake "github.com/Azure/azure-sdk-for-go/sdk/azcore/fake" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/policy" v1 "github.com/radius-project/radius/pkg/armrpc/api/v1" ctrl "github.com/radius-project/radius/pkg/armrpc/asyncoperation/controller" + aztoken "github.com/radius-project/radius/pkg/azure/tokencredentials" + "github.com/radius-project/radius/pkg/to" + "github.com/radius-project/radius/pkg/ucp/api/v20231001preview" + "github.com/radius-project/radius/pkg/ucp/api/v20231001preview/fake" + "github.com/radius-project/radius/pkg/ucp/datamodel" + "github.com/radius-project/radius/pkg/ucp/resources" "github.com/stretchr/testify/require" ) +const ( + inertResourceType = "Applications.Test/testInertResources" + recipeResourceType = "Applications.Test/testRecipeResources" +) + func Test_DynamicResourceController_selectController(t *testing.T) { setup := func() *DynamicResourceController { - opts := ctrl.Options{} - controller, err := NewDynamicResourceController(opts) + ucp, err := testUCPClientFactory() + require.NoError(t, err) + + // The recipe engine and configuration loader are not used in this test. + controller, err := NewDynamicResourceController(ctrl.Options{}, ucp, nil, nil) require.NoError(t, err) return controller.(*DynamicResourceController) } @@ -35,11 +55,11 @@ func Test_DynamicResourceController_selectController(t *testing.T) { t.Run("inert PUT", func(t *testing.T) { controller := setup() request := &ctrl.Request{ - ResourceID: "/planes/radius/local/resourceGroups/test-group/providers/Applications.Test/testResources/test-resource", - OperationType: v1.OperationType{Type: "Applications.Test/testResources", Method: v1.OperationPut}.String(), + ResourceID: "/planes/radius/local/resourceGroups/test-group/providers/" + inertResourceType + "/test-resource", + OperationType: v1.OperationType{Type: inertResourceType, Method: v1.OperationPut}.String(), } - selected, err := controller.selectController(request) + selected, err := controller.selectController(context.Background(), request) require.NoError(t, err) require.IsType(t, &InertPutController{}, selected) @@ -48,26 +68,89 @@ func Test_DynamicResourceController_selectController(t *testing.T) { t.Run("inert DELETE", func(t *testing.T) { controller := setup() request := &ctrl.Request{ - ResourceID: "/planes/radius/local/resourceGroups/test-group/providers/Applications.Test/testResources/test-resource", - OperationType: v1.OperationType{Type: "Applications.Test/testResources", Method: v1.OperationDelete}.String(), + ResourceID: "/planes/radius/local/resourceGroups/test-group/providers/" + inertResourceType + "/test-resource", + OperationType: v1.OperationType{Type: inertResourceType, Method: v1.OperationDelete}.String(), } - selected, err := controller.selectController(request) + selected, err := controller.selectController(context.Background(), request) require.NoError(t, err) require.IsType(t, &InertDeleteController{}, selected) }) + t.Run("recipe PUT", func(t *testing.T) { + controller := setup() + request := &ctrl.Request{ + ResourceID: "/planes/radius/local/resourceGroups/test-group/providers/" + recipeResourceType + "/test-resource", + OperationType: v1.OperationType{Type: recipeResourceType, Method: v1.OperationPut}.String(), + } + + selected, err := controller.selectController(context.Background(), request) + require.NoError(t, err) + + require.IsType(t, &RecipePutController{}, selected) + }) + + t.Run("recipe DELETE", func(t *testing.T) { + controller := setup() + request := &ctrl.Request{ + ResourceID: "/planes/radius/local/resourceGroups/test-group/providers/" + recipeResourceType + "/test-resource", + OperationType: v1.OperationType{Type: recipeResourceType, Method: v1.OperationDelete}.String(), + } + + selected, err := controller.selectController(context.Background(), request) + require.NoError(t, err) + + require.IsType(t, &RecipeDeleteController{}, selected) + }) + t.Run("unknown operation", func(t *testing.T) { controller := setup() request := &ctrl.Request{ - ResourceID: "/planes/radius/local/resourceGroups/test-group/providers/Applications.Test/testResources/test-resource", - OperationType: v1.OperationType{Type: "Applications.Test/testResources", Method: v1.OperationGet}.String(), + ResourceID: "/planes/radius/local/resourceGroups/test-group/providers/" + inertResourceType + "/test-resource", + OperationType: v1.OperationType{Type: inertResourceType, Method: v1.OperationGet}.String(), } - selected, err := controller.selectController(request) + selected, err := controller.selectController(context.Background(), request) require.Error(t, err) - require.Equal(t, "unsupported operation type: \"APPLICATIONS.TEST/TESTRESOURCES|GET\"", err.Error()) + require.Equal(t, "unsupported operation type: \"APPLICATIONS.TEST/TESTINERTRESOURCES|GET\"", err.Error()) require.Nil(t, selected) }) } + +func testUCPClientFactory() (*v20231001preview.ClientFactory, error) { + resourceTypesServer := fake.ResourceTypesServer{ + Get: func(ctx context.Context, planeName, resourceProviderName, resourceTypeName string, options *v20231001preview.ResourceTypesClientGetOptions) (resp azfake.Responder[v20231001preview.ResourceTypesClientGetResponse], errResp azfake.ErrorResponder) { + resourceType := resourceProviderName + resources.SegmentSeparator + resourceTypeName + response := v20231001preview.ResourceTypesClientGetResponse{ + ResourceTypeResource: v20231001preview.ResourceTypeResource{ + Name: to.Ptr(resourceTypeName), + }, + } + + switch resourceType { + case inertResourceType: + response.Properties = &v20231001preview.ResourceTypeProperties{} + resp.SetResponse(http.StatusOK, response, nil) + return + case recipeResourceType: + response.Properties = &v20231001preview.ResourceTypeProperties{ + Capabilities: []*string{to.Ptr(datamodel.CapabilitySupportsRecipes)}, + } + resp.SetResponse(http.StatusOK, response, nil) + return + default: + errResp.SetError(fmt.Errorf("resource type %s not recognized", resourceType)) + return + } + }, + } + + return v20231001preview.NewClientFactory(&aztoken.AnonymousCredential{}, &armpolicy.ClientOptions{ + ClientOptions: policy.ClientOptions{ + Transport: fake.NewServerFactoryTransport(&fake.ServerFactory{ + ResourceTypesServer: resourceTypesServer, + }), + }, + }) +} diff --git a/pkg/dynamicrp/backend/dynamicresource_processor.go b/pkg/dynamicrp/backend/dynamicresource_processor.go new file mode 100644 index 0000000000..d8d5fff31e --- /dev/null +++ b/pkg/dynamicrp/backend/dynamicresource_processor.go @@ -0,0 +1,67 @@ +/* +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" + + "github.com/radius-project/radius/pkg/dynamicrp/datamodel" + "github.com/radius-project/radius/pkg/portableresources/processors" + rpv1 "github.com/radius-project/radius/pkg/rp/v1" +) + +var _ processors.ResourceProcessor[*datamodel.DynamicResource, datamodel.DynamicResource] = (*dynamicProcessor)(nil) + +type dynamicProcessor struct { +} + +func (d *dynamicProcessor) Delete(ctx context.Context, resource *datamodel.DynamicResource, options processors.Options) error { + return nil +} + +func (d *dynamicProcessor) Process(ctx context.Context, resource *datamodel.DynamicResource, options processors.Options) error { + computedValues := map[string]any{} + secretValues := map[string]rpv1.SecretValueReference{} + outputResources := []rpv1.OutputResource{} + status := rpv1.RecipeStatus{} + + validator := processors.NewValidator(&computedValues, &secretValues, &outputResources, &status) + + // TODO: loop over schema and add to validator - right now this bypasses validation. + for key, value := range options.RecipeOutput.Values { + value := value + validator.AddOptionalAnyField(key, &value) + } + for key, value := range options.RecipeOutput.Secrets { + value := value.(string) + validator.AddOptionalSecretField(key, &value) + } + + err := validator.SetAndValidate(options.RecipeOutput) + if err != nil { + return err + } + + err = resource.ApplyDeploymentOutput(rpv1.DeploymentOutput{DeployedOutputResources: outputResources, ComputedValues: computedValues, SecretValues: secretValues}) + if err != nil { + return err + } + + resource.SetRecipeStatus(status) + + return nil +} diff --git a/pkg/dynamicrp/backend/recipe_delete_controller.go b/pkg/dynamicrp/backend/recipe_delete_controller.go new file mode 100644 index 0000000000..947ea7684d --- /dev/null +++ b/pkg/dynamicrp/backend/recipe_delete_controller.go @@ -0,0 +1,54 @@ +/* +Copyright 2023 The Radius Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package backend + +import ( + "context" + + ctrl "github.com/radius-project/radius/pkg/armrpc/asyncoperation/controller" + recipecontroller "github.com/radius-project/radius/pkg/portableresources/backend/controller" + "github.com/radius-project/radius/pkg/recipes/configloader" + "github.com/radius-project/radius/pkg/recipes/engine" +) + +// RecipeDeleteController is the async operation controller to perform DELETE processing on "recipe" dynamic resources. +type RecipeDeleteController struct { + ctrl.BaseController + opts ctrl.Options + engine engine.Engine + configurationLoader configloader.ConfigurationLoader +} + +// NewRecipeDeleteController creates a new RecipeDeleteController. +func NewRecipeDeleteController(opts ctrl.Options, engine engine.Engine, configurationLoader configloader.ConfigurationLoader) (ctrl.Controller, error) { + return &RecipeDeleteController{ + BaseController: ctrl.NewBaseAsyncController(opts), + opts: opts, + engine: engine, + configurationLoader: configurationLoader, + }, nil +} + +// Run implements the async controller interface. +func (c *RecipeDeleteController) Run(ctx context.Context, request *ctrl.Request) (ctrl.Result, error) { + wrapped, err := recipecontroller.NewDeleteResource(c.opts, &dynamicProcessor{}, c.engine, c.configurationLoader) + if err != nil { + return ctrl.Result{}, err + } + + return wrapped.Run(ctx, request) +} diff --git a/pkg/dynamicrp/backend/recipe_put_controller.go b/pkg/dynamicrp/backend/recipe_put_controller.go new file mode 100644 index 0000000000..a25366f1be --- /dev/null +++ b/pkg/dynamicrp/backend/recipe_put_controller.go @@ -0,0 +1,54 @@ +/* +Copyright 2023 The Radius Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package backend + +import ( + "context" + + ctrl "github.com/radius-project/radius/pkg/armrpc/asyncoperation/controller" + recipecontroller "github.com/radius-project/radius/pkg/portableresources/backend/controller" + "github.com/radius-project/radius/pkg/recipes/configloader" + "github.com/radius-project/radius/pkg/recipes/engine" +) + +// RecipePutController is the async operation controller to perform PUT processing on "recipe" dynamic resources. +type RecipePutController struct { + ctrl.BaseController + opts ctrl.Options + engine engine.Engine + configurationLoader configloader.ConfigurationLoader +} + +// NewRecipePutController creates a new RecipePutController. +func NewRecipePutController(opts ctrl.Options, engine engine.Engine, configurationLoader configloader.ConfigurationLoader) (ctrl.Controller, error) { + return &RecipePutController{ + BaseController: ctrl.NewBaseAsyncController(opts), + opts: opts, + engine: engine, + configurationLoader: configurationLoader, + }, nil +} + +// Run implements the async controller interface. +func (c *RecipePutController) Run(ctx context.Context, request *ctrl.Request) (ctrl.Result, error) { + wrapped, err := recipecontroller.NewCreateOrUpdateResource(c.opts, &dynamicProcessor{}, c.engine, c.configurationLoader) + if err != nil { + return ctrl.Result{}, err + } + + return wrapped.Run(ctx, request) +} diff --git a/pkg/dynamicrp/backend/service.go b/pkg/dynamicrp/backend/service.go index ce3569b1dc..c88648ef44 100644 --- a/pkg/dynamicrp/backend/service.go +++ b/pkg/dynamicrp/backend/service.go @@ -21,6 +21,9 @@ import ( ctrl "github.com/radius-project/radius/pkg/armrpc/asyncoperation/controller" "github.com/radius-project/radius/pkg/armrpc/asyncoperation/worker" + aztoken "github.com/radius-project/radius/pkg/azure/tokencredentials" + "github.com/radius-project/radius/pkg/sdk" + "github.com/radius-project/radius/pkg/ucp/api/v20231001preview" "github.com/radius-project/radius/pkg/dynamicrp" "github.com/radius-project/radius/pkg/recipes/engine" @@ -92,5 +95,12 @@ func (w *Service) registerControllers() error { DatabaseClient: w.Service.DatabaseClient, } - return w.Service.Controllers().RegisterDefault(NewDynamicResourceController, options) + ucp, err := v20231001preview.NewClientFactory(&aztoken.AnonymousCredential{}, sdk.NewClientOptions(w.options.UCP)) + if err != nil { + return err + } + + return w.Service.Controllers().RegisterDefault(func(opts ctrl.Options) (ctrl.Controller, error) { + return NewDynamicResourceController(opts, ucp, w.recipes, w.options.Recipes.ConfigurationLoader) + }, options) } diff --git a/pkg/dynamicrp/datamodel/dynamicresource.go b/pkg/dynamicrp/datamodel/dynamicresource.go index 6b33c2e72f..5b75ea3edb 100644 --- a/pkg/dynamicrp/datamodel/dynamicresource.go +++ b/pkg/dynamicrp/datamodel/dynamicresource.go @@ -17,11 +17,20 @@ limitations under the License. package datamodel import ( + "encoding/json" + v1 "github.com/radius-project/radius/pkg/armrpc/api/v1" + "github.com/radius-project/radius/pkg/portableresources" + pbdatamodel "github.com/radius-project/radius/pkg/portableresources/datamodel" + rpv1 "github.com/radius-project/radius/pkg/rp/v1" ) var _ v1.ResourceDataModel = (*DynamicResource)(nil) +// Required for recipes +var _ rpv1.RadiusResourceModel = (*DynamicResource)(nil) +var _ pbdatamodel.RecipeDataModel = (*DynamicResource)(nil) + // DynamicResource is used as the data model for dynamic resources (UDT). // // A dynamic resource uses a user-provided OpenAPI specification to define the resource schema. Therefore, @@ -32,3 +41,231 @@ type DynamicResource struct { // Properties stores the properties of the resource being tracked. Properties map[string]any `json:"properties"` } + +func (d *DynamicResource) Status() map[string]any { + if d.Properties == nil { + d.Properties = map[string]any{} + } + + // We make the assumption that the status is a map[string]any. + // If users define the status as something other than a map[string]any, that just won't work. + // + // Therefore we overwrite it. + obj, ok := d.Properties["status"] + if !ok { + d.Properties["status"] = map[string]any{} + return map[string]any{} + } + + status, ok := obj.(map[string]any) + if !ok { + d.Properties["status"] = map[string]any{} + return map[string]any{} + } + + return status +} + +// GetRecipe implements datamodel.RecipeDataModel. +func (d *DynamicResource) GetRecipe() *portableresources.ResourceRecipe { + if d.Properties == nil { + return &portableresources.ResourceRecipe{} + } + + obj, ok := d.Properties["recipe"] + if !ok { + return &portableresources.ResourceRecipe{} + } + + recipe, ok := obj.(map[string]any) + if !ok { + return &portableresources.ResourceRecipe{} + } + + // This is the best we can do. We require all of the data we store to be JSON-marshallable, + // and the data should have already been validated when it was set. + bs, err := json.Marshal(recipe) + if err != nil { + panic("failed to marshal recipe: " + err.Error()) + } + + result := portableresources.ResourceRecipe{} + err = json.Unmarshal(bs, &result) + if err != nil { + panic("failed to unmarshal recipe: " + err.Error()) + } + + return &result +} + +// SetRecipe implements datamodel.RecipeDataModel. +func (d *DynamicResource) SetRecipe(recipe *portableresources.ResourceRecipe) { + // This is the best we can do. We designed the ResourceRecipe type to be JSON-marshallable. + bs, err := json.Marshal(recipe) + if err != nil { + panic("failed to marshal recipe: " + err.Error()) + } + + store := map[string]any{} + err = json.Unmarshal(bs, &store) + if err != nil { + panic("failed to unmarshal recipe: " + err.Error()) + } + + if d.Properties == nil { + d.Properties = map[string]any{} + } + + d.Properties["recipe"] = store +} + +var _ rpv1.BasicResourcePropertiesAdapter = (*dynamicResourceBasicPropertiesAdapter)(nil) + +// ApplyDeploymentOutput implements v1.RadiusResourceModel. +func (d *DynamicResource) ApplyDeploymentOutput(deploymentOutput rpv1.DeploymentOutput) error { + status := d.Status() + + // This is the best we can do. We require all of the data we store to be JSON-marshallable. + bs, err := json.Marshal(deploymentOutput.DeployedOutputResources) + if err != nil { + panic("failed to marshal output resources: " + err.Error()) + } + + outputResources := []map[string]any{} + err = json.Unmarshal(bs, &outputResources) + if err != nil { + panic("failed to unmarshal output resources: " + err.Error()) + } + + status["outputResources"] = outputResources + if len(outputResources) == 0 { + delete(status, "outputResources") + } + + // We store computed values and secrets in the status under "binding". + // + // TODO: in the future we want to store secrets in their own resource (Applications.Core/secretStores). + // + // This will require changes to the recipe engine. The datamodel is the wrong place to manipulate resources. + // So for now, just store them as part of the binding. + binding := map[string]any{} + for key, value := range deploymentOutput.ComputedValues { + binding[key] = value + } + for key, value := range deploymentOutput.SecretValues { + binding[key] = value.Value + } + + status["binding"] = binding + if len(binding) == 0 { + delete(status, "binding") + } + + return nil +} + +// OutputResources implements v1.RadiusResourceModel. +func (d *DynamicResource) OutputResources() []rpv1.OutputResource { + return d.ResourceMetadata().GetResourceStatus().OutputResources +} + +// ResourceMetadata implements v1.RadiusResourceModel. +func (d *DynamicResource) ResourceMetadata() rpv1.BasicResourcePropertiesAdapter { + return &dynamicResourceBasicPropertiesAdapter{resource: d} +} + +// SetRecipeStatus sets the recipe status of the resource. +func (d *DynamicResource) SetRecipeStatus(recipeStatus rpv1.RecipeStatus) { + rm := d.ResourceMetadata() + status := rm.GetResourceStatus().DeepCopy() + status.Recipe = &recipeStatus + rm.SetResourceStatus(status) +} + +// dynamicResourceBasicPropertiesAdapter adapts a DynamicResource to the BasicResourcePropertiesAdapter interface +// so it can be used with our shared controllers. +type dynamicResourceBasicPropertiesAdapter struct { + resource *DynamicResource +} + +// ApplicationID implements v1.BasicResourcePropertiesAdapter. +func (d *dynamicResourceBasicPropertiesAdapter) ApplicationID() string { + if d.resource.Properties == nil { + return "" + } + + obj, ok := d.resource.Properties["application"] + if !ok { + return "" + } + + str, ok := obj.(string) + if !ok { + return "" + } + + return str +} + +// EnvironmentID implements v1.BasicResourcePropertiesAdapter. +func (d *dynamicResourceBasicPropertiesAdapter) EnvironmentID() string { + if d.resource.Properties == nil { + return "" + } + + obj, ok := d.resource.Properties["environment"] + if !ok { + return "" + } + + str, ok := obj.(string) + if !ok { + return "" + } + + return str +} + +// GetResourceStatus implements v1.BasicResourcePropertiesAdapter. +func (d *dynamicResourceBasicPropertiesAdapter) GetResourceStatus() rpv1.ResourceStatus { + // This is the best we can do. We require all of the data we store to be JSON-marshallable, + // and the data should have already been validated when it was set. + bs, err := json.Marshal(d.resource.Status()) + if err != nil { + panic("failed to marshal status: " + err.Error()) + } + + result := rpv1.ResourceStatus{} + err = json.Unmarshal(bs, &result) + if err != nil { + panic("failed to unmarshal status: " + err.Error()) + } + + return result +} + +// SetResourceStatus implements v1.BasicResourcePropertiesAdapter. +func (d *dynamicResourceBasicPropertiesAdapter) SetResourceStatus(status rpv1.ResourceStatus) { + // This is the best we can do. We designed the ResourceStatus type to be JSON-marshallable. + bs, err := json.Marshal(status) + if err != nil { + panic("failed to marshal status: " + err.Error()) + } + + marshaledResourceStatus := map[string]any{} + err = json.Unmarshal(bs, &marshaledResourceStatus) + if err != nil { + panic("failed to unmarshal status: " + err.Error()) + } + + if d.resource.Properties == nil { + d.resource.Properties = map[string]any{} + } + + // This is tricky because users are allowed to add their own fields to ".properties.status". + // We need to do a merge instead of a simple overwrite. + existingStatus := d.resource.Status() + for key, value := range marshaledResourceStatus { + existingStatus[key] = value + } +} diff --git a/pkg/dynamicrp/integrationtest/dynamic/operations_test.go b/pkg/dynamicrp/integrationtest/dynamic/operations_test.go index a69d8cfc5d..1083f78cd5 100644 --- a/pkg/dynamicrp/integrationtest/dynamic/operations_test.go +++ b/pkg/dynamicrp/integrationtest/dynamic/operations_test.go @@ -40,7 +40,7 @@ func Test_Dynamic_OperationResultAndStatus(t *testing.T) { // Setup a plane & resource provider & location plane := createRadiusPlane(ucp) createResourceProvider(ucp) - createLocation(ucp) + createLocation(ucp, "testResources") // Now we can make a request to the operation result/status endpoints. operationName := uuid.New().String() diff --git a/pkg/dynamicrp/integrationtest/dynamic/providers_test.go b/pkg/dynamicrp/integrationtest/dynamic/providers_test.go deleted file mode 100644 index 1554c14d47..0000000000 --- a/pkg/dynamicrp/integrationtest/dynamic/providers_test.go +++ /dev/null @@ -1,223 +0,0 @@ -/* -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" - ucptesthost "github.com/radius-project/radius/pkg/ucp/testhost" - "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" - - exampleResourceID = exampleResourceGroupID + "/providers/" + resourceProviderNamespace + "/" + resourceTypeName + "/" + exampleResourceName - exampleResourceURL = exampleResourceID + "?api-version=" + apiVersion -) - -// This test covers the lifecycle of a dynamic resource. -func Test_Dynamic_Resource_Lifecycle(t *testing.T) { - _, ucp := testhost.Start(t) - - // Setup a resource provider (Applications.Test/exampleResources) - createRadiusPlane(ucp) - createResourceProvider(ucp) - createResourceType(ucp) - createAPIVersion(ucp) - createLocation(ucp) - - // Setup a resource group where we can interact with the new resource type. - createResourceGroup(ucp) - - // Now let's test the basic CRUD operations on the new resource type. - // - // This resource type DOES NOT support recipes, so it's "inert" and doesn't do anything in the backend. - resource := map[string]any{ - "properties": map[string]any{ - "foo": "bar", - }, - "tags": map[string]string{ - "costcenter": "12345", - }, - } - - // Create the resource - response := ucp.MakeTypedRequest(http.MethodPut, exampleResourceURL, resource) - response.WaitForOperationComplete(nil) - - // Now lets verify the resource was created successfully. - - expectedResource := map[string]any{ - "id": "/planes/radius/testing/resourcegroups/test-group/providers/Applications.Test/exampleResources/my-example", - "location": "global", - "name": "my-example", - "properties": map[string]any{ - "foo": "bar", - "provisioningState": "Succeeded", - }, - "tags": map[string]any{ - "costcenter": "12345", - }, - "type": "Applications.Test/exampleResources", - } - - expectedList := map[string]any{ - "value": []any{expectedResource}, - } - - // GET (single) - response = ucp.MakeRequest(http.MethodGet, exampleResourceURL, nil) - response.EqualsValue(200, expectedResource) - - // GET (list at plane-scope) - response = ucp.MakeRequest(http.MethodGet, "/planes/radius/testing/resourcegroups/test-group/providers/Applications.Test/exampleResources"+"?api-version="+apiVersion, nil) - response.EqualsValue(200, expectedList) - - // GET (list at resourcegroup-scope) - response = ucp.MakeRequest(http.MethodGet, "/planes/radius/testing/providers/Applications.Test/exampleResources"+"?api-version="+apiVersion, nil) - response.EqualsValue(200, expectedList) - - // Now lets delete the resource - response = ucp.MakeRequest(http.MethodDelete, exampleResourceURL, nil) - response.WaitForOperationComplete(nil) - - // Now we should get a 404 when trying to get the resource - response = ucp.MakeRequest(http.MethodGet, exampleResourceURL, nil) - response.EqualsErrorCode(404, v1.CodeNotFound) -} - -func createRadiusPlane(server *ucptesthost.TestHost) 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 *ucptesthost.TestHost) { - 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 *ucptesthost.TestHost) { - ctx := context.Background() - - resourceType := v20231001preview.ResourceTypeResource{ - Properties: &v20231001preview.ResourceTypeProperties{}, - } - - 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 *ucptesthost.TestHost) { - 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 *ucptesthost.TestHost) { - ctx := context.Background() - - location := v20231001preview.LocationResource{ - Properties: &v20231001preview.LocationProperties{ - ResourceTypes: map[string]*v20231001preview.LocationResourceType{ - resourceTypeName: { - APIVersions: map[string]map[string]any{ - apiVersion: {}, - }, - }, - }, - }, - } - - 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 *ucptesthost.TestHost) { - 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/resource_test.go b/pkg/dynamicrp/integrationtest/dynamic/resource_test.go new file mode 100644 index 0000000000..1acb1e007f --- /dev/null +++ b/pkg/dynamicrp/integrationtest/dynamic/resource_test.go @@ -0,0 +1,401 @@ +/* +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" + "github.com/radius-project/radius/pkg/dynamicrp/testhost" + "github.com/radius-project/radius/pkg/recipes" + "github.com/radius-project/radius/pkg/recipes/configloader" + "github.com/radius-project/radius/pkg/recipes/driver" + rpv1 "github.com/radius-project/radius/pkg/rp/v1" + "github.com/radius-project/radius/pkg/to" + "github.com/radius-project/radius/pkg/ucp/api/v20231001preview" + "github.com/radius-project/radius/pkg/ucp/datamodel" + ucptesthost "github.com/radius-project/radius/pkg/ucp/testhost" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" +) + +const ( + radiusPlaneName = "testing" + locationName = v1.LocationGlobal + resourceGroupName = "test-group" + testPlaneID = "/planes/radius/" + radiusPlaneName + testResourceGroupID = testPlaneID + "/resourceGroups/test-group" + + apiVersion = "2024-01-01" + resourceProviderNamespace = "Applications.Test" + inertResourceTypeName = "exampleInertResources" + recipeResourceTypeName = "exampleRecipeResources" + + testInertResourceName = "my-inert-example" + testInertResourceID = testResourceGroupID + "/providers/" + resourceProviderNamespace + "/" + inertResourceTypeName + "/" + testInertResourceName + testInertResourceURL = testInertResourceID + "?api-version=" + apiVersion + + testRecipeResourceName = "my-recipe-example" + testRecipeResourceID = testResourceGroupID + "/providers/" + resourceProviderNamespace + "/" + recipeResourceTypeName + "/" + testRecipeResourceName + testRecipeResourceURL = testRecipeResourceID + "?api-version=" + apiVersion +) + +// This test covers the lifecycle of a dynamic resource with an "inert" lifecycle (not recipes). +func Test_Dynamic_Resource_Inert_Lifecycle(t *testing.T) { + _, ucp := testhost.Start(t) + + // Setup a resource provider (Applications.Test/exampleInertResources) + createRadiusPlane(ucp) + createResourceProvider(ucp) + createInertResourceType(ucp) + createAPIVersion(ucp, inertResourceTypeName) + createLocation(ucp, inertResourceTypeName) + + // Setup a resource group where we can interact with the new resource type. + createResourceGroup(ucp) + + // Now let's test the basic CRUD operations on the new resource type. + // + // This resource type DOES NOT support recipes, so it's "inert" and doesn't do anything in the backend. + resource := map[string]any{ + "properties": map[string]any{ + "foo": "bar", + }, + "tags": map[string]string{ + "costcenter": "12345", + }, + } + + // Create the resource + response := ucp.MakeTypedRequest(http.MethodPut, testInertResourceURL, resource) + response.WaitForOperationComplete(nil) + + // Now lets verify the resource was created successfully. + + expectedResource := map[string]any{ + "id": "/planes/radius/testing/resourcegroups/test-group/providers/Applications.Test/exampleInertResources/my-inert-example", + "location": "global", + "name": "my-inert-example", + "properties": map[string]any{ + "foo": "bar", + "provisioningState": "Succeeded", + }, + "tags": map[string]any{ + "costcenter": "12345", + }, + "type": "Applications.Test/exampleInertResources", + } + + expectedList := map[string]any{ + "value": []any{expectedResource}, + } + + // GET (single) + response = ucp.MakeRequest(http.MethodGet, testInertResourceURL, nil) + response.EqualsValue(200, expectedResource) + + // GET (list at plane-scope) + response = ucp.MakeRequest(http.MethodGet, "/planes/radius/testing/resourcegroups/test-group/providers/Applications.Test/exampleInertResources"+"?api-version="+apiVersion, nil) + response.EqualsValue(200, expectedList) + + // GET (list at resourcegroup-scope) + response = ucp.MakeRequest(http.MethodGet, "/planes/radius/testing/providers/Applications.Test/exampleInertResources"+"?api-version="+apiVersion, nil) + response.EqualsValue(200, expectedList) + + // Now lets delete the resource + response = ucp.MakeRequest(http.MethodDelete, testInertResourceURL, nil) + response.WaitForOperationComplete(nil) + + // Now we should get a 404 when trying to get the resource + response = ucp.MakeRequest(http.MethodGet, testInertResourceURL, nil) + response.EqualsErrorCode(404, v1.CodeNotFound) +} + +func Test_Dynamic_Resource_Recipe_Lifecycle(t *testing.T) { + ctrl := gomock.NewController(t) + mockDriver := driver.NewMockDriver(ctrl) + mockConfigLoader := configloader.NewMockConfigurationLoader(ctrl) + + _, ucp := testhost.Start(t, testhost.TestHostOptionFunc(func(options *dynamicrp.Options) { + // Register a mock driver for the "test" recipe driver. + options.Recipes.Drivers = map[string]func(options *dynamicrp.Options) (driver.Driver, error){ + "test": func(options *dynamicrp.Options) (driver.Driver, error) { + return mockDriver, nil + }, + } + + // Replace the configuration loader with a mock. + // + // We don't have an environment in this test because applications-rp isn't part of the + // integration testing framework. + options.Recipes.ConfigurationLoader = mockConfigLoader + })) + + // Setup a resource provider (Applications.Test/exampleRecipeResources) + createRadiusPlane(ucp) + createResourceProvider(ucp) + createRecipeResourceType(ucp) + createAPIVersion(ucp, recipeResourceTypeName) + createLocation(ucp, recipeResourceTypeName) + + // Setup a resource group where we can interact with the new resource type. + createResourceGroup(ucp) + + // Setup a recipe for the new resource type. + mockConfigLoader.EXPECT(). + LoadRecipe(gomock.Any(), gomock.Any()). + Return(&recipes.EnvironmentDefinition{ + Name: "default", + Driver: "test", + ResourceType: "Applications.Test/exampleRecipeResources", + TemplatePath: "test-path", + TemplateVersion: "test-version", + }, nil). + AnyTimes() + mockConfigLoader.EXPECT(). + LoadConfiguration(gomock.Any(), gomock.Any()). + Return(&recipes.Configuration{}, nil). + AnyTimes() + + mockDriver.EXPECT(). + Execute(gomock.Any(), gomock.Any()). + Return(&recipes.RecipeOutput{ + Resources: []string{"/planes/example/testing/providers/Test.Namespace/testResource/example"}, + Values: map[string]any{ + "port": 8080, + "hostname": "example.com", + }, + Secrets: map[string]any{ + "password": "v3ryS3cr3t", + }, + Status: &rpv1.RecipeStatus{ + TemplateKind: "test", + TemplatePath: "test-path", + TemplateVersion: "test-version", + }, + }, nil). + Times(1) + + mockDriver.EXPECT(). + Delete(gomock.Any(), gomock.Any()). + Return(nil). + Times(1) + + // Now let's test the basic CRUD operations on the new resource type. + // + // This resource type supports recipes, so we expect to see some additional output. + resource := map[string]any{ + "properties": map[string]any{ + "foo": "bar", + }, + "tags": map[string]string{ + "costcenter": "12345", + }, + } + + // Create the resource + response := ucp.MakeTypedRequest(http.MethodPut, testRecipeResourceURL, resource) + response.WaitForOperationComplete(nil) + + // Now lets verify the resource was created successfully. + expectedResource := map[string]any{ + "id": "/planes/radius/testing/resourcegroups/test-group/providers/Applications.Test/exampleRecipeResources/my-recipe-example", + "location": "global", + "name": "my-recipe-example", + "properties": map[string]any{ + "foo": "bar", + "provisioningState": "Succeeded", + "recipe": map[string]any{ + "recipeStatus": "success", + }, + "status": map[string]any{ + "binding": map[string]any{ + "port": float64(8080), // This is an artifact of the JSON unmarshal process. It's wierd but intended. + "hostname": "example.com", + "password": "v3ryS3cr3t", // TODO: See comments in dynamicresource.go + }, + "outputResources": []any{ + map[string]any{ + "id": "/planes/example/testing/providers/Test.Namespace/testResource/example", + "localID": "", + "radiusManaged": true, + }, + }, + "recipe": map[string]any{ + "templateKind": "test", + "templatePath": "test-path", + "templateVersion": "test-version", + }, + }, + }, + "tags": map[string]any{ + "costcenter": "12345", + }, + "type": "Applications.Test/exampleRecipeResources", + } + + expectedList := map[string]any{ + "value": []any{expectedResource}, + } + + // GET (single) + response = ucp.MakeRequest(http.MethodGet, testRecipeResourceURL, nil) + response.EqualsValue(200, expectedResource) + + // GET (list at plane-scope) + response = ucp.MakeRequest(http.MethodGet, "/planes/radius/testing/resourcegroups/test-group/providers/Applications.Test/exampleRecipeResources"+"?api-version="+apiVersion, nil) + response.EqualsValue(200, expectedList) + + // GET (list at resourcegroup-scope) + response = ucp.MakeRequest(http.MethodGet, "/planes/radius/testing/providers/Applications.Test/exampleRecipeResources"+"?api-version="+apiVersion, nil) + response.EqualsValue(200, expectedList) + + // Now lets delete the resource + response = ucp.MakeRequest(http.MethodDelete, testRecipeResourceURL, nil) + response.WaitForOperationComplete(nil) + + // Now we should get a 404 when trying to get the resource + response = ucp.MakeRequest(http.MethodGet, testRecipeResourceURL, nil) + response.EqualsErrorCode(404, v1.CodeNotFound) +} + +func createRadiusPlane(server *ucptesthost.TestHost) 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 *ucptesthost.TestHost) { + 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 createInertResourceType(server *ucptesthost.TestHost) { + ctx := context.Background() + + resourceType := v20231001preview.ResourceTypeResource{ + Properties: &v20231001preview.ResourceTypeProperties{}, + } + + client := server.UCP().NewResourceTypesClient() + poller, err := client.BeginCreateOrUpdate(ctx, radiusPlaneName, resourceProviderNamespace, inertResourceTypeName, resourceType, nil) + require.NoError(server.T(), err) + + _, err = poller.PollUntilDone(ctx, nil) + require.NoError(server.T(), err) +} + +func createRecipeResourceType(server *ucptesthost.TestHost) { + ctx := context.Background() + + resourceType := v20231001preview.ResourceTypeResource{ + Properties: &v20231001preview.ResourceTypeProperties{ + Capabilities: []*string{ + to.Ptr(datamodel.CapabilitySupportsRecipes), + }, + }, + } + + client := server.UCP().NewResourceTypesClient() + poller, err := client.BeginCreateOrUpdate(ctx, radiusPlaneName, resourceProviderNamespace, recipeResourceTypeName, resourceType, nil) + require.NoError(server.T(), err) + + _, err = poller.PollUntilDone(ctx, nil) + require.NoError(server.T(), err) +} + +func createAPIVersion(server *ucptesthost.TestHost, resourceType string) { + ctx := context.Background() + + apiVersionResource := v20231001preview.APIVersionResource{ + Properties: &v20231001preview.APIVersionProperties{}, + } + + client := server.UCP().NewAPIVersionsClient() + poller, err := client.BeginCreateOrUpdate(ctx, radiusPlaneName, resourceProviderNamespace, resourceType, apiVersion, apiVersionResource, nil) + require.NoError(server.T(), err) + + _, err = poller.PollUntilDone(ctx, nil) + require.NoError(server.T(), err) +} + +func createLocation(server *ucptesthost.TestHost, resourceType string) { + ctx := context.Background() + + location := v20231001preview.LocationResource{ + Properties: &v20231001preview.LocationProperties{ + ResourceTypes: map[string]*v20231001preview.LocationResourceType{ + resourceType: { + APIVersions: map[string]map[string]any{ + apiVersion: {}, + }, + }, + }, + }, + } + + 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 *ucptesthost.TestHost) { + 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/messagingrp/datamodel/rabbitmq.go b/pkg/messagingrp/datamodel/rabbitmq.go index d091a49f7d..d465c425cc 100644 --- a/pkg/messagingrp/datamodel/rabbitmq.go +++ b/pkg/messagingrp/datamodel/rabbitmq.go @@ -50,7 +50,7 @@ func (r *RabbitMQQueue) OutputResources() []rpv1.OutputResource { } // ResourceMetadata returns the BasicResourceProperties of the RabbitMQQueue instance. -func (r *RabbitMQQueue) ResourceMetadata() *rpv1.BasicResourceProperties { +func (r *RabbitMQQueue) ResourceMetadata() rpv1.BasicResourcePropertiesAdapter { return &r.Properties.BasicResourceProperties } @@ -87,13 +87,18 @@ func (rabbitmq RabbitMQSecrets) ResourceTypeName() string { // Recipe returns the recipe for the RabbitMQQueue. It gets the ResourceRecipe associated with the RabbitMQQueue instance // if the ResourceProvisioning is not set to Manual, otherwise it returns nil. -func (r *RabbitMQQueue) Recipe() *portableresources.ResourceRecipe { +func (r *RabbitMQQueue) GetRecipe() *portableresources.ResourceRecipe { if r.Properties.ResourceProvisioning == portableresources.ResourceProvisioningManual { return nil } return &r.Properties.Recipe } +// SetRecipe sets the recipe information. +func (r *RabbitMQQueue) SetRecipe(recipe *portableresources.ResourceRecipe) { + r.Properties.Recipe = *recipe +} + // VerifyInputs checks if the queue is provided when resourceProvisioning is set to manual and returns an error if not. func (r *RabbitMQQueue) VerifyInputs() error { properties := r.Properties diff --git a/pkg/messagingrp/processors/rabbitmqqueues/processor.go b/pkg/messagingrp/processors/rabbitmqqueues/processor.go index 4bce4aa215..a871c28009 100644 --- a/pkg/messagingrp/processors/rabbitmqqueues/processor.go +++ b/pkg/messagingrp/processors/rabbitmqqueues/processor.go @@ -38,7 +38,7 @@ type Processor struct { // Process implements the processors.Processor interface for RabbitMQQueue resources. It validates the required fields // and computed secret fields of the RabbitMQQueue resource and returns an error if validation fails. func (p *Processor) Process(ctx context.Context, resource *msg_dm.RabbitMQQueue, options processors.Options) error { - validator := processors.NewValidator(&resource.ComputedValues, &resource.SecretValues, &resource.Properties.Status.OutputResources, resource.ResourceMetadata().Status.Recipe) + validator := processors.NewValidator(&resource.ComputedValues, &resource.SecretValues, &resource.Properties.Status.OutputResources, resource.Properties.Status.Recipe) validator.AddResourcesField(&resource.Properties.Resources) validator.AddRequiredStringField(Queue, &resource.Properties.Queue) validator.AddRequiredStringField(renderers.Host, &resource.Properties.Host) diff --git a/pkg/messagingrp/setup/setup.go b/pkg/messagingrp/setup/setup.go index c3a5bf5dbe..d312c849a8 100644 --- a/pkg/messagingrp/setup/setup.go +++ b/pkg/messagingrp/setup/setup.go @@ -48,7 +48,7 @@ func SetupNamespace(recipeControllerConfig *controllerconfig.RecipeControllerCon rp_frontend.PrepareRadiusResource[*datamodel.RabbitMQQueue], }, AsyncJobController: func(options asyncctrl.Options) (asyncctrl.Controller, error) { - return pr_ctrl.NewCreateOrUpdateResource[*datamodel.RabbitMQQueue, datamodel.RabbitMQQueue](options, &rmq_proc.Processor{}, recipeControllerConfig.Engine, recipeControllerConfig.ResourceClient, recipeControllerConfig.ConfigLoader) + return pr_ctrl.NewCreateOrUpdateResource[*datamodel.RabbitMQQueue, datamodel.RabbitMQQueue](options, &rmq_proc.Processor{}, recipeControllerConfig.Engine, recipeControllerConfig.ConfigLoader) }, AsyncOperationTimeout: msrp_ctrl.AsyncCreateOrUpdateRabbitMQTimeout, AsyncOperationRetryAfter: AsyncOperationRetryAfter, @@ -58,7 +58,7 @@ func SetupNamespace(recipeControllerConfig *controllerconfig.RecipeControllerCon rp_frontend.PrepareRadiusResource[*datamodel.RabbitMQQueue], }, AsyncJobController: func(options asyncctrl.Options) (asyncctrl.Controller, error) { - return pr_ctrl.NewCreateOrUpdateResource[*datamodel.RabbitMQQueue, datamodel.RabbitMQQueue](options, &rmq_proc.Processor{}, recipeControllerConfig.Engine, recipeControllerConfig.ResourceClient, recipeControllerConfig.ConfigLoader) + return pr_ctrl.NewCreateOrUpdateResource[*datamodel.RabbitMQQueue, datamodel.RabbitMQQueue](options, &rmq_proc.Processor{}, recipeControllerConfig.Engine, recipeControllerConfig.ConfigLoader) }, AsyncOperationTimeout: msrp_ctrl.AsyncCreateOrUpdateRabbitMQTimeout, AsyncOperationRetryAfter: AsyncOperationRetryAfter, diff --git a/pkg/portableresources/backend/controller/createorupdateresource.go b/pkg/portableresources/backend/controller/createorupdateresource.go index 4cb1c162c6..0b3644368f 100644 --- a/pkg/portableresources/backend/controller/createorupdateresource.go +++ b/pkg/portableresources/backend/controller/createorupdateresource.go @@ -41,7 +41,6 @@ type CreateOrUpdateResource[P interface { ctrl.BaseController processor processors.ResourceProcessor[P, T] engine engine.Engine - client processors.ResourceClient configurationLoader configloader.ConfigurationLoader } @@ -50,12 +49,11 @@ type CreateOrUpdateResource[P interface { func NewCreateOrUpdateResource[P interface { *T rpv1.RadiusResourceModel -}, T any](opts ctrl.Options, processor processors.ResourceProcessor[P, T], eng engine.Engine, client processors.ResourceClient, configurationLoader configloader.ConfigurationLoader) (ctrl.Controller, error) { +}, T any](opts ctrl.Options, processor processors.ResourceProcessor[P, T], eng engine.Engine, configurationLoader configloader.ConfigurationLoader) (ctrl.Controller, error) { return &CreateOrUpdateResource[P, T]{ ctrl.NewBaseAsyncController(opts), processor, eng, - client, configurationLoader, }, nil } @@ -76,30 +74,25 @@ func (c *CreateOrUpdateResource[P, T]) Run(ctx context.Context, req *ctrl.Reques return ctrl.Result{}, err } - // We will initialize the pointer with an empty recipe status object here and the actual value - // will be eventually populated by the processor. - if data.ResourceMetadata().Status.Recipe == nil { - data.ResourceMetadata().Status.Recipe = &rpv1.RecipeStatus{} - } - // Clone existing output resources so we can diff them later. previousOutputResources := c.copyOutputResources(data) // Load configuration - metadata := recipes.ResourceMetadata{EnvironmentID: data.ResourceMetadata().Environment, ApplicationID: data.ResourceMetadata().Application, ResourceID: data.GetBaseResource().ID} + metadata := recipes.ResourceMetadata{EnvironmentID: data.ResourceMetadata().EnvironmentID(), ApplicationID: data.ResourceMetadata().ApplicationID(), ResourceID: data.GetBaseResource().ID} config, err := c.configurationLoader.LoadConfiguration(ctx, metadata) if err != nil { return ctrl.Result{}, err } // Now we're ready to process recipes (if needed). - recipeDataModel := any(data).(datamodel.RecipeDataModel) + recipeOutput, err := c.executeRecipeIfNeeded(ctx, data, previousOutputResources, config.Simulated) if err != nil { if recipeError, ok := err.(*recipes.RecipeError); ok { logger.Error(err, fmt.Sprintf("failed to execute recipe. Encountered error while processing %s ", recipeError.ErrorDetails.Target)) // Set the deployment status to the recipe error code. - recipeDataModel.Recipe().DeploymentStatus = util.RecipeDeploymentStatus(recipeError.DeploymentStatus) + recipeDataModel := any(data).(datamodel.RecipeDataModel) + recipeDataModel.GetRecipe().DeploymentStatus = util.RecipeDeploymentStatus(recipeError.DeploymentStatus) update := &database.Object{ Metadata: database.Metadata{ ID: req.ResourceID, @@ -125,8 +118,14 @@ func (c *CreateOrUpdateResource[P, T]) Run(ctx context.Context, req *ctrl.Reques return ctrl.Result{}, err } } - if recipeDataModel.Recipe() != nil { - recipeDataModel.Recipe().DeploymentStatus = util.Success + + recipeDataModel, supportsRecipes := any(data).(datamodel.RecipeDataModel) + if supportsRecipes { + recipeData := recipeDataModel.GetRecipe() + if recipeData != nil { + recipeData.DeploymentStatus = util.Success + recipeDataModel.SetRecipe(recipeData) + } } update := &database.Object{ @@ -158,15 +157,15 @@ func (c *CreateOrUpdateResource[P, T]) executeRecipeIfNeeded(ctx context.Context return nil, nil } - input := recipeDataModel.Recipe() + input := recipeDataModel.GetRecipe() if input == nil { return nil, nil } request := recipes.ResourceMetadata{ Name: input.Name, Parameters: input.Parameters, - EnvironmentID: data.ResourceMetadata().Environment, - ApplicationID: data.ResourceMetadata().Application, + EnvironmentID: data.ResourceMetadata().EnvironmentID(), + ApplicationID: data.ResourceMetadata().ApplicationID(), ResourceID: data.GetBaseResource().ID, } diff --git a/pkg/portableresources/backend/controller/createorupdateresource_test.go b/pkg/portableresources/backend/controller/createorupdateresource_test.go index cc1ab85aba..533724b41f 100644 --- a/pkg/portableresources/backend/controller/createorupdateresource_test.go +++ b/pkg/portableresources/backend/controller/createorupdateresource_test.go @@ -72,12 +72,12 @@ func (r *TestResource) OutputResources() []rpv1.OutputResource { } // ResourceMetadata returns the BasicResourceProperties of the TestResource instance. -func (r *TestResource) ResourceMetadata() *rpv1.BasicResourceProperties { +func (r *TestResource) ResourceMetadata() rpv1.BasicResourcePropertiesAdapter { return &r.Properties.BasicResourceProperties } // Recipe returns a pointer to the ResourceRecipe stored in the Properties field of the TestResource struct. -func (t *TestResource) Recipe() *portableresources.ResourceRecipe { +func (t *TestResource) GetRecipe() *portableresources.ResourceRecipe { return &t.Properties.Recipe } @@ -154,7 +154,7 @@ func TestCreateOrUpdateResource_Run(t *testing.T) { { "get-not-found", func(recipeCfg *controllerconfig.RecipeControllerConfig, options ctrl.Options) (ctrl.Controller, error) { - return NewCreateOrUpdateResource(options, errorProcessorReference, recipeCfg.Engine, recipeCfg.ResourceClient, recipeCfg.ConfigLoader) + return NewCreateOrUpdateResource(options, errorProcessorReference, recipeCfg.Engine, recipeCfg.ConfigLoader) }, &database.ErrNotFound{ID: TestResourceID}, false, @@ -168,7 +168,7 @@ func TestCreateOrUpdateResource_Run(t *testing.T) { { "get-error", func(recipeCfg *controllerconfig.RecipeControllerConfig, options ctrl.Options) (ctrl.Controller, error) { - return NewCreateOrUpdateResource(options, errorProcessorReference, recipeCfg.Engine, recipeCfg.ResourceClient, recipeCfg.ConfigLoader) + return NewCreateOrUpdateResource(options, errorProcessorReference, recipeCfg.Engine, recipeCfg.ConfigLoader) }, &database.ErrInvalid{}, false, @@ -182,7 +182,7 @@ func TestCreateOrUpdateResource_Run(t *testing.T) { { "conversion-failure", func(recipeCfg *controllerconfig.RecipeControllerConfig, options ctrl.Options) (ctrl.Controller, error) { - return NewCreateOrUpdateResource(options, errorProcessorReference, recipeCfg.Engine, recipeCfg.ResourceClient, recipeCfg.ConfigLoader) + return NewCreateOrUpdateResource(options, errorProcessorReference, recipeCfg.Engine, recipeCfg.ConfigLoader) }, nil, true, @@ -196,7 +196,7 @@ func TestCreateOrUpdateResource_Run(t *testing.T) { { "recipe-err", func(recipeCfg *controllerconfig.RecipeControllerConfig, options ctrl.Options) (ctrl.Controller, error) { - return NewCreateOrUpdateResource(options, errorProcessorReference, recipeCfg.Engine, recipeCfg.ResourceClient, recipeCfg.ConfigLoader) + return NewCreateOrUpdateResource(options, errorProcessorReference, recipeCfg.Engine, recipeCfg.ConfigLoader) }, nil, false, @@ -210,7 +210,7 @@ func TestCreateOrUpdateResource_Run(t *testing.T) { { "runtime-configuration-err", func(recipeCfg *controllerconfig.RecipeControllerConfig, options ctrl.Options) (ctrl.Controller, error) { - return NewCreateOrUpdateResource(options, errorProcessorReference, recipeCfg.Engine, recipeCfg.ResourceClient, recipeCfg.ConfigLoader) + return NewCreateOrUpdateResource(options, errorProcessorReference, recipeCfg.Engine, recipeCfg.ConfigLoader) }, nil, false, @@ -224,7 +224,7 @@ func TestCreateOrUpdateResource_Run(t *testing.T) { { "processor-err", func(recipeCfg *controllerconfig.RecipeControllerConfig, options ctrl.Options) (ctrl.Controller, error) { - return NewCreateOrUpdateResource(options, errorProcessorReference, recipeCfg.Engine, recipeCfg.ResourceClient, recipeCfg.ConfigLoader) + return NewCreateOrUpdateResource(options, errorProcessorReference, recipeCfg.Engine, recipeCfg.ConfigLoader) }, nil, false, @@ -238,7 +238,7 @@ func TestCreateOrUpdateResource_Run(t *testing.T) { { "save-err", func(recipeCfg *controllerconfig.RecipeControllerConfig, options ctrl.Options) (ctrl.Controller, error) { - return NewCreateOrUpdateResource(options, successProcessorReference, recipeCfg.Engine, recipeCfg.ResourceClient, recipeCfg.ConfigLoader) + return NewCreateOrUpdateResource(options, successProcessorReference, recipeCfg.Engine, recipeCfg.ConfigLoader) }, nil, false, @@ -252,7 +252,7 @@ func TestCreateOrUpdateResource_Run(t *testing.T) { { "success", func(recipeCfg *controllerconfig.RecipeControllerConfig, options ctrl.Options) (ctrl.Controller, error) { - return NewCreateOrUpdateResource(options, successProcessorReference, recipeCfg.Engine, recipeCfg.ResourceClient, recipeCfg.ConfigLoader) + return NewCreateOrUpdateResource(options, successProcessorReference, recipeCfg.Engine, recipeCfg.ConfigLoader) }, nil, false, @@ -267,7 +267,7 @@ func TestCreateOrUpdateResource_Run(t *testing.T) { for _, tt := range cases { t.Run(tt.description, func(t *testing.T) { - msc, eng, client, cfg := setupTest() + msc, eng, _, cfg := setupTest() req := &ctrl.Request{ OperationID: uuid.New(), @@ -416,9 +416,8 @@ func TestCreateOrUpdateResource_Run(t *testing.T) { } recipeCfg := &controllerconfig.RecipeControllerConfig{ - Engine: eng, - ResourceClient: client, - ConfigLoader: cfg, + Engine: eng, + ConfigLoader: cfg, } genCtrl, err := tt.factory(recipeCfg, opts) diff --git a/pkg/portableresources/backend/controller/deleteresource.go b/pkg/portableresources/backend/controller/deleteresource.go index a509bff720..eff6ae864b 100644 --- a/pkg/portableresources/backend/controller/deleteresource.go +++ b/pkg/portableresources/backend/controller/deleteresource.go @@ -78,12 +78,12 @@ func (c *DeleteResource[P, T]) Run(ctx context.Context, request *ctrl.Request) ( // If we have a setup error (error before recipe and output resources are executed, we skip engine/driver deletion. // If we have an execution error, we call engine/driver deletion. - if supportsRecipes && recipeDataModel.Recipe() != nil && recipeDataModel.Recipe().DeploymentStatus != util.RecipeSetupError { + if supportsRecipes && recipeDataModel.GetRecipe() != nil && recipeDataModel.GetRecipe().DeploymentStatus != util.RecipeSetupError { recipeData := recipes.ResourceMetadata{ - Name: recipeDataModel.Recipe().Name, - EnvironmentID: data.ResourceMetadata().Environment, - ApplicationID: data.ResourceMetadata().Application, - Parameters: recipeDataModel.Recipe().Parameters, + Name: recipeDataModel.GetRecipe().Name, + EnvironmentID: data.ResourceMetadata().EnvironmentID(), + ApplicationID: data.ResourceMetadata().ApplicationID(), + Parameters: recipeDataModel.GetRecipe().Parameters, ResourceID: id.String(), } @@ -102,7 +102,7 @@ func (c *DeleteResource[P, T]) Run(ctx context.Context, request *ctrl.Request) ( } // Load details about the runtime for the processor to access. - runtimeConfiguration, err := c.loadRuntimeConfiguration(ctx, data.ResourceMetadata().Environment, data.ResourceMetadata().Application, data.GetBaseResource().ID) + runtimeConfiguration, err := c.loadRuntimeConfiguration(ctx, data.ResourceMetadata().EnvironmentID(), data.ResourceMetadata().ApplicationID(), data.GetBaseResource().ID) if err != nil { return ctrl.Result{}, err } diff --git a/pkg/portableresources/datamodel/recipes.go b/pkg/portableresources/datamodel/recipes.go index e3b0a234b2..ef69b78e2e 100644 --- a/pkg/portableresources/datamodel/recipes.go +++ b/pkg/portableresources/datamodel/recipes.go @@ -26,6 +26,9 @@ import ( // RecipeDataModel should be implemented on the datamodel of types that support recipes. type RecipeDataModel interface { - // Recipe provides access to the user-specified recipe configuration. Can return nil. - Recipe() *portableresources.ResourceRecipe + // GetRecipe provides access to the user-specified recipe configuration. Can return nil. + GetRecipe() *portableresources.ResourceRecipe + + // SetRecipe sets the recipe configuration for the resource. + SetRecipe(*portableresources.ResourceRecipe) } diff --git a/pkg/recipes/controllerconfig/config.go b/pkg/recipes/controllerconfig/config.go index 6ae1e3a82d..2929bda80e 100644 --- a/pkg/recipes/controllerconfig/config.go +++ b/pkg/recipes/controllerconfig/config.go @@ -37,9 +37,6 @@ type RecipeControllerConfig struct { // Kubernetes provides access to the Kubernetes clients. Kubernetes *kubernetesclientprovider.KubernetesClientProvider - // ResourceClient is a client used by resource processors for interacting with UCP resources. - ResourceClient processors.ResourceClient - // ConfigLoader is the configuration loader. ConfigLoader configloader.ConfigurationLoader @@ -62,7 +59,6 @@ func New(options hostoptions.HostOptions) (*RecipeControllerConfig, error) { cfg.UCPConnection = &options.UCPConnection - cfg.ResourceClient = processors.NewResourceClient(options.Arm, options.UCPConnection, cfg.Kubernetes) clientOptions := sdk.NewClientOptions(options.UCPConnection) cfg.DeploymentEngineClient, err = clients.NewResourceDeploymentsClient(&clients.Options{ @@ -100,7 +96,7 @@ func New(options hostoptions.HostOptions) (*RecipeControllerConfig, error) { recipes.TemplateKindBicep: driver.NewBicepDriver( clientOptions, cfg.DeploymentEngineClient, - cfg.ResourceClient, + processors.NewResourceClient(options.Arm, options.UCPConnection, cfg.Kubernetes), driver.BicepOptions{ DeleteRetryCount: bicepDeleteRetryCount, DeleteRetryDelaySeconds: bicepDeleteRetryDeleteSeconds, diff --git a/pkg/rp/frontend/resource_test.go b/pkg/rp/frontend/resource_test.go index 906b43567d..c356603ac7 100644 --- a/pkg/rp/frontend/resource_test.go +++ b/pkg/rp/frontend/resource_test.go @@ -52,7 +52,7 @@ func (c *TestResourceDataModel) OutputResources() []rpv1.OutputResource { } // ResourceMetadata returns the application resource metadata. -func (h *TestResourceDataModel) ResourceMetadata() *rpv1.BasicResourceProperties { +func (h *TestResourceDataModel) ResourceMetadata() rpv1.BasicResourcePropertiesAdapter { return &h.Properties.BasicResourceProperties } diff --git a/pkg/rp/frontend/validator.go b/pkg/rp/frontend/validator.go index 27f89fa2a6..4801bbe4c4 100644 --- a/pkg/rp/frontend/validator.go +++ b/pkg/rp/frontend/validator.go @@ -35,16 +35,16 @@ func PrepareRadiusResource[P interface { return nil, nil } serviceCtx := v1.ARMRequestContextFromContext(ctx) - oldProp := P(oldResource).ResourceMetadata() - newProp := P(newResource).ResourceMetadata() + oldScope := P(oldResource).ResourceMetadata() + newScope := P(newResource).ResourceMetadata() - if !oldProp.EqualLinkedResource(newProp) { - return rest.NewLinkedResourceUpdateErrorResponse(serviceCtx.ResourceID, oldProp, newProp), nil + if !rpv1.ScopesEqual(oldScope, newScope) { + return rest.NewLinkedResourceUpdateErrorResponse(serviceCtx.ResourceID, oldScope, newScope), nil } // Keep outputresource from existing resource since the incoming request hasn't had an outputresource // processed by the backend yet. - newProp.Status.DeepCopy(&oldProp.Status) + newScope.SetResourceStatus(oldScope.GetResourceStatus().DeepCopy()) return nil, nil } diff --git a/pkg/rp/v1/types.go b/pkg/rp/v1/types.go index 9bc10bb49c..b98863437f 100644 --- a/pkg/rp/v1/types.go +++ b/pkg/rp/v1/types.go @@ -49,6 +49,28 @@ type BasicResourceProperties struct { Status ResourceStatus `json:"status,omitempty"` } +var _ BasicResourcePropertiesAdapter = (*BasicResourceProperties)(nil) + +// ApplicationID implements BasicResourcePropertiesAdapter. +func (b *BasicResourceProperties) ApplicationID() string { + return b.Application +} + +// EnvironmentID implements BasicResourcePropertiesAdapter. +func (b *BasicResourceProperties) EnvironmentID() string { + return b.Environment +} + +// GetResourceStatus implements BasicResourcePropertiesAdapter. +func (b *BasicResourceProperties) GetResourceStatus() ResourceStatus { + return b.Status +} + +// SetResourceStatus implements BasicResourcePropertiesAdapter. +func (b *BasicResourceProperties) SetResourceStatus(status ResourceStatus) { + b.Status = status +} + // DaprComponentMetadataValue is the value of a Dapr Metadata type DaprComponentMetadataValue struct { // Plain text value @@ -71,17 +93,6 @@ type DaprComponentAuth struct { SecretStore string `json:"secretStore,omitempty"` } -// Method EqualLinkedResource compares two BasicResourceProperties objects and returns true if their Application and -// Environment fields are equal (i.e. resource belongs to the same env and app). -func (b *BasicResourceProperties) EqualLinkedResource(prop *BasicResourceProperties) bool { - return strings.EqualFold(b.Application, prop.Application) && strings.EqualFold(b.Environment, prop.Environment) -} - -// Method IsGlobalScopedResource checks if resource is global scoped. -func (b *BasicResourceProperties) IsGlobalScopedResource() bool { - return b.Application == "" && b.Environment == "" -} - // ResourceStatus represents the output status of Radius resource. type ResourceStatus struct { // Compute represents a resource presented in the underlying platform. @@ -92,17 +103,19 @@ type ResourceStatus struct { Recipe *RecipeStatus `json:"recipe,omitempty"` } -// DeepCopy copies the contents of the ResourceStatus struct from in to out. -func (in *ResourceStatus) DeepCopy(out *ResourceStatus) { - in.Compute = out.Compute - in.OutputResources = out.OutputResources - if out.Recipe != nil { - in.Recipe = &RecipeStatus{ - TemplateKind: out.Recipe.TemplateKind, - TemplatePath: out.Recipe.TemplatePath, - TemplateVersion: out.Recipe.TemplateVersion, +func (original ResourceStatus) DeepCopy() ResourceStatus { + // TODO: this isn't really a deep copy. It only deep-copies the RecipeStatus. + copy := original + + if original.Recipe != nil { + copy.Recipe = &RecipeStatus{ + TemplateKind: original.Recipe.TemplateKind, + TemplatePath: original.Recipe.TemplatePath, + TemplateVersion: original.Recipe.TemplateVersion, } } + + return copy } // EnvironmentCompute represents the compute resource of Environment. @@ -132,7 +145,38 @@ type RadiusResourceModel interface { ApplyDeploymentOutput(deploymentOutput DeploymentOutput) error OutputResources() []OutputResource - ResourceMetadata() *BasicResourceProperties + ResourceMetadata() BasicResourcePropertiesAdapter +} + +// ResourceScopePropertiesAdapter is the interface that wraps the resource scope properties (application and environment ids). +type ResourceScopePropertiesAdapter interface { + // ApplicationID returns the resource id of the application. + ApplicationID() string + + // EnvironmentID returns the resource id of the environment. + EnvironmentID() string +} + +// BasicResourcePropertiesAdapter is the interface that wraps the basic resource properties. +type BasicResourcePropertiesAdapter interface { + ResourceScopePropertiesAdapter + + // GetResourceStatus returns the resource status. + GetResourceStatus() ResourceStatus + + // SetResourceStatus sets the resource status. + SetResourceStatus(status ResourceStatus) +} + +// ScopesEqual compares two resources and returns true if their Application and +// Environment fields are equal (i.e. resource belongs to the same env and app). +func ScopesEqual(a ResourceScopePropertiesAdapter, b ResourceScopePropertiesAdapter) bool { + return strings.EqualFold(a.ApplicationID(), b.ApplicationID()) && strings.EqualFold(a.EnvironmentID(), b.EnvironmentID()) +} + +// IsGlobalScopedResource checks if resource is global scoped. +func IsGlobalScopedResource(resource ResourceScopePropertiesAdapter) bool { + return resource.ApplicationID() == "" && resource.EnvironmentID() == "" } // DeploymentOutput is the output details of a deployment. diff --git a/pkg/rp/v1/types_test.go b/pkg/rp/v1/types_test.go index 8d6ad5da8b..91aeded5db 100644 --- a/pkg/rp/v1/types_test.go +++ b/pkg/rp/v1/types_test.go @@ -22,7 +22,7 @@ import ( "github.com/stretchr/testify/require" ) -func TestEqualLinkedResource(t *testing.T) { +func Test_ScopesEqual(t *testing.T) { parentResourceTests := []struct { propA BasicResourceProperties propB BasicResourceProperties @@ -93,11 +93,11 @@ func TestEqualLinkedResource(t *testing.T) { } for _, tt := range parentResourceTests { - require.Equal(t, tt.propA.EqualLinkedResource(&tt.propB), tt.eq) + require.Equal(t, ScopesEqual(&tt.propA, &tt.propB), tt.eq) } } -func Test_isGlobalScopedResource(t *testing.T) { +func Test_IsGlobalScopedResource(t *testing.T) { tests := []struct { desc string basicResourceProperties BasicResourceProperties @@ -125,8 +125,7 @@ func Test_isGlobalScopedResource(t *testing.T) { } for _, tt := range tests { t.Run(tt.desc, func(t *testing.T) { - act := tt.basicResourceProperties.IsGlobalScopedResource() - require.Equal(t, act, tt.isGlobal) + require.Equal(t, IsGlobalScopedResource(&tt.basicResourceProperties), tt.isGlobal) }) }