From 7827e4f1b7447846bae76169a431aaa3cb2a5585 Mon Sep 17 00:00:00 2001 From: Ryan Nowak Date: Sun, 15 Oct 2023 15:05:55 -0700 Subject: [PATCH] Make application and environment configurable This change makes the application and environment of a Recipe or Deployment configurable through the Kubernetes API. This includes the logic and tests to handle the case where the application or environment change after creation. For this case we need to delete the existing paired resource for two reasons: - This will result in a different deployment scope (resource group name is computed). - It is forbidden in our API to change the application or environment of an existing resource. This PR does not address the case where a Deployment reacts to a Recipe changing its environment or application. That will be handled in a follow-up. --- .../Chart/crds/radius/radapp.io_recipes.yaml | 10 +++ .../api/radapp.io/v1alpha3/recipe_types.go | 8 ++ pkg/controller/reconciler/annotations.go | 10 ++- pkg/controller/reconciler/const.go | 8 ++ .../reconciler/deployment_reconciler.go | 64 +++++++++++---- .../reconciler/deployment_reconciler_test.go | 77 +++++++++++++++++- .../reconciler/recipe_reconciler.go | 64 +++++++++++---- .../reconciler/recipe_reconciler_test.go | 78 ++++++++++++++++++- pkg/controller/reconciler/shared_test.go | 11 ++- 9 files changed, 290 insertions(+), 40 deletions(-) diff --git a/deploy/Chart/crds/radius/radapp.io_recipes.yaml b/deploy/Chart/crds/radius/radapp.io_recipes.yaml index 808d8b2c34d..d18c847ba65 100644 --- a/deploy/Chart/crds/radius/radapp.io_recipes.yaml +++ b/deploy/Chart/crds/radius/radapp.io_recipes.yaml @@ -51,6 +51,16 @@ spec: spec: description: RecipeSpec defines the desired state of Recipe properties: + application: + description: Application is the name of the Radius application to + use. If unset the namespace of the Recipe will be used as the application + name. + type: string + environment: + description: Environment is the name of the Radius environment to + use. If unset the value 'default' will be used as the environment + name. + type: string secretName: description: SecretName is the name of a Kubernetes secret to create once the resource is created. diff --git a/pkg/controller/api/radapp.io/v1alpha3/recipe_types.go b/pkg/controller/api/radapp.io/v1alpha3/recipe_types.go index 7ba68c0f5c8..2f39fc3be62 100644 --- a/pkg/controller/api/radapp.io/v1alpha3/recipe_types.go +++ b/pkg/controller/api/radapp.io/v1alpha3/recipe_types.go @@ -32,6 +32,14 @@ type RecipeSpec struct { // SecretName is the name of a Kubernetes secret to create once the resource is created. // +kubebuilder:validation:Optional SecretName string `json:"secretName,omitempty"` + + // Environment is the name of the Radius environment to use. If unset the value 'default' will be + // used as the environment name. + Environment string `json:"environment,omitempty"` + + // Application is the name of the Radius application to use. If unset the namespace of the + // Recipe will be used as the application name. + Application string `json:"application,omitempty"` } // RecipePhrase is a string representation of the current status of a Recipe. diff --git a/pkg/controller/reconciler/annotations.go b/pkg/controller/reconciler/annotations.go index 4d21900b8ee..c0f0333d222 100644 --- a/pkg/controller/reconciler/annotations.go +++ b/pkg/controller/reconciler/annotations.go @@ -54,7 +54,9 @@ type deploymentAnnotations struct { // deploymentConfiguration is the configuration of the Deployment provided by the user via annotations. type deploymentConfiguration struct { - Connections map[string]string + Application string `json:"application,omitempty"` + Environment string `json:"environment,omitempty"` + Connections map[string]string `json:"connections,omitempty"` } func (c *deploymentConfiguration) computeHash() (string, error) { @@ -107,7 +109,11 @@ func readAnnotations(deployment *appsv1.Deployment) (*deploymentAnnotations, err return &result, nil } - result.Configuration = &deploymentConfiguration{Connections: map[string]string{}} + result.Configuration = &deploymentConfiguration{ + Environment: deployment.Annotations[AnnotatationRadiusEnvironment], + Application: deployment.Annotations[AnnotationRadiusApplication], + Connections: map[string]string{}, + } for k, v := range deployment.Annotations { if strings.HasPrefix(k, AnnotationRadiusConnectionPrefix) { diff --git a/pkg/controller/reconciler/const.go b/pkg/controller/reconciler/const.go index 686fa02761e..ac7d6b6493e 100644 --- a/pkg/controller/reconciler/const.go +++ b/pkg/controller/reconciler/const.go @@ -34,6 +34,14 @@ const ( // AnnotationRadiusConfigurationHash is the name of the annotation that indicates the hash of the configuration. AnnotationRadiusConfigurationHash = "radapp.io/configuration-hash" + // AnnotationRadiusEnvionment is the name of the annotation that indicates the name of the environment. If unset, + // the value 'default' will be used as the environment name. + AnnotatationRadiusEnvironment = "radapp.io/environment" + + // AnnotationRadiusApplication is the name of the annotation that indicates the name of the application. If unset, + // the namespace of the Deployment will be used as the application name. + AnnotationRadiusApplication = "radapp.io/application" + // DeploymentFinalizer is the name of the finalizer added to Deployments. DeploymentFinalizer = "radapp.io/deployment-finalizer" diff --git a/pkg/controller/reconciler/deployment_reconciler.go b/pkg/controller/reconciler/deployment_reconciler.go index 46902bb582e..dd9e1e6e9ac 100644 --- a/pkg/controller/reconciler/deployment_reconciler.go +++ b/pkg/controller/reconciler/deployment_reconciler.go @@ -20,6 +20,7 @@ import ( "context" "encoding/base64" "fmt" + "strings" "time" appsv1 "k8s.io/api/apps/v1" @@ -248,11 +249,15 @@ func (r *DeploymentReconciler) reconcileUpdate(ctx context.Context, deployment * } } - // For now the environment name is hardcoded to default. environmentName := "default" + if annotations.Configuration.Environment != "" { + environmentName = annotations.Configuration.Environment + } - // For now the application name is hardcoded to the namespace. applicationName := deployment.Namespace + if annotations.Configuration.Application != "" { + applicationName = annotations.Configuration.Application + } resourceGroupID, environmentID, applicationID, err := resolveDependencies(ctx, r.Radius, "/planes/radius/local", environmentName, applicationName) if err != nil { @@ -269,8 +274,9 @@ func (r *DeploymentReconciler) reconcileUpdate(ctx context.Context, deployment * // // 1) err != nil - an error happened, this will be retried next reconcile. // 2) waiting == true - we're waiting on dependencies, this will be retried next reconcile. - // 3) poller != nil - we've started an operation, this will be checked next reconcile. - poller, waiting, err := r.startPutOperationIfNeeded(ctx, deployment, annotations) + // 3) updatePoller != nil - we've started a PUT operation, this will be checked next reconcile. + // 4) deletePoller != nil - we've started a DELETE operation, this will be checked next reconcile. + updatePoller, deletePoller, waiting, err := r.startPutOrDeleteOperationIfNeeded(ctx, deployment, annotations) if err != nil { logger.Error(err, "Unable to create or update resource.") r.EventRecorder.Event(deployment, corev1.EventTypeWarning, "ResourceError", err.Error()) @@ -288,9 +294,9 @@ func (r *DeploymentReconciler) reconcileUpdate(ctx context.Context, deployment * // We don't need to requeue here because we watch Recipes and will be notified when // the state changes. return ctrl.Result{}, nil - } else if poller != nil { + } else if updatePoller != nil { // We've successfully started an operation. Update the status and requeue. - token, err := poller.ResumeToken() + token, err := updatePoller.ResumeToken() if err != nil { return ctrl.Result{}, fmt.Errorf("failed to get operation token: %w", err) } @@ -302,6 +308,21 @@ func (r *DeploymentReconciler) reconcileUpdate(ctx context.Context, deployment * return ctrl.Result{}, err } + return ctrl.Result{Requeue: true, RequeueAfter: r.requeueDelay()}, nil + } else if deletePoller != nil { + // We've successfully started an operation. Update the status and requeue. + token, err := deletePoller.ResumeToken() + if err != nil { + return ctrl.Result{}, fmt.Errorf("failed to get operation token: %w", err) + } + + annotations.Status.Operation = &radappiov1alpha3.ResourceOperation{ResumeToken: token, OperationKind: radappiov1alpha3.OperationKindDelete} + annotations.Status.Phrase = deploymentPhraseDeleting + err = r.saveState(ctx, deployment, annotations) + if err != nil { + return ctrl.Result{}, err + } + return ctrl.Result{Requeue: true, RequeueAfter: r.requeueDelay()}, nil } @@ -366,15 +387,29 @@ func (r *DeploymentReconciler) reconcileDelete(ctx context.Context, deployment * return ctrl.Result{}, nil } -func (r *DeploymentReconciler) startPutOperationIfNeeded(ctx context.Context, deployment *appsv1.Deployment, annotations *deploymentAnnotations) (Poller[v20231001preview.ContainersClientCreateOrUpdateResponse], bool, error) { +func (r *DeploymentReconciler) startPutOrDeleteOperationIfNeeded(ctx context.Context, deployment *appsv1.Deployment, annotations *deploymentAnnotations) (Poller[v20231001preview.ContainersClientCreateOrUpdateResponse], Poller[v20231001preview.ContainersClientDeleteResponse], bool, error) { logger := ucplog.FromContextOrDiscard(ctx) + resourceID := annotations.Status.Scope + "/providers/Applications.Core/containers/" + deployment.Name + // Check the annotations first to see how the current configuration compares to the desired configuration. - if !annotations.IsUpToDate() { + if annotations.Status.Container != "" && !strings.EqualFold(annotations.Status.Container, resourceID) { + // If we get here it means that the environment or application changed, so we should delete + // the old resource and create a new one. + logger.Info("Container is already created but is out-of-date") + + logger.Info("Starting DELETE operation.") + poller, err := deleteContainer(ctx, r.Radius, annotations.Status.Container) + if err != nil { + return nil, nil, false, err + } + + return nil, poller, false, err + } else if !annotations.IsUpToDate() { logger.Info("Container configuration is out-of-date.") } else if annotations.Status.Container != "" { logger.Info("Container is already created and is up-to-date.") - return nil, false, nil + return nil, nil, false, nil } logger.Info("Starting PUT operation.") @@ -397,12 +432,12 @@ func (r *DeploymentReconciler) startPutOperationIfNeeded(ctx context.Context, de err := r.Client.Get(ctx, client.ObjectKey{Namespace: deployment.Namespace, Name: source}, &recipe) if apierrors.IsNotFound(err) { logger.Info("Recipe does not exist.", "recipe", source) - return nil, true, nil + return nil, nil, true, nil } else if err != nil { - return nil, false, fmt.Errorf("failed to fetch recipe %s: %w", source, err) + return nil, nil, false, fmt.Errorf("failed to fetch recipe %s: %w", source, err) } else if recipe.Status.Resource == "" { logger.Info("Recipe is not ready.", "recipe", source) - return nil, true, nil + return nil, nil, true, nil } properties.Connections[name] = &v20231001preview.ConnectionProperties{ @@ -410,13 +445,12 @@ func (r *DeploymentReconciler) startPutOperationIfNeeded(ctx context.Context, de } } - resourceID := annotations.Status.Scope + "/providers/Applications.Core/containers/" + deployment.Name poller, err := createOrUpdateContainer(ctx, r.Radius, resourceID, &properties) if err != nil { - return nil, false, err + return nil, nil, false, err } - return poller, false, nil + return poller, nil, false, nil } func (r *DeploymentReconciler) startDeleteOperationIfNeeded(ctx context.Context, deployment *appsv1.Deployment, annotations *deploymentAnnotations) (Poller[v20231001preview.ContainersClientDeleteResponse], error) { diff --git a/pkg/controller/reconciler/deployment_reconciler_test.go b/pkg/controller/reconciler/deployment_reconciler_test.go index 59ba5f29e2a..6438cc119b5 100644 --- a/pkg/controller/reconciler/deployment_reconciler_test.go +++ b/pkg/controller/reconciler/deployment_reconciler_test.go @@ -101,7 +101,7 @@ func Test_DeploymentReconciler_RadiusEnabled_ThenDeploymentDeleted(t *testing.T) require.NoError(t, err) // Deployment will be waiting for environment to be created. - createEnvironment(radius) + createEnvironment(radius, "default") // Deployment will be waiting for container to complete deployment. annotations := waitForStateUpdating(t, client, name) @@ -127,6 +127,77 @@ func Test_DeploymentReconciler_RadiusEnabled_ThenDeploymentDeleted(t *testing.T) waitForDeploymentDeleted(t, client, name) } +func Test_DeploymentReconciler_ChangeEnvironmentAndApplication(t *testing.T) { + ctx := testcontext.New(t) + radius, client := SetupDeploymentTest(t) + + name := types.NamespacedName{Namespace: "deployment-change-envapp", Name: "test-deployment-change-envapp"} + err := client.Create(ctx, &corev1.Namespace{ObjectMeta: ctrl.ObjectMeta{Name: name.Namespace}}) + require.NoError(t, err) + + deployment := makeDeployment(name) + deployment.Annotations[AnnotationRadiusEnabled] = "true" + err = client.Create(ctx, deployment) + require.NoError(t, err) + + // Deployment will be waiting for environment to be created. + createEnvironment(radius, "default") + + // Deployment will be waiting for container to complete deployment. + annotations := waitForStateUpdating(t, client, name) + require.Equal(t, "/planes/radius/local/resourceGroups/default-deployment-change-envapp", annotations.Status.Scope) + require.Equal(t, "/planes/radius/local/resourceGroups/default/providers/Applications.Core/environments/default", annotations.Status.Environment) + require.Equal(t, "/planes/radius/local/resourceGroups/default-deployment-change-envapp/providers/Applications.Core/applications/deployment-change-envapp", annotations.Status.Application) + radius.CompleteOperation(annotations.Status.Operation.ResumeToken, nil) + + // Deployment will update after operation completes + annotations = waitForStateReady(t, client, name) + require.Equal(t, "/planes/radius/local/resourceGroups/default-deployment-change-envapp/providers/Applications.Core/containers/test-deployment-change-envapp", annotations.Status.Container) + + createEnvironment(radius, "new-environment") + + // Now update the deployment to change the environment and application. + err = client.Get(ctx, name, deployment) + require.NoError(t, err) + + deployment.Annotations[AnnotatationRadiusEnvironment] = "new-environment" + deployment.Annotations[AnnotationRadiusApplication] = "new-application" + + err = client.Update(ctx, deployment) + require.NoError(t, err) + + // Now the deployment will delete and re-create the resource. + + // Deletion of the container is in progress. + annotations = waitForStateDeleting(t, client, name) + radius.CompleteOperation(annotations.Status.Operation.ResumeToken, nil) + + // Resource should be gone. + _, err = radius.Containers(annotations.Status.Scope).Get(ctx, name.Name, nil) + require.Error(t, err) + + // Recipe will be waiting for extender to complete provisioning. + annotations = waitForStateUpdating(t, client, name) + require.Equal(t, "/planes/radius/local/resourceGroups/new-environment-new-application", annotations.Status.Scope) + require.Equal(t, "/planes/radius/local/resourceGroups/new-environment/providers/Applications.Core/environments/new-environment", annotations.Status.Environment) + require.Equal(t, "/planes/radius/local/resourceGroups/new-environment-new-application/providers/Applications.Core/applications/new-application", annotations.Status.Application) + radius.CompleteOperation(annotations.Status.Operation.ResumeToken, nil) + + // Recipe will update after operation completes + annotations = waitForStateReady(t, client, name) + require.Equal(t, "/planes/radius/local/resourceGroups/new-environment-new-application/providers/Applications.Core/containers/test-deployment-change-envapp", annotations.Status.Container) + + err = client.Delete(ctx, deployment) + require.NoError(t, err) + + // Deletion of the container is in progress. + annotations = waitForStateDeleting(t, client, name) + radius.CompleteOperation(annotations.Status.Operation.ResumeToken, nil) + + // Now deleting of the deployment object can complete. + waitForDeploymentDeleted(t, client, name) +} + // Creates a deployment with Radius enabled. // // Then exercises the cleanup path by disabling Radius. @@ -144,7 +215,7 @@ func Test_DeploymentReconciler_RadiusEnabled_ThenRadiusDisabled(t *testing.T) { require.NoError(t, err) // Deployment will be waiting for environment to be created. - createEnvironment(radius) + createEnvironment(radius, "default") // Deployment will be waiting for container to complete deployment. annotations := waitForStateUpdating(t, client, name) @@ -199,7 +270,7 @@ func Test_DeploymentReconciler_Connections(t *testing.T) { require.NoError(t, err) // Deployment will be waiting for environment to be created. - createEnvironment(radius) + createEnvironment(radius, "default") // Deployment will be waiting for recipe resources to be created _ = waitForStateWaiting(t, client, name) diff --git a/pkg/controller/reconciler/recipe_reconciler.go b/pkg/controller/reconciler/recipe_reconciler.go index 6596819fd85..fa7aec7308a 100644 --- a/pkg/controller/reconciler/recipe_reconciler.go +++ b/pkg/controller/reconciler/recipe_reconciler.go @@ -20,6 +20,7 @@ import ( "context" "encoding/base64" "fmt" + "strings" "time" apierrors "k8s.io/apimachinery/pkg/api/errors" @@ -232,11 +233,15 @@ func (r *RecipeReconciler) reconcileUpdate(ctx context.Context, recipe *radappio // fully processed any status changes until the async operation completes. recipe.Status.ObservedGeneration = recipe.Generation - // For now the environment name is hardcoded to default. environmentName := "default" + if recipe.Spec.Environment != "" { + environmentName = recipe.Spec.Environment + } - // For now the application name is hardcoded to the namespace. applicationName := recipe.Namespace + if recipe.Spec.Application != "" { + applicationName = recipe.Spec.Application + } resourceGroupID, environmentID, applicationID, err := resolveDependencies(ctx, r.Radius, "/planes/radius/local", environmentName, applicationName) if err != nil { @@ -249,14 +254,14 @@ func (r *RecipeReconciler) reconcileUpdate(ctx context.Context, recipe *radappio recipe.Status.Environment = environmentID recipe.Status.Application = applicationID - poller, err := r.startPutOperationIfNeeded(ctx, recipe) + updatePoller, deletePoller, err := r.startPutOrDeleteOperationIfNeeded(ctx, recipe) if err != nil { logger.Error(err, "Unable to create or update resource.") r.EventRecorder.Event(recipe, corev1.EventTypeWarning, "ResourceError", err.Error()) return ctrl.Result{}, err - } else if poller != nil { + } else if updatePoller != nil { // We've successfully started an operation. Update the status and requeue. - token, err := poller.ResumeToken() + token, err := updatePoller.ResumeToken() if err != nil { return ctrl.Result{}, fmt.Errorf("failed to get operation token: %w", err) } @@ -268,13 +273,28 @@ func (r *RecipeReconciler) reconcileUpdate(ctx context.Context, recipe *radappio return ctrl.Result{}, err } + return ctrl.Result{Requeue: true, RequeueAfter: r.requeueDelay()}, nil + } else if deletePoller != nil { + // We've successfully started an operation. Update the status and requeue. + token, err := deletePoller.ResumeToken() + if err != nil { + return ctrl.Result{}, fmt.Errorf("failed to get operation token: %w", err) + } + + recipe.Status.Operation = &radappiov1alpha3.ResourceOperation{ResumeToken: token, OperationKind: radappiov1alpha3.OperationKindDelete} + recipe.Status.Phrase = radappiov1alpha3.PhraseDeleting + err = r.Client.Status().Update(ctx, recipe) + if err != nil { + return ctrl.Result{}, err + } + return ctrl.Result{Requeue: true, RequeueAfter: r.requeueDelay()}, nil } // If we get here then it means we can process the result of the operation. logger.Info("Resource is in desired state.", "resourceId", recipe.Status.Resource) - err = r.updateSecret(ctx, recipe, poller) + err = r.updateSecret(ctx, recipe) if err != nil { return ctrl.Result{}, fmt.Errorf("failed to process secret %s: %w", recipe.Spec.SecretName, err) } @@ -348,11 +368,25 @@ func (r *RecipeReconciler) reconcileDelete(ctx context.Context, recipe *radappio return ctrl.Result{}, nil } -func (r *RecipeReconciler) startPutOperationIfNeeded(ctx context.Context, recipe *radappiov1alpha3.Recipe) (Poller[generated.GenericResourcesClientCreateOrUpdateResponse], error) { +func (r *RecipeReconciler) startPutOrDeleteOperationIfNeeded(ctx context.Context, recipe *radappiov1alpha3.Recipe) (Poller[generated.GenericResourcesClientCreateOrUpdateResponse], Poller[generated.GenericResourcesClientDeleteResponse], error) { logger := ucplog.FromContextOrDiscard(ctx) - if recipe.Status.Resource != "" { - logger.Info("Resource is already created.") - return nil, nil + + resourceID := recipe.Status.Scope + "/providers/" + recipe.Spec.Type + "/" + recipe.Name + if recipe.Status.Resource != "" && !strings.EqualFold(recipe.Status.Resource, resourceID) { + // If we get here it means that the environment or application changed, so we should delete + // the old resource and create a new one. + logger.Info("Resource is already created but is out-of-date") + + logger.Info("Starting DELETE operation.") + poller, err := deleteResource(ctx, r.Radius, recipe.Status.Resource) + if err != nil { + return nil, nil, err + } + + return nil, poller, err + } else if recipe.Status.Resource != "" { + logger.Info("Resource is already created and is up-to-date.") + return nil, nil, nil } logger.Info("Starting PUT operation.") @@ -362,8 +396,12 @@ func (r *RecipeReconciler) startPutOperationIfNeeded(ctx context.Context, recipe "resourceProvisioning": "recipe", } - resourceID := recipe.Status.Scope + "/providers/" + recipe.Spec.Type + "/" + recipe.Name - return createOrUpdateResource(ctx, r.Radius, resourceID, properties) + poller, err := createOrUpdateResource(ctx, r.Radius, resourceID, properties) + if err != nil { + return nil, nil, err + } + + return poller, nil, nil } func (r *RecipeReconciler) startDeleteOperationIfNeeded(ctx context.Context, recipe *radappiov1alpha3.Recipe) (Poller[generated.GenericResourcesClientDeleteResponse], error) { @@ -377,7 +415,7 @@ func (r *RecipeReconciler) startDeleteOperationIfNeeded(ctx context.Context, rec return deleteResource(ctx, r.Radius, recipe.Status.Resource) } -func (r *RecipeReconciler) updateSecret(ctx context.Context, recipe *radappiov1alpha3.Recipe, poller Poller[generated.GenericResourcesClientCreateOrUpdateResponse]) error { +func (r *RecipeReconciler) updateSecret(ctx context.Context, recipe *radappiov1alpha3.Recipe) error { logger := ucplog.FromContextOrDiscard(ctx) // If the secret name changed, delete the old secret. diff --git a/pkg/controller/reconciler/recipe_reconciler_test.go b/pkg/controller/reconciler/recipe_reconciler_test.go index 20ae5aae5d8..379e1b9c65d 100644 --- a/pkg/controller/reconciler/recipe_reconciler_test.go +++ b/pkg/controller/reconciler/recipe_reconciler_test.go @@ -93,7 +93,7 @@ func Test_RecipeReconciler_WithoutSecret(t *testing.T) { require.NoError(t, err) // Recipe will be waiting for environment to be created. - createEnvironment(radius) + createEnvironment(radius, "default") // Recipe will be waiting for extender to complete provisioning. status := waitForRecipeStateUpdating(t, client, name, nil) @@ -122,6 +122,78 @@ func Test_RecipeReconciler_WithoutSecret(t *testing.T) { waitForRecipeDeleted(t, client, name) } +func Test_RecipeReconciler_ChangeEnvironmentAndApplication(t *testing.T) { + ctx := testcontext.New(t) + radius, client := SetupRecipeTest(t) + + name := types.NamespacedName{Namespace: "recipe-change-envapp", Name: "test-recipe-change-envapp"} + err := client.Create(ctx, &corev1.Namespace{ObjectMeta: ctrl.ObjectMeta{Name: name.Namespace}}) + require.NoError(t, err) + + recipe := makeRecipe(name, "Applications.Core/extenders") + err = client.Create(ctx, recipe) + require.NoError(t, err) + + // Recipe will be waiting for environment to be created. + createEnvironment(radius, "default") + + // Recipe will be waiting for extender to complete provisioning. + status := waitForRecipeStateUpdating(t, client, name, nil) + require.Equal(t, "/planes/radius/local/resourceGroups/default-recipe-change-envapp", status.Scope) + require.Equal(t, "/planes/radius/local/resourceGroups/default/providers/Applications.Core/environments/default", status.Environment) + require.Equal(t, "/planes/radius/local/resourceGroups/default-recipe-change-envapp/providers/Applications.Core/applications/recipe-change-envapp", status.Application) + + radius.CompleteOperation(status.Operation.ResumeToken, nil) + + // Recipe will update after operation completes + status = waitForRecipeStateReady(t, client, name) + require.Equal(t, "/planes/radius/local/resourceGroups/default-recipe-change-envapp/providers/Applications.Core/extenders/test-recipe-change-envapp", status.Resource) + + createEnvironment(radius, "new-environment") + + // Now update the recipe to change the environment and application. + err = client.Get(ctx, name, recipe) + require.NoError(t, err) + + recipe.Spec.Environment = "new-environment" + recipe.Spec.Application = "new-application" + + err = client.Update(ctx, recipe) + require.NoError(t, err) + + // Now the recipe will delete and re-create the resource. + + // Deletion of the resource is in progress. + status = waitForRecipeStateDeleting(t, client, name, nil) + radius.CompleteOperation(status.Operation.ResumeToken, nil) + + // Resource should be gone. + _, err = radius.Resources(status.Scope, "Applications.Core/extenders").Get(ctx, name.Name) + require.Error(t, err) + + // Recipe will be waiting for extender to complete provisioning. + status = waitForRecipeStateUpdating(t, client, name, nil) + require.Equal(t, "/planes/radius/local/resourceGroups/new-environment-new-application", status.Scope) + require.Equal(t, "/planes/radius/local/resourceGroups/new-environment/providers/Applications.Core/environments/new-environment", status.Environment) + require.Equal(t, "/planes/radius/local/resourceGroups/new-environment-new-application/providers/Applications.Core/applications/new-application", status.Application) + radius.CompleteOperation(status.Operation.ResumeToken, nil) + + // Recipe will update after operation completes + status = waitForRecipeStateReady(t, client, name) + require.Equal(t, "/planes/radius/local/resourceGroups/new-environment-new-application/providers/Applications.Core/extenders/test-recipe-change-envapp", status.Resource) + + // Now delete the recipe. + err = client.Delete(ctx, recipe) + require.NoError(t, err) + + // Deletion of the resource is in progress. + status = waitForRecipeStateDeleting(t, client, name, nil) + radius.CompleteOperation(status.Operation.ResumeToken, nil) + + // Now deleting of the deployment object can complete. + waitForRecipeDeleted(t, client, name) +} + func Test_RecipeReconciler_FailureRecovery(t *testing.T) { // This test tests our ability to recover from failed operations inside Radius. // @@ -140,7 +212,7 @@ func Test_RecipeReconciler_FailureRecovery(t *testing.T) { require.NoError(t, err) // Recipe will be waiting for environment to be created. - createEnvironment(radius) + createEnvironment(radius, "default") // Recipe will be waiting for extender to complete provisioning. status := waitForRecipeStateUpdating(t, client, name, nil) @@ -206,7 +278,7 @@ func Test_RecipeReconciler_WithSecret(t *testing.T) { require.NoError(t, err) // Recipe will be waiting for environment to be created. - createEnvironment(radius) + createEnvironment(radius, "default") // Recipe will be waiting for extender to complete provisioning. status := waitForRecipeStateUpdating(t, client, name, nil) diff --git a/pkg/controller/reconciler/shared_test.go b/pkg/controller/reconciler/shared_test.go index ccabc1227ff..2f1e6ecc6cc 100644 --- a/pkg/controller/reconciler/shared_test.go +++ b/pkg/controller/reconciler/shared_test.go @@ -17,6 +17,8 @@ limitations under the License. package reconciler import ( + "fmt" + v1 "github.com/radius-project/radius/pkg/armrpc/api/v1" radappiov1alpha3 "github.com/radius-project/radius/pkg/controller/api/radapp.io/v1alpha3" "github.com/radius-project/radius/pkg/corerp/api/v20231001preview" @@ -25,11 +27,12 @@ import ( ctrl "sigs.k8s.io/controller-runtime" ) -func createEnvironment(radius *mockRadiusClient) { +func createEnvironment(radius *mockRadiusClient, name string) { + id := fmt.Sprintf("/planes/radius/local/resourceGroups/%s/providers/Applications.Core/environments/%s", name, name) radius.Update(func() { - radius.environments["/planes/radius/local/resourceGroups/default/providers/Applications.Core/environments/default"] = v20231001preview.EnvironmentResource{ - ID: to.Ptr("/planes/radius/local/resourceGroups/default/providers/Applications.Core/environments/default"), - Name: to.Ptr("default"), + radius.environments[id] = v20231001preview.EnvironmentResource{ + ID: to.Ptr(id), + Name: to.Ptr(name), Location: to.Ptr(v1.LocationGlobal), } })