Skip to content

Commit 4e65844

Browse files
committed
bug: deploymentType Local and management cluster
Fixes a bug where a SveltosCluster representing the management cluster itself (self-managed) matching a ClusterProfile could cause its ClusterSummary to delete resources deployed by other, unrelated ClusterSummary instances. When a PolicyRef/KustomizationRef uses deploymentType: Local, resources are deployed into the management cluster and tagged with a projectsveltos.io/clustersummary annotation identifying the owning ClusterSummary, so stale-resource cleanup only removes what that specific ClusterSummary deployed in the management cluster. That scoping was only ever applied to the "clean the management cluster" pass. The "clean the remote/managed cluster" pass never set it, which was harmless as long as the remote cluster was a distinct physical cluster from the management cluster. When the managed cluster is a self-managed SveltosCluster (its remote client/config resolve back to the management cluster itself), the remote-cluster cleanup pass ends up scanning the management cluster with no scoping at all, and deletes every same-GVK, same-ClusterProfile resource not present in its own (often empty, since all its policies are Local) desired state, including resources deployed by other ClusterSummary instances via Local. Fix: - The clustersummary annotation is now set on every deployed resource, not only ones deployed via Local. - Stale-resource scanning now checks this annotation on both cleanup passes, but only as a protective signal: a resource is skipped only when it's explicitly annotated for a different ClusterSummary. A resource with no annotation at all (deployed by a version before this change) still falls through to the existing ownership/reference checks, so upgrades don't leave pre-existing resources permanently undetectable as stale.
1 parent 0e80ac8 commit 4e65844

4 files changed

Lines changed: 432 additions & 12 deletions

File tree

controllers/export_test.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,7 @@ var (
121121
GetDeployedGroupVersionKinds = getDeployedGroupVersionKinds
122122
GetSecret = getSecret
123123
ReadFiles = readFiles
124+
GetClusterSummaryAnnotationValue = getClusterSummaryAnnotationValue
124125

125126
AddExtraLabels = addExtraLabels
126127
AddExtraAnnotations = addExtraAnnotations
@@ -195,6 +196,9 @@ const (
195196
HelmActionUpgrade = upgrade
196197
HelmActionDowngrade = downgrade
197198
HelmActionUninstall = uninstall
199+
200+
ClusterSummaryAnnotation = clusterSummaryAnnotation
201+
DeploymentTypeAnnotation = deploymentTypeAnnotation
198202
)
199203

200204
var (

controllers/handlers_utils.go

Lines changed: 56 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ import (
6565

6666
const (
6767
clusterSummaryAnnotation = "projectsveltos.io/clustersummary"
68+
deploymentTypeAnnotation = "projectsveltos.io/deploymenttype"
6869
subresourcesAnnotation = "projectsveltos.io/subresources"
6970
pathAnnotation = "path"
7071
)
@@ -390,6 +391,29 @@ func applyPatches(ctx context.Context, clusterSummary *configv1beta1.ClusterSumm
390391
return referencedUnstructured, nil
391392
}
392393

394+
// addStaleResourceScopingAnnotations annotates policy with the information stale-resource
395+
// cleanup needs to scope its scan to resources this exact ClusterSummary/deploymentType pair
396+
// deployed, rather than to everything present in the cluster.
397+
//
398+
// Just setting (Cluster)Profile as OwnerReference is not enough: a SveltosCluster can be
399+
// self-managed (its remote client/config resolve back to the management cluster), in which
400+
// case the "clean the management cluster" and "clean the remote cluster" passes end up
401+
// scanning the same physical cluster. Without these annotations, each pass would consider the
402+
// other's just-deployed resources stale, since neither pass's desired-state set includes what
403+
// the other pass deployed.
404+
func addStaleResourceScopingAnnotations(policy *unstructured.Unstructured, deployingToMgmtCluster bool,
405+
clusterSummary *configv1beta1.ClusterSummary) {
406+
407+
value := getClusterSummaryAnnotationValue(clusterSummary)
408+
deployer.AddAnnotation(policy, clusterSummaryAnnotation, value)
409+
410+
deploymentTypeValue := string(configv1beta1.DeploymentTypeRemote)
411+
if deployingToMgmtCluster {
412+
deploymentTypeValue = string(configv1beta1.DeploymentTypeLocal)
413+
}
414+
deployer.AddAnnotation(policy, deploymentTypeAnnotation, deploymentTypeValue)
415+
}
416+
393417
// deployUnstructured deploys referencedUnstructured objects.
394418
// Returns an error if one occurred. Otherwise it returns a slice containing the name of
395419
// the policies deployed in the form of kind.group:namespace:name for namespaced policies
@@ -484,15 +508,7 @@ func deployUnstructured(ctx context.Context, deployingToMgmtCluster bool, destCo
484508
deployer.AddMetadata(policy, resourceInfo.GetResourceVersion(), profile,
485509
clusterSummary.Spec.ClusterProfileSpec.ExtraLabels, clusterSummary.Spec.ClusterProfileSpec.ExtraAnnotations)
486510

487-
if deployingToMgmtCluster {
488-
// When deploying resources in the management cluster, just setting (Cluster)Profile as OwnerReference is
489-
// not enough. We also need to track which ClusterSummary is creating the resource. Otherwise while
490-
// trying to clean stale resources those objects will be incorrectly removed.
491-
// An extra annotation is added here to indicate the clustersummary, so the managed cluster, this
492-
// resource was created for
493-
value := getClusterSummaryAnnotationValue(clusterSummary)
494-
deployer.AddAnnotation(policy, clusterSummaryAnnotation, value)
495-
}
511+
addStaleResourceScopingAnnotations(policy, deployingToMgmtCluster, clusterSummary)
496512

497513
if requeue {
498514
if clusterSummary.Spec.ClusterProfileSpec.SyncMode != configv1beta1.SyncModeDryRun {
@@ -1110,14 +1126,42 @@ func processDeployedGVKs(ctx context.Context, isMgmtCluster bool, remoteConfig *
11101126

11111127
leavePolicies := isLeavePolicies(clusterSummary, logger)
11121128
isDryRun := clusterSummary.Spec.ClusterProfileSpec.SyncMode == configv1beta1.SyncModeDryRun
1113-
var skipAnnotationKey, skipAnnotationValue string
1129+
ownClusterSummaryValue := getClusterSummaryAnnotationValue(clusterSummary)
1130+
1131+
expectedDeploymentType := string(configv1beta1.DeploymentTypeRemote)
11141132
if isMgmtCluster {
1115-
skipAnnotationValue = getClusterSummaryAnnotationValue(clusterSummary)
1116-
skipAnnotationKey = clusterSummaryAnnotation
1133+
expectedDeploymentType = string(configv1beta1.DeploymentTypeLocal)
11171134
}
11181135

11191136
for j := range list.Items {
11201137
r := list.Items[j]
1138+
1139+
// Protective signal, same reasoning as the clustersummary annotation check below:
1140+
// skip a resource here when it is explicitly annotated for the *other* deployment
1141+
// type (relevant when this ClusterSummary's remote cluster is the self-managed
1142+
// management cluster: the Local and Remote cleanup passes then scan the same
1143+
// physical cluster, and each pass's currentPolicies only reflects its own half of
1144+
// what was just deployed, so without this check each pass would consider the
1145+
// other's resources stale). A resource missing the annotation predates it being
1146+
// set on every deploy and must still fall through to the checks below.
1147+
if v, ok := r.GetAnnotations()[deploymentTypeAnnotation]; ok && v != expectedDeploymentType {
1148+
continue
1149+
}
1150+
1151+
// Only rely on the clustersummary annotation as a protective signal: skip a
1152+
// resource here when it is explicitly annotated for a *different* ClusterSummary
1153+
// (relevant when this ClusterSummary's remote cluster is the self-managed
1154+
// management cluster, and another ClusterSummary deployed there via
1155+
// deploymentType: Local). A resource missing the annotation entirely predates it
1156+
// being set on every deploy, and must still fall through to the normal
1157+
// canDelete/isResourceOwner checks below, or upgraded ClusterSummaries would never
1158+
// detect their own pre-existing resources as stale again.
1159+
var skipAnnotationKey, skipAnnotationValue string
1160+
if _, ok := r.GetAnnotations()[clusterSummaryAnnotation]; ok {
1161+
skipAnnotationKey = clusterSummaryAnnotation
1162+
skipAnnotationValue = ownClusterSummaryValue
1163+
}
1164+
11211165
rr, err := deployer.UndeployStaleResource(ctx, skipAnnotationKey, skipAnnotationValue, localClient,
11221166
profile, leavePolicies, isDryRun, r, currentPolicies, logger)
11231167
if err != nil {

controllers/handlers_utils_test.go

Lines changed: 238 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1018,6 +1018,244 @@ var _ = Describe("HandlersUtils", func() {
10181018
}, timeout, pollingInterval).Should(BeTrue())
10191019
})
10201020

1021+
It(`undeployStaleResources scopes deletion to resources annotated for this ClusterSummary, even when isMgmtCluster is false`, func() {
1022+
// This reproduces the scenario of a self-managed SveltosCluster: this ClusterSummary's
1023+
// "remote" cluster is physically the same cluster other ClusterSummaries deploy Local
1024+
// resources into. The remote-cluster cleanup pass (isMgmtCluster=false) must still only
1025+
// ever touch resources this exact ClusterSummary created, identified via the
1026+
// clustersummary annotation, and must leave other ClusterSummaries' resources alone.
1027+
ownClusterRoleName := randomString()
1028+
ownClusterRole := &rbacv1.ClusterRole{
1029+
ObjectMeta: metav1.ObjectMeta{
1030+
Name: ownClusterRoleName,
1031+
Labels: map[string]string{
1032+
deployer.ReasonLabel: string(libsveltosv1beta1.FeatureResources),
1033+
},
1034+
Annotations: map[string]string{
1035+
deployer.ReferenceKindAnnotation: string(libsveltosv1beta1.ConfigMapReferencedResourceKind),
1036+
deployer.ReferenceNamespaceAnnotation: randomString(),
1037+
deployer.ReferenceNameAnnotation: randomString(),
1038+
controllers.ClusterSummaryAnnotation: controllers.GetClusterSummaryAnnotationValue(clusterSummary),
1039+
},
1040+
},
1041+
}
1042+
1043+
otherClusterRoleName := randomString()
1044+
otherClusterRole := &rbacv1.ClusterRole{
1045+
ObjectMeta: metav1.ObjectMeta{
1046+
Name: otherClusterRoleName,
1047+
Labels: map[string]string{
1048+
deployer.ReasonLabel: string(libsveltosv1beta1.FeatureResources),
1049+
},
1050+
Annotations: map[string]string{
1051+
deployer.ReferenceKindAnnotation: string(libsveltosv1beta1.ConfigMapReferencedResourceKind),
1052+
deployer.ReferenceNamespaceAnnotation: randomString(),
1053+
deployer.ReferenceNameAnnotation: randomString(),
1054+
// Deployed (Local) by a different ClusterSummary for a different managed cluster.
1055+
controllers.ClusterSummaryAnnotation: randomString(),
1056+
},
1057+
},
1058+
}
1059+
1060+
// Simulates a resource deployed by an older addon-controller version, before the
1061+
// clustersummary annotation was set on every deploy. It must still be detected as
1062+
// stale and removed by this same ClusterSummary; otherwise upgrading would leave such
1063+
// resources undeletable forever.
1064+
legacyClusterRoleName := randomString()
1065+
legacyClusterRole := &rbacv1.ClusterRole{
1066+
ObjectMeta: metav1.ObjectMeta{
1067+
Name: legacyClusterRoleName,
1068+
Labels: map[string]string{
1069+
deployer.ReasonLabel: string(libsveltosv1beta1.FeatureResources),
1070+
},
1071+
Annotations: map[string]string{
1072+
deployer.ReferenceKindAnnotation: string(libsveltosv1beta1.ConfigMapReferencedResourceKind),
1073+
deployer.ReferenceNamespaceAnnotation: randomString(),
1074+
deployer.ReferenceNameAnnotation: randomString(),
1075+
},
1076+
},
1077+
}
1078+
1079+
Expect(testEnv.Create(context.TODO(), ownClusterRole)).To(Succeed())
1080+
Expect(waitForObject(ctx, testEnv.Client, ownClusterRole)).To(Succeed())
1081+
Expect(testEnv.Create(context.TODO(), otherClusterRole)).To(Succeed())
1082+
Expect(waitForObject(ctx, testEnv.Client, otherClusterRole)).To(Succeed())
1083+
Expect(testEnv.Create(context.TODO(), legacyClusterRole)).To(Succeed())
1084+
Expect(waitForObject(ctx, testEnv.Client, legacyClusterRole)).To(Succeed())
1085+
1086+
currentClusterProfile := &configv1beta1.ClusterProfile{}
1087+
Expect(testEnv.Get(context.TODO(),
1088+
types.NamespacedName{Name: clusterProfile.Name},
1089+
currentClusterProfile)).To(Succeed())
1090+
1091+
addOwnerReference(context.TODO(), testEnv.Client, ownClusterRole, currentClusterProfile)
1092+
addOwnerReference(context.TODO(), testEnv.Client, otherClusterRole, currentClusterProfile)
1093+
addOwnerReference(context.TODO(), testEnv.Client, legacyClusterRole, currentClusterProfile)
1094+
1095+
currentClusterSummary := &configv1beta1.ClusterSummary{}
1096+
Expect(testEnv.Get(context.TODO(),
1097+
types.NamespacedName{Namespace: clusterSummary.Namespace, Name: clusterSummary.Name},
1098+
currentClusterSummary)).To(Succeed())
1099+
currentClusterSummary.Status.FeatureSummaries = []configv1beta1.FeatureSummary{
1100+
{
1101+
FeatureID: libsveltosv1beta1.FeatureResources,
1102+
Status: libsveltosv1beta1.FeatureStatusProvisioned,
1103+
},
1104+
}
1105+
currentClusterSummary.Status.DeployedGVKs = []libsveltosv1beta1.FeatureDeploymentInfo{
1106+
{
1107+
FeatureID: libsveltosv1beta1.FeatureResources,
1108+
DeployedGroupVersionKind: []string{
1109+
testClusterRoleKindV1,
1110+
},
1111+
},
1112+
}
1113+
Expect(testEnv.Status().Update(context.TODO(), currentClusterSummary)).To(Succeed())
1114+
1115+
deployedGKVs := controllers.GetDeployedGroupVersionKinds(currentClusterSummary, libsveltosv1beta1.FeatureResources)
1116+
Expect(deployedGKVs).ToNot(BeEmpty())
1117+
1118+
// None of the ClusterRoles is in currentPolicies (nil), so all are candidates for
1119+
// deletion were it not for the clustersummary-annotation scoping.
1120+
_, err := controllers.UndeployStaleResources(context.TODO(), false, testEnv.Config, testEnv.Client,
1121+
libsveltosv1beta1.FeatureResources, currentClusterSummary, deployedGKVs, nil,
1122+
textlogger.NewLogger(textlogger.NewConfig()))
1123+
Expect(err).To(BeNil())
1124+
1125+
// Own resource is no longer referenced: it must be removed.
1126+
Eventually(func() bool {
1127+
currentClusterRole := &rbacv1.ClusterRole{}
1128+
err = testEnv.Get(context.TODO(), types.NamespacedName{Name: ownClusterRoleName}, currentClusterRole)
1129+
return err != nil && apierrors.IsNotFound(err)
1130+
}, timeout, pollingInterval).Should(BeTrue())
1131+
1132+
// Legacy (pre-annotation) resource is no longer referenced either: it must still be
1133+
// detected as stale and removed.
1134+
Eventually(func() bool {
1135+
currentClusterRole := &rbacv1.ClusterRole{}
1136+
err = testEnv.Get(context.TODO(), types.NamespacedName{Name: legacyClusterRoleName}, currentClusterRole)
1137+
return err != nil && apierrors.IsNotFound(err)
1138+
}, timeout, pollingInterval).Should(BeTrue())
1139+
1140+
// Other ClusterSummary's resource must never be touched.
1141+
Consistently(func() error {
1142+
currentClusterRole := &rbacv1.ClusterRole{}
1143+
return testEnv.Get(context.TODO(), types.NamespacedName{Name: otherClusterRoleName}, currentClusterRole)
1144+
}, timeout, pollingInterval).Should(BeNil())
1145+
})
1146+
1147+
It(`undeployStaleResources does not remove resources deployed by the other deployment type of the same ClusterSummary`, func() {
1148+
// Further self-managed SveltosCluster scenario: this time both resources were deployed
1149+
// by the SAME ClusterSummary, one via deploymentType Local and one via deploymentType
1150+
// Remote. Because it is the same ClusterSummary, the clustersummary annotation alone
1151+
// cannot tell the two apart once the remote cluster is physically the management
1152+
// cluster. The deploymenttype annotation must protect the Local resource from the
1153+
// Remote cleanup pass, and the Remote resource from the Local cleanup pass.
1154+
localClusterRoleName := randomString()
1155+
localClusterRole := &rbacv1.ClusterRole{
1156+
ObjectMeta: metav1.ObjectMeta{
1157+
Name: localClusterRoleName,
1158+
Labels: map[string]string{
1159+
deployer.ReasonLabel: string(libsveltosv1beta1.FeatureResources),
1160+
},
1161+
Annotations: map[string]string{
1162+
deployer.ReferenceKindAnnotation: string(libsveltosv1beta1.ConfigMapReferencedResourceKind),
1163+
deployer.ReferenceNamespaceAnnotation: randomString(),
1164+
deployer.ReferenceNameAnnotation: randomString(),
1165+
controllers.ClusterSummaryAnnotation: controllers.GetClusterSummaryAnnotationValue(clusterSummary),
1166+
controllers.DeploymentTypeAnnotation: string(configv1beta1.DeploymentTypeLocal),
1167+
},
1168+
},
1169+
}
1170+
1171+
remoteClusterRoleName := randomString()
1172+
remoteClusterRole := &rbacv1.ClusterRole{
1173+
ObjectMeta: metav1.ObjectMeta{
1174+
Name: remoteClusterRoleName,
1175+
Labels: map[string]string{
1176+
deployer.ReasonLabel: string(libsveltosv1beta1.FeatureResources),
1177+
},
1178+
Annotations: map[string]string{
1179+
deployer.ReferenceKindAnnotation: string(libsveltosv1beta1.ConfigMapReferencedResourceKind),
1180+
deployer.ReferenceNamespaceAnnotation: randomString(),
1181+
deployer.ReferenceNameAnnotation: randomString(),
1182+
controllers.ClusterSummaryAnnotation: controllers.GetClusterSummaryAnnotationValue(clusterSummary),
1183+
controllers.DeploymentTypeAnnotation: string(configv1beta1.DeploymentTypeRemote),
1184+
},
1185+
},
1186+
}
1187+
1188+
Expect(testEnv.Create(context.TODO(), localClusterRole)).To(Succeed())
1189+
Expect(waitForObject(ctx, testEnv.Client, localClusterRole)).To(Succeed())
1190+
Expect(testEnv.Create(context.TODO(), remoteClusterRole)).To(Succeed())
1191+
Expect(waitForObject(ctx, testEnv.Client, remoteClusterRole)).To(Succeed())
1192+
1193+
currentClusterProfile := &configv1beta1.ClusterProfile{}
1194+
Expect(testEnv.Get(context.TODO(),
1195+
types.NamespacedName{Name: clusterProfile.Name},
1196+
currentClusterProfile)).To(Succeed())
1197+
1198+
addOwnerReference(context.TODO(), testEnv.Client, localClusterRole, currentClusterProfile)
1199+
addOwnerReference(context.TODO(), testEnv.Client, remoteClusterRole, currentClusterProfile)
1200+
1201+
currentClusterSummary := &configv1beta1.ClusterSummary{}
1202+
Expect(testEnv.Get(context.TODO(),
1203+
types.NamespacedName{Namespace: clusterSummary.Namespace, Name: clusterSummary.Name},
1204+
currentClusterSummary)).To(Succeed())
1205+
currentClusterSummary.Status.FeatureSummaries = []configv1beta1.FeatureSummary{
1206+
{
1207+
FeatureID: libsveltosv1beta1.FeatureResources,
1208+
Status: libsveltosv1beta1.FeatureStatusProvisioned,
1209+
},
1210+
}
1211+
currentClusterSummary.Status.DeployedGVKs = []libsveltosv1beta1.FeatureDeploymentInfo{
1212+
{
1213+
FeatureID: libsveltosv1beta1.FeatureResources,
1214+
DeployedGroupVersionKind: []string{
1215+
testClusterRoleKindV1,
1216+
},
1217+
},
1218+
}
1219+
Expect(testEnv.Status().Update(context.TODO(), currentClusterSummary)).To(Succeed())
1220+
1221+
deployedGKVs := controllers.GetDeployedGroupVersionKinds(currentClusterSummary, libsveltosv1beta1.FeatureResources)
1222+
Expect(deployedGKVs).ToNot(BeEmpty())
1223+
1224+
// Neither ClusterRole is in currentPolicies (nil), so both are candidates for deletion
1225+
// were it not for the deploymenttype-annotation scoping.
1226+
1227+
// isMgmtCluster=false simulates the Remote cleanup pass: it must remove the
1228+
// Remote-deployed resource, but leave the Local-deployed one alone.
1229+
_, err := controllers.UndeployStaleResources(context.TODO(), false, testEnv.Config, testEnv.Client,
1230+
libsveltosv1beta1.FeatureResources, currentClusterSummary, deployedGKVs, nil,
1231+
textlogger.NewLogger(textlogger.NewConfig()))
1232+
Expect(err).To(BeNil())
1233+
1234+
Eventually(func() bool {
1235+
currentClusterRole := &rbacv1.ClusterRole{}
1236+
err = testEnv.Get(context.TODO(), types.NamespacedName{Name: remoteClusterRoleName}, currentClusterRole)
1237+
return err != nil && apierrors.IsNotFound(err)
1238+
}, timeout, pollingInterval).Should(BeTrue())
1239+
1240+
Consistently(func() error {
1241+
currentClusterRole := &rbacv1.ClusterRole{}
1242+
return testEnv.Get(context.TODO(), types.NamespacedName{Name: localClusterRoleName}, currentClusterRole)
1243+
}, timeout, pollingInterval).Should(BeNil())
1244+
1245+
// isMgmtCluster=true simulates the Local cleanup pass: it must now remove the
1246+
// Local-deployed resource.
1247+
_, err = controllers.UndeployStaleResources(context.TODO(), true, testEnv.Config, testEnv.Client,
1248+
libsveltosv1beta1.FeatureResources, currentClusterSummary, deployedGKVs, nil,
1249+
textlogger.NewLogger(textlogger.NewConfig()))
1250+
Expect(err).To(BeNil())
1251+
1252+
Eventually(func() bool {
1253+
currentClusterRole := &rbacv1.ClusterRole{}
1254+
err = testEnv.Get(context.TODO(), types.NamespacedName{Name: localClusterRoleName}, currentClusterRole)
1255+
return err != nil && apierrors.IsNotFound(err)
1256+
}, timeout, pollingInterval).Should(BeTrue())
1257+
})
1258+
10211259
It("addExtraLabels adds extra labels on unstructured", func() {
10221260
u := &unstructured.Unstructured{}
10231261
extraLabels := map[string]string{

0 commit comments

Comments
 (0)