Skip to content

Commit 43a136a

Browse files
committed
HYPERFLEET-861 - feat: add Tier 1 E2E tests for update and delete lifecycle
1 parent 6037cf8 commit 43a136a

12 files changed

Lines changed: 1322 additions & 108 deletions

e2e/cluster/delete_edge_cases.go

Lines changed: 353 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,353 @@
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

Comments
 (0)