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
4 changes: 2 additions & 2 deletions internal/controller/seinetwork/envtest/sts_status_faker.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,8 @@ type StatusFaker struct {
//
// - status.observedGeneration = .Generation
// - status.currentRevision = "stub-rev"
// - status.updateRevision = "stub-rev" (matches currentRevision so
// ReplacePod's "rollout complete" branch fires immediately)
// - status.updateRevision = "stub-rev" (non-empty, so ReplacePod passes
// its revision gate; envtest pods carry no revision hash, so none are deleted)
// - status.updatedReplicas = *spec.replicas (ObserveImage's gate)
// - status.readyReplicas = *spec.replicas
// - status.replicas = *spec.replicas
Expand Down
20 changes: 11 additions & 9 deletions internal/task/replace_pod.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,18 +59,16 @@ func (e *replacePodExecution) Execute(ctx context.Context) error {
}

// Revision gate runs before the selector/replica guards: a not-yet-observed
// or not-yet-populated revision is a transient wait, and an already-rolled
// StatefulSet is a complete no-op — neither should reach pod deletion.
// or not-yet-populated revision is a transient wait and must not reach pod
// deletion. Staleness is judged per pod against UpdateRevision, never from
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
// CurrentRevision == UpdateRevision: a rollback to a revision still in
// history makes those equal while the pods carry the abandoned revision.
if sts.Status.ObservedGeneration < sts.Generation {
return nil
}
if sts.Status.UpdateRevision == "" {
return nil
}
if sts.Status.CurrentRevision == sts.Status.UpdateRevision {
e.complete()
return nil
}

if err := guardSelectorAndReplicas(node, sts); err != nil {
return err
Expand Down Expand Up @@ -151,10 +149,14 @@ func (e *replacePodExecution) ownedPods(ctx context.Context, node *seiv1alpha1.S
return owned, nil
}

// deletePod deletes the pod, tolerating an already-gone pod (NotFound) so the
// task is idempotent across reconciles.
// deletePod deletes exactly the observed pod: the UID precondition stops a
// stale cache entry for the stable pod name from deleting its recreated
// replacement. An already-gone pod (NotFound) or a UID mismatch (Conflict) is
// tolerated so the task is idempotent across reconciles.
func (e *replacePodExecution) deletePod(ctx context.Context, pod *corev1.Pod) error {
if err := e.cfg.KubeClient.Delete(ctx, pod); err != nil && !apierrors.IsNotFound(err) {
uid := pod.UID
err := e.cfg.KubeClient.Delete(ctx, pod, client.Preconditions{UID: &uid})
if err != nil && !apierrors.IsNotFound(err) && !apierrors.IsConflict(err) {
return fmt.Errorf("deleting pod %q: %w", pod.Name, err)
}
return nil
Expand Down
23 changes: 23 additions & 0 deletions internal/task/replace_pod_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,29 @@ func TestReplacePod_AlreadyAtUpdateRevision_NoOp(t *testing.T) {
types.NamespacedName{Name: currentPod.Name, Namespace: currentPod.Namespace}, got)).To(Succeed())
}

// Rollback to a revision still in history: the StatefulSet reports
// CurrentRevision == UpdateRevision while the pod still carries the abandoned
// revision. The task must delete that pod rather than treating the equal
// revisions as an already-rolled StatefulSet.
func TestReplacePod_RollbackWithEqualRevisions_DeletesStalePod(t *testing.T) {
g := NewWithT(t)
node := replacePodNode()
sts := stsForReplace("rev-a", "rev-a")
stalePod := podForReplace("rev-b", false)

cfg := replacePodCfg(t, node, sts, stalePod)
exec := newReplacePodExec(t, cfg)

g.Expect(exec.Execute(context.Background())).To(Succeed())
g.Expect(exec.Status(context.Background())).To(Equal(ExecutionComplete))

got := &corev1.Pod{}
err := cfg.KubeClient.Get(context.Background(),
types.NamespacedName{Name: stalePod.Name, Namespace: stalePod.Namespace}, got)
g.Expect(apierrors.IsNotFound(err)).To(BeTrue(),
"expected rolled-back pod to be deleted, got err=%v", err)
}

// Pod already terminating (deletionTimestamp present) → task skips it.
// We assert the pod's finalizer wasn't stripped (i.e. task didn't double-delete).
func TestReplacePod_TerminatingPod_Skipped(t *testing.T) {
Expand Down
Loading