package controller import ( "context" "net/http/httptest" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" "k8s.io/apimachinery/pkg/api/meta" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" "sigs.k8s.io/controller-runtime/pkg/reconcile" terdutv1alpha1 "git.ryuvia.com/niklas/terdut-operator/api/v1alpha1" "git.ryuvia.com/niklas/terdut-operator/internal/tdclient" ) var _ = Describe("TerdutDeadmanSwitch Controller", func() { const operatorNamespace = "default" var ( reconciler *TerdutDeadmanSwitchReconciler fake *fakeTerdutServer fakeSrv *httptest.Server srv *terdutv1alpha1.TerdutServer team *terdutv1alpha1.TerdutTeam swName string swKey types.NamespacedName ) BeforeEach(func(ctx SpecContext) { fake, fakeSrv = newFakeTerdutServer() DeferCleanup(fakeSrv.Close) srv = bootstrapReadyTerdutServer(ctx, uniqueName("dmserver"), fakeSrv.URL) team = readyTerdutTeam(ctx, operatorNamespace, uniqueName("dmteam"), srv, fakeSrv.URL) reconciler = &TerdutDeadmanSwitchReconciler{ Client: k8sClient, Scheme: k8sClient.Scheme(), OperatorNamespace: operatorNamespace, NewClient: func(string) *tdclient.Client { return tdclient.New(fakeSrv.URL) }, } swName = uniqueName("switch") swKey = types.NamespacedName{Name: swName, Namespace: operatorNamespace} }) AfterEach(func(ctx SpecContext) { sw := &terdutv1alpha1.TerdutDeadmanSwitch{} if err := k8sClient.Get(ctx, swKey, sw); err == nil { sw.Finalizers = nil _ = k8sClient.Update(ctx, sw) _ = k8sClient.Delete(ctx, sw) } teamKey := types.NamespacedName{Name: team.Name, Namespace: operatorNamespace} if err := k8sClient.Get(ctx, teamKey, team); err == nil { team.Finalizers = nil _ = k8sClient.Update(ctx, team) _ = k8sClient.Delete(ctx, team) } srvKey := types.NamespacedName{Name: srv.Name, Namespace: operatorNamespace} if err := k8sClient.Get(ctx, srvKey, srv); err == nil { srv.Finalizers = nil _ = k8sClient.Update(ctx, srv) _ = k8sClient.Delete(ctx, srv) } }) createSwitch := func(ctx context.Context, teamRef terdutv1alpha1.TerdutTeamRef, name, matcher, timeout string) { sw := &terdutv1alpha1.TerdutDeadmanSwitch{ ObjectMeta: metav1.ObjectMeta{Name: swName, Namespace: operatorNamespace}, Spec: terdutv1alpha1.TerdutDeadmanSwitchSpec{ TeamRef: teamRef, Name: name, Matcher: matcher, Timeout: timeout, }, } Expect(k8sClient.Create(ctx, sw)).To(Succeed()) } reconcileOnce := func(ctx context.Context) { _, err := reconciler.Reconcile(ctx, reconcile.Request{NamespacedName: swKey}) Expect(err).NotTo(HaveOccurred()) } readyCondition := func(ctx context.Context) metav1.Condition { sw := &terdutv1alpha1.TerdutDeadmanSwitch{} Expect(k8sClient.Get(ctx, swKey, sw)).To(Succeed()) c := meta.FindStatusCondition(sw.Status.Conditions, terdutv1alpha1.ConditionReady) Expect(c).NotTo(BeNil()) return *c } sameTeamRef := func() terdutv1alpha1.TerdutTeamRef { return terdutv1alpha1.TerdutTeamRef{Name: team.Name} } Describe("the happy path", func() { It("creates the switch server-side", func(ctx SpecContext) { createSwitch(ctx, sameTeamRef(), "heartbeat", "alertname=Watchdog", "15m") reconcileOnce(ctx) // finalizer reconcileOnce(ctx) // create cond := readyCondition(ctx) Expect(cond.Status).To(Equal(metav1.ConditionTrue)) Expect(cond.Reason).To(Equal(terdutv1alpha1.ReasonChildAdopted)) sw := &terdutv1alpha1.TerdutDeadmanSwitch{} Expect(k8sClient.Get(ctx, swKey, sw)).To(Succeed()) Expect(sw.Status.SwitchID).NotTo(BeZero()) created, ok := fake.switches[team.Status.TeamID][sw.Status.SwitchID] Expect(ok).To(BeTrue()) Expect(created.Matcher).To(Equal("alertname=Watchdog")) Expect(created.TimeoutSeconds).To(Equal(int64(900))) Expect(created.Severity).To(Equal("critical")) // kubebuilder default }) }) Describe("list-and-match-by-name adoption", func() { It("adopts an already-created switch instead of creating a duplicate", func(ctx SpecContext) { // Simulates a prior, interrupted reconcile that got as far as // POSTing the switch -- no 409 signal exists for this resource // (DESIGN.md ยง4.4), so the recovery path is GET-list-and-match, // not adopt-on-409. fake.nextSwitchID = 1 fake.switches[team.Status.TeamID] = map[int64]tdclient.DeadmanSwitch{ 1: {ID: 1, Name: "heartbeat", Matcher: "alertname=Watchdog", TimeoutSeconds: 900, Severity: "critical"}, } createSwitch(ctx, sameTeamRef(), "heartbeat", "alertname=Watchdog", "15m") reconcileOnce(ctx) // finalizer reconcileOnce(ctx) sw := &terdutv1alpha1.TerdutDeadmanSwitch{} Expect(k8sClient.Get(ctx, swKey, sw)).To(Succeed()) Expect(sw.Status.SwitchID).To(Equal(int64(1))) Expect(fake.switches[team.Status.TeamID]).To(HaveLen(1), "should not have created a second switch") }) }) Describe("update-in-place on spec drift", func() { It("PUTs the new spec rather than creating a second switch", func(ctx SpecContext) { createSwitch(ctx, sameTeamRef(), "heartbeat", "alertname=Watchdog", "15m") reconcileOnce(ctx) // finalizer reconcileOnce(ctx) Expect(readyCondition(ctx).Status).To(Equal(metav1.ConditionTrue)) sw := &terdutv1alpha1.TerdutDeadmanSwitch{} Expect(k8sClient.Get(ctx, swKey, sw)).To(Succeed()) switchID := sw.Status.SwitchID sw.Spec.Timeout = "30m" Expect(k8sClient.Update(ctx, sw)).To(Succeed()) reconcileOnce(ctx) Expect(fake.switches[team.Status.TeamID]).To(HaveLen(1), "update-in-place, not a second switch") Expect(fake.switches[team.Status.TeamID][switchID].TimeoutSeconds).To(Equal(int64(1800))) }) }) Describe("waiting on the referenced TerdutTeam", func() { It("reports TeamRefNotFound when the TerdutTeam doesn't exist", func(ctx SpecContext) { createSwitch(ctx, terdutv1alpha1.TerdutTeamRef{Name: testRefNotFoundName}, "heartbeat", "alertname=Watchdog", "15m") reconcileOnce(ctx) // finalizer reconcileOnce(ctx) Expect(readyCondition(ctx).Reason).To(Equal(terdutv1alpha1.ReasonTeamRefNotFound)) }) It("reports WaitingForTeam when the TerdutTeam exists but isn't Ready yet", func(ctx SpecContext) { unreadyName := uniqueName("dmteam-unready") unready := &terdutv1alpha1.TerdutTeam{ ObjectMeta: metav1.ObjectMeta{Name: unreadyName, Namespace: operatorNamespace}, Spec: terdutv1alpha1.TerdutTeamSpec{ ServerRef: terdutv1alpha1.TerdutServerRef{Name: srv.Name}, DisplayName: testUnreadyDisplayName, }, } Expect(k8sClient.Create(ctx, unready)).To(Succeed()) DeferCleanup(func() { _ = k8sClient.Delete(ctx, unready) }) createSwitch(ctx, terdutv1alpha1.TerdutTeamRef{Name: unreadyName}, "heartbeat", "alertname=Watchdog", "15m") reconcileOnce(ctx) // finalizer reconcileOnce(ctx) Expect(readyCondition(ctx).Reason).To(Equal(terdutv1alpha1.ReasonWaitingForTeam)) }) }) Describe("deletion", func() { It("deletes the switch server-side (the real DELETE this resource has) and removes the finalizer", func(ctx SpecContext) { createSwitch(ctx, sameTeamRef(), "heartbeat", "alertname=Watchdog", "15m") reconcileOnce(ctx) reconcileOnce(ctx) Expect(readyCondition(ctx).Status).To(Equal(metav1.ConditionTrue)) sw := &terdutv1alpha1.TerdutDeadmanSwitch{} Expect(k8sClient.Get(ctx, swKey, sw)).To(Succeed()) switchID := sw.Status.SwitchID Expect(k8sClient.Delete(ctx, sw)).To(Succeed()) reconcileOnce(ctx) // runs the finalizer Expect(fake.switchDelete[switchID]).To(BeTrue()) err := k8sClient.Get(ctx, swKey, sw) Expect(err).To(HaveOccurred(), "the TerdutDeadmanSwitch itself should be gone once the finalizer clears") }) }) })