Skip to content
Merged
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
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ shoot-grafter continuously monitors Garden clusters for Shoots matching specific
6. **Configures RBAC**: Optionally sets up role-based access control on Shoot clusters for Greenhouse service accounts
7. **Maintains synchronization**: Keeps Greenhouse Cluster resources in sync with their corresponding Shoots

shoot-grafter currently only creates clusters matching Shoots but does not automatically clean up clusters when Shoot labels change or Shoots are deleted. Manual cleanup of Greenhouse Cluster resources is required in these scenarios.
shoot-grafter currently does not automatically clean up clusters when Shoot labels change. Manual cleanup of Greenhouse Cluster resources is required in such a scenario.

## Architecture

Expand Down Expand Up @@ -147,6 +147,7 @@ For each CareInstruction, a dedicated Shoot controller is dynamically created an
- Generates Greenhouse Cluster resources with appropriate labels
- Optionally configures OIDC authentication on Shoot clusters for Greenhouse access. Also see respective [Greenhouse docs](https://cloudoperators.github.io/greenhouse/docs/user-guides/cluster/oidc_connectivity/) and [Gardener docs](https://gardener.cloud/docs/guides/administer-shoots/oidc-login/#configure-the-shoot-cluster)
- Optionally configures RBAC on the Shoot cluster for Greenhouse access
- Cleans up Greenhouse clusters when the corresponding Gardener Shoot is removed

## Custom Resource: CareInstruction

Expand Down Expand Up @@ -435,6 +436,7 @@ shoot-grafter emits the following events during Shoot reconciliation:
| `ShootReconciled` | Successfully completed reconciliation for a Shoot |
| `SecretCreated` | Created Greenhouse secret with cluster credentials |
| `SecretUpdated` | Updated existing Greenhouse secret with new credentials |
| `ClusterDeleted` | Cluster deletion was requested in the Greenhouse |
| `ShootDeleted` | Shoot was deleted from the Garden cluster |
| `OIDCConfigured` | Successfully configured OIDC authentication for the Shoot |
| `RBACCreated` | Created RBAC ClusterRoleBinding for Greenhouse ServiceAccount on the Shoot |
Comment thread
Copilot marked this conversation as resolved.
Expand All @@ -447,6 +449,7 @@ shoot-grafter emits the following events during Shoot reconciliation:
| `CAConfigMapFetchFailed` | Failed to fetch CA certificate ConfigMap | Verify ConfigMap `<shoot-name>.ca-cluster` exists in Garden namespace |
| `CADataMissing` | CA certificate data is empty in ConfigMap | Check ConfigMap data contains valid `ca.crt` entry |
| `SecretOperationFailed` | Failed to create or update Greenhouse secret | Check RBAC permissions and Greenhouse cluster connectivity |
| `ClusterDeletionFailed` | Failed to delete a Cluster in the Greenhouse | Check event details for the particular error reason |
| `OIDCConfigurationFailed` | Failed to configure OIDC authentication on the Shoot | Verify AuthenticationConfigMap exists and contains valid configuration; check Garden cluster connectivity and permissions |
| `ShootClientFetchFailed` | Failed to get Shoot cluster client | Verify Shoot is accessible and kubeconfig is valid; check network connectivity to Shoot cluster |
| `RBACCreationFailed` | Failed to create RBAC ClusterRoleBinding on the Shoot | Check connectivity to Shoot cluster; verify service account has sufficient permissions |
Expand Down
49 changes: 44 additions & 5 deletions controller/shoot/shoot_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -112,28 +112,60 @@ func (r *ShootController) Reconcile(ctx context.Context, req ctrl.Request) (ctrl

// Check if a cluster with this name already exists and is owned by a different CareInstruction
// Do this early to avoid unnecessary work
var existingCluster greenhousev1alpha1.Cluster
var (
existingCluster greenhousev1alpha1.Cluster
ownerLabel string
hasLabel bool
)
existingClusterFound := false
err := r.GreenhouseClient.Get(ctx, client.ObjectKey{Name: req.Name, Namespace: r.CareInstruction.Namespace}, &existingCluster)
if err == nil {
if err != nil {
if !apierrors.IsNotFound(err) {
return ctrl.Result{}, err
}
} else {
// Cluster exists - check ownership
if ownerLabel, hasLabel := existingCluster.Labels[v1alpha1.CareInstructionLabel]; hasLabel && ownerLabel != r.CareInstruction.Name {
// TODO: emit event on CareInstruction
if ownerLabel, hasLabel = existingCluster.Labels[v1alpha1.CareInstructionLabel]; hasLabel && ownerLabel != r.CareInstruction.Name {
r.Info("Skipping shoot - cluster already owned by different CareInstruction",
"shoot", req.Name,
"currentOwner", ownerLabel,
"attemptedOwner", r.CareInstruction.Name)
return ctrl.Result{}, nil
}
existingClusterFound = true
}

var shoot gardenerv1beta1.Shoot
if err := r.GardenClient.Get(ctx, client.ObjectKey{Namespace: req.Namespace, Name: req.Name}, &shoot); err != nil {
r.Info("unable to fetch Shoot")
if client.IgnoreNotFound(err) == nil {
// Shoot was deleted
if existingClusterFound && hasLabel && ownerLabel == r.CareInstruction.Name {
if err := r.RequestClusterDeletion(ctx, existingCluster); err != nil {
r.Info(
"error during Cluster removal",
"name", existingCluster.Name,
"error", err.Error(),
)
r.emitEvent(r.CareInstruction, corev1.EventTypeWarning, "ClusterDeletionFailed",
fmt.Sprintf(
"Shoot %s/%s deleted, deletion of Cluster %s/%s failed with error: %s",
r.CareInstruction.Namespace, existingCluster.Name,
existingCluster.Namespace, existingCluster.Name,
err.Error(),
))
return ctrl.Result{}, client.IgnoreNotFound(err)
}
r.emitEvent(r.CareInstruction, corev1.EventTypeNormal, "ClusterDeleted",
fmt.Sprintf(
"Shoot %s/%s deleted, deletion of Cluster %s/%s was requested",
r.CareInstruction.Namespace, existingCluster.Name,
existingCluster.Namespace, existingCluster.Name,
))
}
r.emitEvent(r.CareInstruction, corev1.EventTypeNormal, "ShootDeleted",
fmt.Sprintf("Shoot %s/%s was deleted", req.Namespace, req.Name))
}
r.Info("unable to fetch Shoot", "name", req.Name, "error", err.Error())
return ctrl.Result{}, client.IgnoreNotFound(err)
}

Expand Down Expand Up @@ -313,6 +345,13 @@ func (r *ShootController) Reconcile(ctx context.Context, req ctrl.Request) (ctrl
return ctrl.Result{}, nil
}

func (r *ShootController) RequestClusterDeletion(ctx context.Context, existingCluster greenhousev1alpha1.Cluster) error {
if err := r.GreenhouseClient.Delete(ctx, &existingCluster); err != nil {
return err
}
return nil
}

// GenerateName generates a name for the shoot controller based on the garden cluster name.
func GenerateName(gardenClusterName string) string {
return "shoot-controller-" + gardenClusterName
Expand Down
82 changes: 82 additions & 0 deletions controller/shoot/shoot_controller_fake_client_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
// SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and Greenhouse contributors
// SPDX-License-Identifier: Apache-2.0

package shoot_test
Comment thread
uwe-mayer marked this conversation as resolved.

import (
"context"
"errors"

"shoot-grafter/api/v1alpha1"
"shoot-grafter/controller/shoot"
"shoot-grafter/internal/test"

greenhousev1alpha1 "github.com/cloudoperators/greenhouse/api/v1alpha1"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/client/fake"
"sigs.k8s.io/controller-runtime/pkg/client/interceptor"
)

var _ = Describe("Shoot Controller with fake client", func() {
AfterEach(func() {
clusters := &greenhousev1alpha1.ClusterList{}
Expect(test.K8sClient.List(test.Ctx, clusters, client.InNamespace("default"))).To(Succeed(), "should list Clusters")
for _, cluster := range clusters.Items {
Expect(client.IgnoreNotFound(test.K8sClient.Delete(test.Ctx, &cluster))).To(Succeed(), "should delete Cluster resource")
}
})

When("a shoot controller with fake client is starting", func() {
BeforeEach(func() {
careInstruction = &v1alpha1.CareInstruction{
ObjectMeta: metav1.ObjectMeta{
Name: "test-non-happy-path",
Namespace: "default",
},
}
})
It("should handle shoot removal - non-happy path", func() {
fakeClient := fake.NewClientBuilder().
WithInterceptorFuncs(interceptor.Funcs{
Get: func(ctx context.Context, client client.WithWatch, key client.ObjectKey, obj client.Object, opts ...client.GetOption) error {
return errors.New("fake cluster conflict or timeout error")
},
}).Build()
shootController := shoot.ShootController{
GreenhouseClient: test.K8sClient,
GardenClient: fakeClient,
Logger: ctrl.Log.WithName("controllers").WithName("ShootController"),
Name: "ShootController",
CareInstruction: careInstruction,
}

// Simulate the Greenhouse Cluster existing
cluster := &greenhousev1alpha1.Cluster{
ObjectMeta: metav1.ObjectMeta{
Name: careInstruction.Name,
Namespace: careInstruction.Namespace,
Labels: map[string]string{
v1alpha1.CareInstructionLabel: careInstruction.Name,
},
},
Spec: greenhousev1alpha1.ClusterSpec{
AccessMode: greenhousev1alpha1.ClusterAccessModeDirect,
},
}
Expect(test.K8sClient.Create(test.Ctx, cluster)).To(Succeed(), "should create Cluster resource")

req := ctrl.Request{}
req.Name = cluster.Name
req.Namespace = cluster.Namespace
_, err := shootController.Reconcile(test.Ctx, req)
Expect(err).To(HaveOccurred(), "should fail reconciliation")

existingCluster := &greenhousev1alpha1.Cluster{}
Expect(test.K8sClient.Get(test.Ctx, client.ObjectKeyFromObject(cluster), existingCluster)).To(Succeed(), "should keep Cluster resource")
})
})
})
104 changes: 104 additions & 0 deletions controller/shoot/shoot_controller_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import (
webhookv1alpha1 "shoot-grafter/webhook/v1alpha1"

greenhouseapis "github.com/cloudoperators/greenhouse/api"
greenhousev1alpha1 "github.com/cloudoperators/greenhouse/api/v1alpha1"
gardenerv1beta1 "github.com/gardener/gardener/pkg/apis/core/v1beta1"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
Expand Down Expand Up @@ -1017,6 +1018,109 @@ var _ = Describe("Shoot Controller", func() {
})
})

When("a CareInstruction is created", func() {
BeforeEach(func() {
careInstruction = &v1alpha1.CareInstruction{
ObjectMeta: metav1.ObjectMeta{
Name: "test-careinstruction-shoot-removal",
Namespace: "default",
},
Spec: v1alpha1.CareInstructionSpec{
ShootSelector: &v1alpha1.ShootSelector{
LabelSelector: &metav1.LabelSelector{
MatchLabels: map[string]string{
"env": "test",
},
},
},
},
}
})

It("should handle shoot removal - happy path", func() {
shoot := &gardenerv1beta1.Shoot{
ObjectMeta: metav1.ObjectMeta{
Name: "test-shoot-sr",
Namespace: "default",
Labels: map[string]string{
"env": "test",
},
},
}
Expect(test.GardenK8sClient.Create(test.Ctx, shoot)).To(Succeed(), "should create Shoot resource")
shoot.Status = gardenerv1beta1.ShootStatus{
AdvertisedAddresses: []gardenerv1beta1.ShootAdvertisedAddress{
{
Name: "external",
URL: "https://api-server.test-shoot-sr.example.com",
},
},
}
Expect(test.GardenK8sClient.Status().Update(test.Ctx, shoot)).To(Succeed(), "should update Shoot status")

// Create ConfigMap with CA data so reconciliation can proceed.
cm := &corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{
Name: "test-shoot-sr.ca-cluster",
Namespace: "default",
},
Data: map[string]string{
"ca.crt": "test-ca-data",
},
}
Expect(test.GardenK8sClient.Create(test.Ctx, cm)).To(Succeed(), "should create ConfigMap resource")

// Wait for initial reconciliation (Secret creation).
Eventually(func(g Gomega) bool {
secret := &corev1.Secret{}
return test.K8sClient.Get(test.Ctx, client.ObjectKey{Name: shoot.Name, Namespace: "default"}, secret) == nil
}).Should(BeTrue(), "should eventually create secret")

// Simulate the Greenhouse Cluster existing (it is created by Greenhouse based on the Secret).
cluster := &greenhousev1alpha1.Cluster{
ObjectMeta: metav1.ObjectMeta{
Name: shoot.Name,
Namespace: "default",
Labels: map[string]string{
v1alpha1.CareInstructionLabel: careInstruction.Name,
},
},
Spec: greenhousev1alpha1.ClusterSpec{
AccessMode: greenhousev1alpha1.ClusterAccessModeDirect,
},
}
Expect(test.K8sClient.Create(test.Ctx, cluster)).To(Succeed(), "should create Cluster resource")

Expect(test.GardenK8sClient.Delete(test.Ctx, shoot)).To(Succeed(), "should remove Shoot resource")

Eventually(func(g Gomega) bool {
var existingCluster greenhousev1alpha1.Cluster
if err := test.K8sClient.Get(test.Ctx, client.ObjectKeyFromObject(shoot), &existingCluster); err != nil {
return client.IgnoreNotFound(err) == nil
}
return false
}).Should(BeTrue(), "should eventually notice removal of Cluster resource")

Eventually(func(g Gomega) bool {
events := &corev1.EventList{}
g.Expect(test.K8sClient.List(test.Ctx, events, client.InNamespace("default"))).To(Succeed(), "should list events")

hasClusterDeletionEvent := false

for _, event := range events.Items {
if event.InvolvedObject.Name == careInstruction.Name &&
event.InvolvedObject.Kind == "CareInstruction" {
if event.Reason == "ClusterDeleted" && event.Type == corev1.EventTypeNormal {
hasClusterDeletionEvent = true
}
}
}

return hasClusterDeletionEvent
}).Should(BeTrue(), "should eventually find ClusterDeleted event")
})
})

When("testing event recording", func() {
BeforeEach(func() {
careInstruction = &v1alpha1.CareInstruction{
Expand Down
Loading