Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 3 additions & 8 deletions api/well_known.go
Original file line number Diff line number Diff line change
Expand Up @@ -130,14 +130,9 @@ const (

// cluster annotations
const (
// MarkClusterDeletionAnnotation is used to mark a cluster for deletion.
MarkClusterDeletionAnnotation = "greenhouse.sap/delete-cluster"
// ScheduleClusterDeletionAnnotation is used to schedule a cluster for deletion.
// Timestamp is set by mutating webhook if cluster is marked for deletion.
ScheduleClusterDeletionAnnotation = "greenhouse.sap/deletion-schedule"
ClusterConnectivityAnnotation = "greenhouse.sap/cluster-connectivity"
ClusterConnectivityKubeconfig = "kubeconfig"
ClusterConnectivityOIDC = "oidc"
ClusterConnectivityAnnotation = "greenhouse.sap/cluster-connectivity"
ClusterConnectivityKubeconfig = "kubeconfig"
ClusterConnectivityOIDC = "oidc"
)

const (
Expand Down
95 changes: 5 additions & 90 deletions docs/user-guides/cluster/offboarding.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,7 @@ description: >
## Content Overview

- [Pre-requisites](#pre-requisites)
- [Schedule Deletion](#schedule-deletion)
- [Impact](#impact)
- [Immediate Deletion](#immediate-deletion)
- [Off-boarding](#off-boarding)
- [Troubleshooting](#troubleshooting)

This guides describes how to off-board an existing Kubernetes cluster in your Greenhouse organization.
Expand All @@ -25,103 +23,20 @@ While all members of an organization can see existing clusters, their management

### Pre-requisites

Offboarding a `Cluster` in Greenhouse requires authenticating to the `greenhouse` cluster via `kubeconfig` file:
Off-boarding a `Cluster` in Greenhouse requires authenticating to the `greenhouse` cluster via `kubeconfig` file:
Comment thread
jellonek marked this conversation as resolved.

- `greenhouse`: The cluster where Greenhouse installation is running on.
- `organization-admin` or `cluster-admin` privileges is needed for deleting a `Cluster` resource.

### Schedule Deletion

By default `Cluster` resource deletion is blocked by `ValidatingWebhookConfiguration` in Greenhouse.
This is done to prevent accidental deletion of cluster resources.
## Off-boarding

List the clusters in your Greenhouse organization:
Off-board a `Cluster` in Greenhouse is initiated by calling the command:

```shell
kubectl --namespace=<greenhouse-organization-name> get clusters
kubectl --namespace=<greenhouse-organization-name> delete cluster <cluster-name>
```

A typical output when you run the command looks like

```shell
NAME AGE ACCESSMODE READY
mycluster-1 15d direct True
mycluster-2 35d direct True
mycluster-3 108d direct True
```

Delete a `Cluster` resource by annotating it with `greenhouse.sap/delete-cluster: "true"`.

Example:

```shell
kubectl annotate cluster mycluster-1 greenhouse.sap/delete-cluster=true --namespace=my-org
```

Once the `Cluster` resource is annotated, the `Cluster` will be scheduled for deletion in 48 hours (UTC time).
This is reflected in the `Cluster` resource annotations and in the status conditions.

View the deletion schedule by inspecting the `Cluster` resource:

```shell
kubectl get cluster mycluster-1 --namespace=my-org -o yaml
````

A typical output when you run the command looks like

```yaml
apiVersion: greenhouse.sap/v1alpha1
kind: Cluster
metadata:
annotations:
greenhouse.sap/delete-cluster: "true"
greenhouse.sap/deletion-schedule: "2025-01-17 11:16:40"
finalizers:
- greenhouse.sap/cleanup
name: mycluster-1
namespace: my-org
spec:
accessMode: direct
kubeConfig:
maxTokenValidity: 72
status:
...
statusConditions:
conditions:
...
- lastTransitionTime: "2025-01-15T11:16:40Z"
message: deletion scheduled at 2025-01-17 11:16:40
reason: ScheduledDeletion
status: "False"
type: Delete
```

In order to cancel the deletion, you can remove the `greenhouse.sap/delete-cluster` annotation:

```shell
kubectl annotate cluster mycluster-1 greenhouse.sap/delete-cluster- --namespace=my-org
```

> the `-` at the end of the annotation name is used to remove the annotation.

### Impact

When a `Cluster` resource is scheduled for deletion, all `Plugin` resources associated with the `Cluster` resource will skip the reconciliation process.

When the deletion schedule is reached, the `Cluster` resource will be deleted and all associated resources `Plugin` resources will be deleted as well.

### Immediate Deletion

In order to delete a `Cluster` resource immediately -

1. annotate the `Cluster` resource with `greenhouse.sap/delete-cluster`. (see [Schedule Deletion](#schedule-deletion))
2. update the `greenhouse.sap/deletion-schedule` annotation to the current date and time.

You can also annotate the `Cluster` resource with `greenhouse.sap/delete-cluster` and `greenhouse.sap/deletion-schedule` at the same time and set the current date and time for deletion.

> The time and date should be in `YYYY-MM-DD HH:MM:SS` format or golang's `time.DateTime` format.
> The time should be in UTC timezone.

## Troubleshooting

If the cluster deletion has failed, you can troubleshoot the issue by inspecting -
Expand Down
5 changes: 0 additions & 5 deletions e2e/cluster/e2e_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -135,11 +135,6 @@ var _ = Describe("Cluster E2E", Ordered, func() {
isOwner := shared.IsResourceOwnedByOwner(crb, sa)
Expect(isOwner).To(BeTrue(), "service account should have an owner reference")
})

It("should successfully schedule the cluster for deletion", func() {
By("verifying for the cluster deletion schedule annotation")
expect.ClusterDeletionIsScheduled(ctx, adminClient, remoteClusterHName, env.TestNamespace)
})
})

// the context executes the tests for Cluster where a secret of type kubeconfig is provided
Expand Down
26 changes: 0 additions & 26 deletions e2e/cluster/expect/expect.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@ import (
greenhouseapis "github.com/cloudoperators/greenhouse/api"
greenhousev1alpha1 "github.com/cloudoperators/greenhouse/api/v1alpha1"
"github.com/cloudoperators/greenhouse/internal/clientutil"
"github.com/cloudoperators/greenhouse/internal/test"
"github.com/cloudoperators/greenhouse/pkg/lifecycle"
)

Expand Down Expand Up @@ -64,31 +63,6 @@ func VerifyClusterVersion(ctx context.Context, adminClient client.Client, remote
Expect(statusKubeVersion).To(Equal(expectedKubeVersion.String()))
}

func ClusterDeletionIsScheduled(ctx context.Context, adminClient client.Client, name, namespace string) {
now := time.Now().UTC()
cluster := &greenhousev1alpha1.Cluster{}
cluster.Name = name
cluster.Namespace = namespace
objKey := client.ObjectKeyFromObject(cluster)

By("marking the cluster for deletion")
test.MustSetAnnotation(ctx, adminClient, cluster, greenhouseapis.MarkClusterDeletionAnnotation, "true")

Eventually(func(g Gomega) bool {
cluster := &greenhousev1alpha1.Cluster{}
err := adminClient.Get(ctx, objKey, cluster)
g.Expect(err).ToNot(HaveOccurred())
annotations := cluster.GetAnnotations()
ok, schedule, err := clientutil.ExtractDeletionSchedule(annotations)
g.Expect(err).ToNot(HaveOccurred(), "there should be no error extracting the deletion schedule")
g.Expect(ok).To(BeTrue(), "cluster should be marked for deletion")
diff := schedule.Sub(now).Hours()
GinkgoWriter.Printf("diff: %f\n", diff)
g.Expect(diff).To(BeNumerically("~", 48, 0.04), "deletion schedule should be within 1 hour")
return true
}).Should(BeTrue(), "cluster should have a deletion schedule annotation")
}

func RevokingRemoteClusterAccess(ctx context.Context, adminClient, remoteClient client.Client, serviceAccountName, clusterName, namespace string) {
By("replacing the kubeconfig key data with the greenhouse kubeconfig key data")
Eventually(func(g Gomega) bool {
Expand Down
16 changes: 3 additions & 13 deletions e2e/shared/cluster.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import (
apierrors "k8s.io/apimachinery/pkg/api/errors"

greenhouseapis "github.com/cloudoperators/greenhouse/api"
"github.com/cloudoperators/greenhouse/internal/clientutil"
"github.com/cloudoperators/greenhouse/internal/test"
"github.com/cloudoperators/greenhouse/pkg/lifecycle"

Expand Down Expand Up @@ -65,8 +64,9 @@ func OffBoardRemoteCluster(ctx context.Context, adminClient, remoteClient client
}
Expect(err).NotTo(HaveOccurred())

By("marking the cluster for deletion")
mustTriggerClusterDeletion(ctx, adminClient, cluster, testStartTime)
By("removing the cluster resource")
err = adminClient.Delete(ctx, cluster)
Expect(err).NotTo(HaveOccurred())

By("checking the cluster resource is eventually deleted")
Eventually(func(g Gomega) {
Expand Down Expand Up @@ -106,13 +106,3 @@ func ClusterIsReady(ctx context.Context, adminClient client.Client, clusterName,
g.Expect(cluster.Status.KubernetesVersion).ToNot(BeEmpty(), "cluster should have kubernetes version")
}).Should(Succeed(), "cluster should be ready")
}

func mustTriggerClusterDeletion(ctx context.Context, k8sClient client.Client, cluster *greenhousev1alpha1.Cluster, testStartTime time.Time) {
GinkgoHelper()
schedule, err := clientutil.ParseDateTime(testStartTime)
Expect(err).ToNot(HaveOccurred(), "there should be no error parsing the time")
test.MustSetAnnotations(ctx, k8sClient, cluster, map[string]string{
greenhouseapis.MarkClusterDeletionAnnotation: "true",
greenhouseapis.ScheduleClusterDeletionAnnotation: schedule.Format(time.DateTime),
})
}
50 changes: 0 additions & 50 deletions internal/clientutil/util.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,17 +8,14 @@ import (
"fmt"
"os"
"path/filepath"
"slices"
"strings"
"time"

corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/types"
"k8s.io/apimachinery/pkg/version"
"k8s.io/cli-runtime/pkg/genericclioptions"
"sigs.k8s.io/controller-runtime/pkg/client"

greehouseapis "github.com/cloudoperators/greenhouse/api"
greenhousev1alpha1 "github.com/cloudoperators/greenhouse/api/v1alpha1"
)

Expand Down Expand Up @@ -81,50 +78,3 @@ func findRecursively(path, dirName string, maxSteps, steps int) (string, error)
}
return dirPath, nil
}

// isMarkedForDeletion - checks if the cluster has deletion annotation and deletion schedule annotation
func isMarkedForDeletion(annotations map[string]string) bool {
_, deletionMarked := annotations[greehouseapis.MarkClusterDeletionAnnotation]
_, scheduleExists := annotations[greehouseapis.ScheduleClusterDeletionAnnotation]
return deletionMarked && scheduleExists
}

// ExtractDeletionSchedule - extracts the deletion schedule from the annotation in time.DateTime format
func ExtractDeletionSchedule(annotations map[string]string) (bool, time.Time, error) {
if annotations == nil {
return false, time.Time{}, nil
}
_, deletionMarked := annotations[greehouseapis.MarkClusterDeletionAnnotation]
deletionSchedule, scheduleExists := annotations[greehouseapis.ScheduleClusterDeletionAnnotation]
if deletionMarked && scheduleExists {
schedule, err := time.Parse(time.DateTime, deletionSchedule)
return scheduleExists, schedule, err
}
return scheduleExists, time.Time{}, nil
}

// ShouldProceedDeletion - checks if the deletion should be allowed if the schedule has elapsed
func ShouldProceedDeletion(now, schedule time.Time) (bool, error) {
// time.Before() compares two time objects
// schedule is formatted as time.DateTime
// so we need to format now as well to time.DateTime otherwise it will always return false
formattedNow, err := ParseDateTime(now)
if err != nil {
return false, err
}
return !formattedNow.Before(schedule), nil
}

// FilterClustersBeingDeleted - filters out the clusters that are marked for deletion
func FilterClustersBeingDeleted(clusters *greenhousev1alpha1.ClusterList) *greenhousev1alpha1.ClusterList {
clusters.Items = slices.DeleteFunc(clusters.Items, func(c greenhousev1alpha1.Cluster) bool {
return isMarkedForDeletion(c.GetAnnotations()) || c.GetDeletionTimestamp() != nil
})
return clusters
}

// ParseDateTime - parses the time object to time.DateTime format
func ParseDateTime(t time.Time) (time.Time, error) {
layout := t.Format(time.DateTime)
return time.Parse(time.DateTime, layout)
}
14 changes: 0 additions & 14 deletions internal/controller/cluster/cluster_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,20 +77,6 @@ func (r *RemoteClusterReconciler) EnsureCreated(ctx context.Context, resource li
if cluster.Spec.AccessMode != greenhousev1alpha1.ClusterAccessModeDirect {
return ctrl.Result{}, lifecycle.Failed, nil
}
// Deletion Schedule mechanism
isScheduled, schedule, err := clientutil.ExtractDeletionSchedule(cluster.GetAnnotations())
if err != nil {
return ctrl.Result{}, lifecycle.Failed, err
}
if isScheduled && cluster.DeletionTimestamp == nil {
if ok, err := clientutil.ShouldProceedDeletion(time.Now(), schedule); ok && err == nil {
err = r.Delete(ctx, cluster)
if err != nil {
return ctrl.Result{}, lifecycle.Failed, err
}
return ctrl.Result{}, lifecycle.Success, nil
}
}
defer UpdateClusterMetrics(cluster)
clusterSecret := &corev1.Secret{}
if err := r.Get(ctx, types.NamespacedName{Name: cluster.GetSecretName(), Namespace: cluster.GetNamespace()}, clusterSecret); err != nil {
Expand Down
24 changes: 0 additions & 24 deletions internal/controller/cluster/cluster_status.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,7 @@ import (
"context"
"errors"
"fmt"
"slices"
"time"

"github.com/go-logr/logr"
corev1 "k8s.io/api/core/v1"
rbacv1 "k8s.io/api/rbac/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
Expand Down Expand Up @@ -86,10 +83,6 @@ func (r *RemoteClusterReconciler) setConditions() lifecycle.Conditioner {
resourcesDeployedCondition,
payloadSchedulable,
}
deletionCondition := r.checkDeletionSchedule(logger, cluster)
if !deletionCondition.IsUnknown() {
conditions = append(conditions, deletionCondition)
}
cluster.Status.KubernetesVersion = k8sVersion
cluster.Status.SetConditions(conditions...)

Expand All @@ -111,23 +104,6 @@ func (r *RemoteClusterReconciler) getClusterSecretAndClientGetter(ctx context.Co
return clusterSecret, restClientGetter, nil
}

func (r *RemoteClusterReconciler) checkDeletionSchedule(logger logr.Logger, cluster *greenhousev1alpha1.Cluster) greenhousemetav1alpha1.Condition {
deletionCondition := greenhousemetav1alpha1.UnknownCondition(greenhousemetav1alpha1.DeleteCondition, "", "")
scheduleExists, schedule, err := clientutil.ExtractDeletionSchedule(cluster.GetAnnotations())
if err != nil {
logger.Error(err, "failed to extract deletion schedule - ignoring deletion schedule")
}
if scheduleExists {
deletionCondition = greenhousemetav1alpha1.FalseCondition(greenhousemetav1alpha1.DeleteCondition, lifecycle.ScheduledDeletionReason, "deletion scheduled at "+schedule.Format(time.DateTime))
} else {
// Remove the deletion condition if it exists as the deletion schedule annotation has been removed
cluster.Status.Conditions = slices.DeleteFunc(cluster.Status.Conditions, func(condition greenhousemetav1alpha1.Condition) bool {
return condition.Type == greenhousemetav1alpha1.DeleteCondition && condition.IsFalse()
})
}
return deletionCondition
}

func (r *RemoteClusterReconciler) reconcileBootstrapResources(ctx context.Context, clientGetter genericclioptions.RESTClientGetter, secret *corev1.Secret) greenhousemetav1alpha1.Condition {
if secret == nil {
return greenhousemetav1alpha1.UnknownCondition(greenhousev1alpha1.ManagedResourcesDeployed, "", "managed resources could not be validated")
Expand Down
20 changes: 0 additions & 20 deletions internal/controller/cluster/status_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@ import (
greenhousemetav1alpha1 "github.com/cloudoperators/greenhouse/api/meta/v1alpha1"
greenhousev1alpha1 "github.com/cloudoperators/greenhouse/api/v1alpha1"
"github.com/cloudoperators/greenhouse/internal/test"
"github.com/cloudoperators/greenhouse/pkg/lifecycle"
)

var _ = Describe("Cluster status", Ordered, func() {
Expand Down Expand Up @@ -263,23 +262,4 @@ var _ = Describe("Cluster status", Ordered, func() {
g.Expect(readyCondition.Status).To(Equal(metav1.ConditionFalse))
}).Should(Succeed())
})

It("should set the deletion condition when the cluster is marked for deletion", func() {
By("marking the cluster for deletion")
Eventually(func(g Gomega) {
g.Expect(test.K8sClient.Get(test.Ctx, types.NamespacedName{Name: validCluster.Name, Namespace: setup.Namespace()}, &validCluster)).
ShouldNot(HaveOccurred(), "There should be no error getting the cluster resource")
validCluster.SetAnnotations(map[string]string{
greenhouseapis.MarkClusterDeletionAnnotation: "true",
})
g.Expect(test.K8sClient.Update(test.Ctx, &validCluster)).To(Succeed(), "there must be no error updating the object")
}).Should(Succeed(), "marking cluster for deletion should eventually succeed")

By("checking the deletion condition")
Eventually(func(g Gomega) {
g.Expect(test.K8sClient.Get(test.Ctx, types.NamespacedName{Name: validCluster.Name, Namespace: setup.Namespace()}, &validCluster)).ShouldNot(HaveOccurred(), "There should be no error getting the cluster resource")
g.Expect(validCluster.Status.GetConditionByType(greenhousemetav1alpha1.DeleteCondition)).ToNot(BeNil(), "The Delete condition should be present")
g.Expect(validCluster.Status.GetConditionByType(greenhousemetav1alpha1.DeleteCondition).Reason).To(Equal(lifecycle.ScheduledDeletionReason))
}).Should(Succeed())
})
})
Loading
Loading