|
| 1 | +package cluster |
| 2 | + |
| 3 | +import ( |
| 4 | + "context" |
| 5 | + "errors" |
| 6 | + "net/http" |
| 7 | + "sync" |
| 8 | + |
| 9 | + "github.com/onsi/ginkgo/v2" |
| 10 | + . "github.com/onsi/gomega" //nolint:staticcheck // dot import for test readability |
| 11 | + |
| 12 | + "github.com/openshift-hyperfleet/hyperfleet-e2e/pkg/api/openapi" |
| 13 | + "github.com/openshift-hyperfleet/hyperfleet-e2e/pkg/client" |
| 14 | + "github.com/openshift-hyperfleet/hyperfleet-e2e/pkg/helper" |
| 15 | + "github.com/openshift-hyperfleet/hyperfleet-e2e/pkg/labels" |
| 16 | +) |
| 17 | + |
| 18 | +var _ = ginkgo.Describe("[Suite: cluster][delete] Re-DELETE Idempotency and API Boundary Tests", |
| 19 | + ginkgo.Label(labels.Tier1), |
| 20 | + func() { |
| 21 | + var h *helper.Helper |
| 22 | + var clusterID string |
| 23 | + |
| 24 | + ginkgo.BeforeEach(func(ctx context.Context) { |
| 25 | + h = helper.New() |
| 26 | + |
| 27 | + ginkgo.By("creating cluster and waiting for Reconciled") |
| 28 | + var err error |
| 29 | + clusterID, err = h.GetTestCluster(ctx, h.TestDataPath("payloads/clusters/cluster-request.json")) |
| 30 | + Expect(err).NotTo(HaveOccurred(), "failed to create cluster") |
| 31 | + |
| 32 | + Eventually(h.PollCluster(ctx, clusterID), h.Cfg.Timeouts.Cluster.Reconciled, h.Cfg.Polling.Interval). |
| 33 | + Should(helper.HaveResourceCondition(client.ConditionTypeReconciled, openapi.ResourceConditionStatusTrue)) |
| 34 | + }) |
| 35 | + |
| 36 | + ginkgo.It("should handle re-DELETE idempotently without changing deleted_time or generation", |
| 37 | + ginkgo.Label(labels.Disruptive), |
| 38 | + func(ctx context.Context) { |
| 39 | + ginkgo.By("pausing sentinel to prevent hard-delete between DELETE calls") |
| 40 | + sentinelDeployment, err := h.GetDeploymentName(ctx, h.Cfg.Namespace, helper.SentinelClustersRelease) |
| 41 | + Expect(err).NotTo(HaveOccurred(), "failed to find sentinel-clusters deployment") |
| 42 | + err = h.ScaleDeployment(ctx, h.Cfg.Namespace, sentinelDeployment, 0) |
| 43 | + Expect(err).NotTo(HaveOccurred(), "failed to scale sentinel to 0") |
| 44 | + ginkgo.DeferCleanup(func(ctx context.Context) { |
| 45 | + ginkgo.By("restoring sentinel-clusters to 1 replica") |
| 46 | + if err := h.ScaleDeployment(ctx, h.Cfg.Namespace, sentinelDeployment, 1); err != nil { |
| 47 | + ginkgo.GinkgoWriter.Printf("Warning: failed to restore sentinel: %v\n", err) |
| 48 | + } |
| 49 | + }) |
| 50 | + |
| 51 | + ginkgo.By("sending first DELETE request") |
| 52 | + firstDelete, err := h.Client.DeleteCluster(ctx, clusterID) |
| 53 | + Expect(err).NotTo(HaveOccurred(), "first DELETE should succeed with 202") |
| 54 | + Expect(firstDelete.DeletedTime).NotTo(BeNil(), "first DELETE should set deleted_time") |
| 55 | + originalDeletedTime := *firstDelete.DeletedTime |
| 56 | + originalGeneration := firstDelete.Generation |
| 57 | + |
| 58 | + ginkgo.By("sending second DELETE request") |
| 59 | + secondDelete, err := h.Client.DeleteCluster(ctx, clusterID) |
| 60 | + Expect(err).NotTo(HaveOccurred(), "second DELETE should succeed with 202") |
| 61 | + Expect(secondDelete.DeletedTime).NotTo(BeNil(), "second DELETE should still have deleted_time") |
| 62 | + Expect(*secondDelete.DeletedTime).To(Equal(originalDeletedTime), "deleted_time should not change on re-DELETE") |
| 63 | + Expect(secondDelete.Generation).To(Equal(originalGeneration), "generation should not increment on re-DELETE") |
| 64 | + }) |
| 65 | + |
| 66 | + ginkgo.It("should return 409 Conflict when creating nodepool under soft-deleted cluster", |
| 67 | + ginkgo.Label(labels.Negative), |
| 68 | + func(ctx context.Context) { |
| 69 | + ginkgo.By("soft-deleting the cluster") |
| 70 | + deletedCluster, err := h.Client.DeleteCluster(ctx, clusterID) |
| 71 | + Expect(err).NotTo(HaveOccurred(), "DELETE should succeed with 202") |
| 72 | + Expect(deletedCluster.DeletedTime).NotTo(BeNil()) |
| 73 | + |
| 74 | + ginkgo.By("attempting to create a nodepool under the soft-deleted cluster") |
| 75 | + _, err = h.Client.CreateNodePoolFromPayload(ctx, clusterID, h.TestDataPath("payloads/nodepools/nodepool-request.json")) |
| 76 | + var httpErr *client.HTTPError |
| 77 | + Expect(errors.As(err, &httpErr)).To(BeTrue(), "error should be HTTPError") |
| 78 | + Expect(httpErr.StatusCode).To(Equal(http.StatusConflict), |
| 79 | + "creating nodepool under soft-deleted cluster should return 409") |
| 80 | + |
| 81 | + ginkgo.By("verifying no nodepool was created") |
| 82 | + npList, err := h.Client.ListNodePools(ctx, clusterID) |
| 83 | + Expect(err).NotTo(HaveOccurred()) |
| 84 | + Expect(npList.Items).To(BeEmpty(), "no nodepools should exist under soft-deleted cluster") |
| 85 | + }, |
| 86 | + ) |
| 87 | + |
| 88 | + ginkgo.AfterEach(func(ctx context.Context) { |
| 89 | + if h == nil || clusterID == "" { |
| 90 | + return |
| 91 | + } |
| 92 | + ginkgo.By("cleaning up cluster " + clusterID) |
| 93 | + if cluster, err := h.Client.GetCluster(ctx, clusterID); err == nil && cluster.DeletedTime == nil { |
| 94 | + if _, err := h.Client.DeleteCluster(ctx, clusterID); err != nil { |
| 95 | + ginkgo.GinkgoWriter.Printf("Warning: API delete failed for cluster %s: %v\n", clusterID, err) |
| 96 | + } |
| 97 | + } |
| 98 | + if err := h.CleanupTestCluster(ctx, clusterID); err != nil { |
| 99 | + ginkgo.GinkgoWriter.Printf("Warning: cleanup failed for cluster %s: %v\n", clusterID, err) |
| 100 | + } |
| 101 | + }) |
| 102 | + }, |
| 103 | +) |
| 104 | + |
| 105 | +var _ = ginkgo.Describe("[Suite: cluster][delete] DELETE Non-Existent Cluster", |
| 106 | + ginkgo.Label(labels.Tier1, labels.Negative), |
| 107 | + func() { |
| 108 | + var h *helper.Helper |
| 109 | + |
| 110 | + ginkgo.BeforeEach(func() { |
| 111 | + h = helper.New() |
| 112 | + }) |
| 113 | + |
| 114 | + ginkgo.It("should return 404 when deleting a non-existent cluster", func(ctx context.Context) { |
| 115 | + ginkgo.By("sending DELETE for a non-existent cluster ID") |
| 116 | + _, err := h.Client.DeleteCluster(ctx, "non-existent-cluster-id-12345") |
| 117 | + var httpErr *client.HTTPError |
| 118 | + Expect(errors.As(err, &httpErr)).To(BeTrue(), "error should be HTTPError") |
| 119 | + Expect(httpErr.StatusCode).To(Equal(http.StatusNotFound), |
| 120 | + "DELETE on non-existent cluster should return 404") |
| 121 | + }) |
| 122 | + }, |
| 123 | +) |
| 124 | + |
| 125 | +var _ = ginkgo.Describe("[Suite: cluster][delete] Concurrent Deletion", |
| 126 | + ginkgo.Label(labels.Tier1), |
| 127 | + func() { |
| 128 | + var h *helper.Helper |
| 129 | + var clusterID string |
| 130 | + |
| 131 | + ginkgo.BeforeEach(func(ctx context.Context) { |
| 132 | + h = helper.New() |
| 133 | + |
| 134 | + ginkgo.By("creating cluster and waiting for Reconciled") |
| 135 | + var err error |
| 136 | + clusterID, err = h.GetTestCluster(ctx, h.TestDataPath("payloads/clusters/cluster-request.json")) |
| 137 | + Expect(err).NotTo(HaveOccurred(), "failed to create cluster") |
| 138 | + |
| 139 | + Eventually(h.PollCluster(ctx, clusterID), h.Cfg.Timeouts.Cluster.Reconciled, h.Cfg.Polling.Interval). |
| 140 | + Should(helper.HaveResourceCondition(client.ConditionTypeReconciled, openapi.ResourceConditionStatusTrue)) |
| 141 | + }) |
| 142 | + |
| 143 | + ginkgo.It("should produce a single soft-delete record from simultaneous DELETE requests", func(ctx context.Context) { |
| 144 | + ginkgo.By("capturing generation before deletion") |
| 145 | + clusterBefore, err := h.Client.GetCluster(ctx, clusterID) |
| 146 | + Expect(err).NotTo(HaveOccurred()) |
| 147 | + genBefore := clusterBefore.Generation |
| 148 | + |
| 149 | + ginkgo.By("firing 5 concurrent DELETE requests") |
| 150 | + const concurrency = 5 |
| 151 | + type deleteResult struct { |
| 152 | + cluster *openapi.Cluster |
| 153 | + err error |
| 154 | + } |
| 155 | + results := make([]deleteResult, concurrency) |
| 156 | + var wg sync.WaitGroup |
| 157 | + wg.Add(concurrency) |
| 158 | + for i := range concurrency { |
| 159 | + go func(idx int) { |
| 160 | + defer wg.Done() |
| 161 | + defer ginkgo.GinkgoRecover() |
| 162 | + c, e := h.Client.DeleteCluster(ctx, clusterID) |
| 163 | + results[idx] = deleteResult{cluster: c, err: e} |
| 164 | + }(i) |
| 165 | + } |
| 166 | + wg.Wait() |
| 167 | + |
| 168 | + ginkgo.By("verifying all requests succeeded with consistent state") |
| 169 | + for i, r := range results { |
| 170 | + Expect(r.err).NotTo(HaveOccurred(), "DELETE request %d should succeed", i) |
| 171 | + Expect(r.cluster.DeletedTime).NotTo(BeNil(), "DELETE request %d should have deleted_time", i) |
| 172 | + } |
| 173 | + |
| 174 | + // All responses should carry identical deleted_time and generation |
| 175 | + referenceTime := *results[0].cluster.DeletedTime |
| 176 | + referenceGen := results[0].cluster.Generation |
| 177 | + for i := 1; i < concurrency; i++ { |
| 178 | + Expect(*results[i].cluster.DeletedTime).To(Equal(referenceTime), |
| 179 | + "all DELETE responses should have the same deleted_time") |
| 180 | + Expect(results[i].cluster.Generation).To(Equal(referenceGen), |
| 181 | + "all DELETE responses should have the same generation") |
| 182 | + } |
| 183 | + |
| 184 | + ginkgo.By("verifying generation incremented exactly once") |
| 185 | + Expect(referenceGen).To(Equal(genBefore+1), |
| 186 | + "generation should increment by exactly 1, not by the number of concurrent requests") |
| 187 | + |
| 188 | + ginkgo.By("verifying cluster completes deletion lifecycle") |
| 189 | + Eventually(h.PollClusterHTTPStatus(ctx, clusterID), h.Cfg.Timeouts.Adapter.Processing, h.Cfg.Polling.Interval). |
| 190 | + Should(Equal(http.StatusNotFound)) |
| 191 | + }) |
| 192 | + |
| 193 | + ginkgo.AfterEach(func(ctx context.Context) { |
| 194 | + if h == nil || clusterID == "" { |
| 195 | + return |
| 196 | + } |
| 197 | + ginkgo.By("cleaning up cluster " + clusterID) |
| 198 | + if cluster, err := h.Client.GetCluster(ctx, clusterID); err == nil && cluster.DeletedTime == nil { |
| 199 | + if _, err := h.Client.DeleteCluster(ctx, clusterID); err != nil { |
| 200 | + ginkgo.GinkgoWriter.Printf("Warning: API delete failed for cluster %s: %v\n", clusterID, err) |
| 201 | + } |
| 202 | + } |
| 203 | + if err := h.CleanupTestCluster(ctx, clusterID); err != nil { |
| 204 | + ginkgo.GinkgoWriter.Printf("Warning: cleanup failed for cluster %s: %v\n", clusterID, err) |
| 205 | + } |
| 206 | + }) |
| 207 | + }, |
| 208 | +) |
| 209 | + |
| 210 | +var _ = ginkgo.Describe("[Suite: cluster][delete] DELETE During Update Reconciliation", |
| 211 | + ginkgo.Label(labels.Tier1), |
| 212 | + func() { |
| 213 | + var h *helper.Helper |
| 214 | + var clusterID string |
| 215 | + |
| 216 | + ginkgo.BeforeEach(func(ctx context.Context) { |
| 217 | + h = helper.New() |
| 218 | + |
| 219 | + ginkgo.By("creating cluster and waiting for Reconciled at generation 1") |
| 220 | + cluster, err := h.Client.CreateClusterFromPayload(ctx, h.TestDataPath("payloads/clusters/cluster-request.json")) |
| 221 | + Expect(err).NotTo(HaveOccurred(), "failed to create cluster") |
| 222 | + Expect(cluster.Id).NotTo(BeNil()) |
| 223 | + clusterID = *cluster.Id |
| 224 | + |
| 225 | + Eventually(h.PollCluster(ctx, clusterID), h.Cfg.Timeouts.Cluster.Reconciled, h.Cfg.Polling.Interval). |
| 226 | + Should(helper.HaveResourceCondition(client.ConditionTypeReconciled, openapi.ResourceConditionStatusTrue)) |
| 227 | + }) |
| 228 | + |
| 229 | + ginkgo.It("should complete deletion when DELETE is sent during update reconciliation", func(ctx context.Context) { |
| 230 | + ginkgo.By("sending PATCH to trigger generation 2 (do NOT wait for reconciliation)") |
| 231 | + patchedCluster, err := h.Client.PatchCluster(ctx, clusterID, openapi.ClusterPatchRequest{ |
| 232 | + Spec: &openapi.ClusterSpec{"trigger-update": "true"}, |
| 233 | + }) |
| 234 | + Expect(err).NotTo(HaveOccurred(), "PATCH should succeed") |
| 235 | + Expect(patchedCluster.Generation).To(Equal(int32(2))) |
| 236 | + |
| 237 | + ginkgo.By("immediately sending DELETE before update reconciliation completes") |
| 238 | + deletedCluster, err := h.Client.DeleteCluster(ctx, clusterID) |
| 239 | + Expect(err).NotTo(HaveOccurred(), "DELETE should succeed with 202") |
| 240 | + Expect(deletedCluster.DeletedTime).NotTo(BeNil()) |
| 241 | + Expect(deletedCluster.Generation).To(Equal(int32(3)), |
| 242 | + "generation should be 3: create(1) + PATCH(2) + DELETE(3)") |
| 243 | + |
| 244 | + ginkgo.By("verifying cluster is hard-deleted") |
| 245 | + Eventually(h.PollClusterHTTPStatus(ctx, clusterID), h.Cfg.Timeouts.Adapter.Processing, h.Cfg.Polling.Interval). |
| 246 | + Should(Equal(http.StatusNotFound)) |
| 247 | + |
| 248 | + ginkgo.By("verifying downstream K8s namespace is cleaned up") |
| 249 | + Eventually(h.PollNamespacesByPrefix(ctx, clusterID), h.Cfg.Timeouts.Adapter.Processing, h.Cfg.Polling.Interval). |
| 250 | + Should(BeEmpty()) |
| 251 | + }) |
| 252 | + |
| 253 | + ginkgo.AfterEach(func(ctx context.Context) { |
| 254 | + if h == nil || clusterID == "" { |
| 255 | + return |
| 256 | + } |
| 257 | + ginkgo.By("cleaning up cluster " + clusterID) |
| 258 | + if cluster, err := h.Client.GetCluster(ctx, clusterID); err == nil && cluster.DeletedTime == nil { |
| 259 | + if _, err := h.Client.DeleteCluster(ctx, clusterID); err != nil { |
| 260 | + ginkgo.GinkgoWriter.Printf("Warning: API delete failed for cluster %s: %v\n", clusterID, err) |
| 261 | + } |
| 262 | + } |
| 263 | + if err := h.CleanupTestCluster(ctx, clusterID); err != nil { |
| 264 | + ginkgo.GinkgoWriter.Printf("Warning: cleanup failed for cluster %s: %v\n", clusterID, err) |
| 265 | + } |
| 266 | + }) |
| 267 | + }, |
| 268 | +) |
| 269 | + |
| 270 | +var _ = ginkgo.Describe("[Suite: cluster][delete] Recreate Cluster After Hard-Delete", |
| 271 | + ginkgo.Label(labels.Tier1), |
| 272 | + func() { |
| 273 | + var h *helper.Helper |
| 274 | + var firstClusterID string |
| 275 | + var secondClusterID string |
| 276 | + var originalCluster *openapi.Cluster |
| 277 | + |
| 278 | + ginkgo.BeforeEach(func(ctx context.Context) { |
| 279 | + h = helper.New() |
| 280 | + |
| 281 | + ginkgo.By("creating first cluster and waiting for Reconciled") |
| 282 | + cluster, err := h.Client.CreateClusterFromPayload(ctx, h.TestDataPath("payloads/clusters/cluster-request.json")) |
| 283 | + Expect(err).NotTo(HaveOccurred(), "failed to create cluster") |
| 284 | + Expect(cluster.Id).NotTo(BeNil()) |
| 285 | + firstClusterID = *cluster.Id |
| 286 | + originalCluster = cluster |
| 287 | + |
| 288 | + Eventually(h.PollCluster(ctx, firstClusterID), h.Cfg.Timeouts.Cluster.Reconciled, h.Cfg.Polling.Interval). |
| 289 | + Should(helper.HaveResourceCondition(client.ConditionTypeReconciled, openapi.ResourceConditionStatusTrue)) |
| 290 | + }) |
| 291 | + |
| 292 | + ginkgo.It("should create a new cluster with the same name after the original is hard-deleted", func(ctx context.Context) { |
| 293 | + ginkgo.By("deleting the first cluster and waiting for hard-delete") |
| 294 | + _, err := h.Client.DeleteCluster(ctx, firstClusterID) |
| 295 | + Expect(err).NotTo(HaveOccurred()) |
| 296 | + |
| 297 | + Eventually(h.PollClusterHTTPStatus(ctx, firstClusterID), h.Cfg.Timeouts.Adapter.Processing, h.Cfg.Polling.Interval). |
| 298 | + Should(Equal(http.StatusNotFound)) |
| 299 | + |
| 300 | + ginkgo.By("waiting for namespace cleanup from first cluster") |
| 301 | + Eventually(h.PollNamespacesByPrefix(ctx, firstClusterID), h.Cfg.Timeouts.Adapter.Processing, h.Cfg.Polling.Interval). |
| 302 | + Should(BeEmpty()) |
| 303 | + |
| 304 | + ginkgo.By("creating a new cluster with the same name") |
| 305 | + kind := "Cluster" |
| 306 | + newCluster, err := h.Client.CreateCluster(ctx, openapi.ClusterCreateRequest{ |
| 307 | + Kind: &kind, |
| 308 | + Name: originalCluster.Name, |
| 309 | + Labels: originalCluster.Labels, |
| 310 | + Spec: originalCluster.Spec, |
| 311 | + }) |
| 312 | + Expect(err).NotTo(HaveOccurred(), "creating cluster with reused name should succeed") |
| 313 | + Expect(newCluster.Id).NotTo(BeNil()) |
| 314 | + secondClusterID = *newCluster.Id |
| 315 | + |
| 316 | + Expect(secondClusterID).NotTo(Equal(firstClusterID), |
| 317 | + "new cluster should have a different ID than the deleted one") |
| 318 | + Expect(newCluster.Generation).To(Equal(int32(1)), |
| 319 | + "new cluster should start at generation 1") |
| 320 | + |
| 321 | + ginkgo.By("waiting for the new cluster to reach Reconciled") |
| 322 | + Eventually(h.PollCluster(ctx, secondClusterID), h.Cfg.Timeouts.Cluster.Reconciled, h.Cfg.Polling.Interval). |
| 323 | + Should(helper.HaveResourceCondition(client.ConditionTypeReconciled, openapi.ResourceConditionStatusTrue)) |
| 324 | + |
| 325 | + ginkgo.By("verifying the old cluster is still gone") |
| 326 | + _, err = h.Client.GetCluster(ctx, firstClusterID) |
| 327 | + var httpErr *client.HTTPError |
| 328 | + Expect(errors.As(err, &httpErr)).To(BeTrue()) |
| 329 | + Expect(httpErr.StatusCode).To(Equal(http.StatusNotFound), |
| 330 | + "old cluster should remain 404 after recreate") |
| 331 | + }) |
| 332 | + |
| 333 | + ginkgo.AfterEach(func(ctx context.Context) { |
| 334 | + if h == nil { |
| 335 | + return |
| 336 | + } |
| 337 | + for _, id := range []string{firstClusterID, secondClusterID} { |
| 338 | + if id == "" { |
| 339 | + continue |
| 340 | + } |
| 341 | + ginkgo.By("cleaning up cluster " + id) |
| 342 | + if cluster, err := h.Client.GetCluster(ctx, id); err == nil && cluster.DeletedTime == nil { |
| 343 | + if _, err := h.Client.DeleteCluster(ctx, id); err != nil { |
| 344 | + ginkgo.GinkgoWriter.Printf("Warning: API delete failed for cluster %s: %v\n", id, err) |
| 345 | + } |
| 346 | + } |
| 347 | + if err := h.CleanupTestCluster(ctx, id); err != nil { |
| 348 | + ginkgo.GinkgoWriter.Printf("Warning: cleanup failed for cluster %s: %v\n", id, err) |
| 349 | + } |
| 350 | + } |
| 351 | + }) |
| 352 | + }, |
| 353 | +) |
0 commit comments