|
9 | 9 | "time" |
10 | 10 |
|
11 | 11 | corev1 "k8s.io/api/core/v1" |
| 12 | + apierrors "k8s.io/apimachinery/pkg/api/errors" |
12 | 13 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" |
13 | 14 | "k8s.io/apimachinery/pkg/types" |
14 | 15 | ctrl "sigs.k8s.io/controller-runtime" |
@@ -127,6 +128,28 @@ func TestEnsureToolboxesCollisionReturnsError(t *testing.T) { |
127 | 128 | } |
128 | 129 | } |
129 | 130 |
|
| 131 | +func TestEnsureToolboxesRejectsIntegerName(t *testing.T) { |
| 132 | + scheme := testScheme(t) |
| 133 | + cs := newCocoonSet("demo", func(cs *cocoonv1.CocoonSet) { |
| 134 | + cs.Spec.Toolboxes = []cocoonv1.ToolboxSpec{ |
| 135 | + {Name: "1", Image: "ghcr.io/cocoonstack/cocoon/toolbox:latest"}, |
| 136 | + } |
| 137 | + }) |
| 138 | + |
| 139 | + cli := ctrlfake.NewClientBuilder().WithScheme(scheme).Build() |
| 140 | + r := &Reconciler{Client: cli, Scheme: scheme} |
| 141 | + classified := classifiedPods{ |
| 142 | + sub: map[int32]*corev1.Pod{}, |
| 143 | + toolbox: map[string]*corev1.Pod{}, |
| 144 | + allByName: map[string]*corev1.Pod{}, |
| 145 | + } |
| 146 | + |
| 147 | + _, err := r.ensureToolboxes(t.Context(), cs, classified, r.newRestoreIntent(t.Context(), cs.Namespace)) |
| 148 | + if err == nil { |
| 149 | + t.Fatal("ensureToolboxes must reject a toolbox name that collides with agent slot pod naming") |
| 150 | + } |
| 151 | +} |
| 152 | + |
130 | 153 | func TestEnsureToolboxesRejectsDuplicateNames(t *testing.T) { |
131 | 154 | scheme := testScheme(t) |
132 | 155 | cs := newCocoonSet("demo", func(cs *cocoonv1.CocoonSet) { |
@@ -493,6 +516,39 @@ func TestReconcileMainLifecycleFailedTransitionsToFailed(t *testing.T) { |
493 | 516 | } |
494 | 517 | } |
495 | 518 |
|
| 519 | +// A Failed main pod whose spec has drifted from the current CocoonSet spec |
| 520 | +// must be deleted for recreate, not parked in Failed forever. |
| 521 | +func TestReconcileMainLifecycleFailedWithDriftRecreatesPod(t *testing.T) { |
| 522 | + scheme := testScheme(t) |
| 523 | + cs := newCocoonSet("demo", func(cs *cocoonv1.CocoonSet) { |
| 524 | + cs.Finalizers = []string{finalizerName} |
| 525 | + }) |
| 526 | + mainPod := mustBuildAgentPod(t, cs, 0, "", "", scheme) |
| 527 | + mainPod.Status.Phase = corev1.PodRunning |
| 528 | + if mainPod.Annotations == nil { |
| 529 | + mainPod.Annotations = map[string]string{} |
| 530 | + } |
| 531 | + mainPod.Annotations[meta.AnnotationLifecycleState] = string(meta.LifecycleStateFailed) |
| 532 | + |
| 533 | + cs.Spec.Agent.Image = "ghcr.io/cocoonstack/cocoon/ubuntu:26.04" |
| 534 | + |
| 535 | + cli := ctrlfake.NewClientBuilder(). |
| 536 | + WithScheme(scheme). |
| 537 | + WithObjects(cs, mainPod). |
| 538 | + WithStatusSubresource(&cocoonv1.CocoonSet{}). |
| 539 | + Build() |
| 540 | + r := &Reconciler{Client: cli, Scheme: scheme, Registry: &fakeRegistry{}} |
| 541 | + |
| 542 | + if _, err := r.Reconcile(t.Context(), ctrl.Request{NamespacedName: types.NamespacedName{Namespace: cs.Namespace, Name: cs.Name}}); err != nil { |
| 543 | + t.Fatalf("Reconcile: %v", err) |
| 544 | + } |
| 545 | + if err := cli.Get(t.Context(), types.NamespacedName{Namespace: mainPod.Namespace, Name: mainPod.Name}, &corev1.Pod{}); err == nil { |
| 546 | + t.Error("Failed main pod with drifted spec should have been deleted for recreate") |
| 547 | + } else if !apierrors.IsNotFound(err) { |
| 548 | + t.Fatalf("get main pod: %v", err) |
| 549 | + } |
| 550 | +} |
| 551 | + |
496 | 552 | // A sub-agent carrying lifecycle-state=Failed but still PodPhase=Running must |
497 | 553 | // be rebuilt so the backoff / dead-letter logic runs. |
498 | 554 | func TestEnsureSubAgentsTreatsLifecycleFailedAsTerminal(t *testing.T) { |
@@ -793,6 +849,53 @@ func TestApplyUnsuspendSkipsPodHibernatedByCR(t *testing.T) { |
793 | 849 | } |
794 | 850 | } |
795 | 851 |
|
| 852 | +// A Wake desire mid-Hibernating transition (the hibernation reconciler's own |
| 853 | +// reverse-desire window) must still exclude the pod: the annotation is owned |
| 854 | +// by the in-flight hibernate, not by applyUnsuspend. |
| 855 | +func TestApplyUnsuspendSkipsPodMidHibernateOnReverseDesire(t *testing.T) { |
| 856 | + scheme := testScheme(t) |
| 857 | + |
| 858 | + hibernated := &corev1.Pod{ |
| 859 | + ObjectMeta: metav1.ObjectMeta{Name: "demo-0", Namespace: "ns"}, |
| 860 | + } |
| 861 | + meta.HibernateState(true).Apply(hibernated) |
| 862 | + |
| 863 | + hibCR := &cocoonv1.CocoonHibernation{ |
| 864 | + ObjectMeta: metav1.ObjectMeta{Name: "demo-hib", Namespace: "ns"}, |
| 865 | + Spec: cocoonv1.CocoonHibernationSpec{ |
| 866 | + Desire: cocoonv1.HibernationDesireWake, |
| 867 | + PodRef: cocoonv1.HibernationPodRef{Name: "demo-0"}, |
| 868 | + }, |
| 869 | + Status: cocoonv1.CocoonHibernationStatus{ |
| 870 | + Phase: cocoonv1.CocoonHibernationPhaseHibernating, |
| 871 | + }, |
| 872 | + } |
| 873 | + |
| 874 | + cli := ctrlfake.NewClientBuilder(). |
| 875 | + WithScheme(scheme). |
| 876 | + WithObjects(hibernated, hibCR). |
| 877 | + Build() |
| 878 | + r := &Reconciler{Client: cli, Scheme: scheme} |
| 879 | + classified := classifiedPods{ |
| 880 | + main: hibernated, |
| 881 | + sub: map[int32]*corev1.Pod{}, |
| 882 | + toolbox: map[string]*corev1.Pod{}, |
| 883 | + allByName: map[string]*corev1.Pod{"demo-0": hibernated}, |
| 884 | + } |
| 885 | + |
| 886 | + if err := r.applyUnsuspend(t.Context(), "ns", classified); err != nil { |
| 887 | + t.Fatalf("applyUnsuspend: %v", err) |
| 888 | + } |
| 889 | + |
| 890 | + var got corev1.Pod |
| 891 | + if err := cli.Get(t.Context(), types.NamespacedName{Namespace: "ns", Name: "demo-0"}, &got); err != nil { |
| 892 | + t.Fatalf("get demo-0: %v", err) |
| 893 | + } |
| 894 | + if !bool(meta.ReadHibernateState(&got)) { |
| 895 | + t.Errorf("demo-0 is mid-Hibernating under a reverse Wake desire; applyUnsuspend must leave it set") |
| 896 | + } |
| 897 | +} |
| 898 | + |
796 | 899 | // A nil manager suffices: the guard rejects before mgr is touched. |
797 | 900 | func TestSetupWithManagerRejectsInvalidConcurrency(t *testing.T) { |
798 | 901 | for _, n := range []int{0, -1} { |
|
0 commit comments