package controller import ( "context" "net/http/httptest" "strconv" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" corev1 "k8s.io/api/core/v1" "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("TerdutAlertSource Controller", func() { const operatorNamespace = "default" var ( reconciler *TerdutAlertSourceReconciler fake *fakeTerdutServer fakeSrv *httptest.Server srv *terdutv1alpha1.TerdutServer team *terdutv1alpha1.TerdutTeam srcName string srcKey types.NamespacedName ) BeforeEach(func(ctx SpecContext) { fake, fakeSrv = newFakeTerdutServer() DeferCleanup(fakeSrv.Close) srv = bootstrapReadyTerdutServer(ctx, uniqueName("asserver"), fakeSrv.URL) team = readyTerdutTeam(ctx, operatorNamespace, uniqueName("asteam"), srv, fakeSrv.URL) reconciler = &TerdutAlertSourceReconciler{ Client: k8sClient, Scheme: k8sClient.Scheme(), OperatorNamespace: operatorNamespace, NewClient: func(string) *tdclient.Client { return tdclient.New(fakeSrv.URL) }, } srcName = uniqueName("alertsource") srcKey = types.NamespacedName{Name: srcName, Namespace: operatorNamespace} }) AfterEach(func(ctx SpecContext) { as := &terdutv1alpha1.TerdutAlertSource{} if err := k8sClient.Get(ctx, srcKey, as); err == nil { as.Finalizers = nil _ = k8sClient.Update(ctx, as) _ = k8sClient.Delete(ctx, as) } _ = k8sClient.Delete(ctx, &corev1.Secret{ObjectMeta: metav1.ObjectMeta{ Name: srcName + "-terdut-webhook", Namespace: operatorNamespace, }}) 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) } }) createSource := func(ctx context.Context, teamRef terdutv1alpha1.TerdutTeamRef, kind, name string) { as := &terdutv1alpha1.TerdutAlertSource{ ObjectMeta: metav1.ObjectMeta{Name: srcName, Namespace: operatorNamespace}, Spec: terdutv1alpha1.TerdutAlertSourceSpec{TeamRef: teamRef, Kind: kind, Name: name}, } Expect(k8sClient.Create(ctx, as)).To(Succeed()) } reconcileOnce := func(ctx context.Context) { _, err := reconciler.Reconcile(ctx, reconcile.Request{NamespacedName: srcKey}) Expect(err).NotTo(HaveOccurred()) } readyCondition := func(ctx context.Context) metav1.Condition { as := &terdutv1alpha1.TerdutAlertSource{} Expect(k8sClient.Get(ctx, srcKey, as)).To(Succeed()) c := meta.FindStatusCondition(as.Status.Conditions, terdutv1alpha1.ConditionReady) Expect(c).NotTo(BeNil()) return *c } sameTeamRef := func() terdutv1alpha1.TerdutTeamRef { return terdutv1alpha1.TerdutTeamRef{Name: team.Name} } webhookSecret := func(ctx context.Context) corev1.Secret { var secret corev1.Secret Expect(k8sClient.Get(ctx, types.NamespacedName{ Name: srcName + "-terdut-webhook", Namespace: operatorNamespace, }, &secret)).To(Succeed()) return secret } Describe("the happy path", func() { It("creates the integration and writes the webhook Secret", func(ctx SpecContext) { createSource(ctx, sameTeamRef(), "alertmanager", "prod-alertmanager") reconcileOnce(ctx) // finalizer reconcileOnce(ctx) // create cond := readyCondition(ctx) Expect(cond.Status).To(Equal(metav1.ConditionTrue)) Expect(cond.Reason).To(Equal(terdutv1alpha1.ReasonChildAdopted)) as := &terdutv1alpha1.TerdutAlertSource{} Expect(k8sClient.Get(ctx, srcKey, as)).To(Succeed()) Expect(as.Status.IntegrationID).NotTo(BeZero()) Expect(as.Status.LastAppliedKind).To(Equal("alertmanager")) Expect(as.Status.WebhookURLSecretRef).NotTo(BeNil()) Expect(as.Status.WebhookURLSecretRef.Name).To(Equal(srcName + "-terdut-webhook")) secret := webhookSecret(ctx) Expect(secret.Data[webhookSecretKeyDataKey]).NotTo(BeEmpty()) Expect(secret.Data[webhookSecretURLKey]).NotTo(BeEmpty()) Expect(secret.OwnerReferences).To(HaveLen(1), "same-namespace generated Secret should be owner-referenced, not finalizer-cleaned") created, ok := fake.integrations[team.Status.TeamID][as.Status.IntegrationID] Expect(ok).To(BeTrue()) Expect(created.Name).To(Equal("prod-alertmanager")) Expect(created.Kind).To(Equal("alertmanager")) }) }) Describe("renaming", func() { It("PATCHes the new name without rotating the key", func(ctx SpecContext) { createSource(ctx, sameTeamRef(), "alertmanager", "prod-alertmanager") reconcileOnce(ctx) reconcileOnce(ctx) Expect(readyCondition(ctx).Status).To(Equal(metav1.ConditionTrue)) as := &terdutv1alpha1.TerdutAlertSource{} Expect(k8sClient.Get(ctx, srcKey, as)).To(Succeed()) integrationID := as.Status.IntegrationID secretBefore := webhookSecret(ctx) as.Spec.Name = "prod-alertmanager-renamed" Expect(k8sClient.Update(ctx, as)).To(Succeed()) reconcileOnce(ctx) Expect(fake.integrations[team.Status.TeamID][integrationID].Name).To(Equal("prod-alertmanager-renamed")) Expect(k8sClient.Get(ctx, srcKey, as)).To(Succeed()) Expect(as.Status.IntegrationID).To(Equal(integrationID), "renaming must not rotate the integration id") secretAfter := webhookSecret(ctx) Expect(secretAfter.Data[webhookSecretKeyDataKey]).To(Equal(secretBefore.Data[webhookSecretKeyDataKey]), "renaming must not rotate the webhook key") }) }) Describe("a kind change", func() { It("deletes the old integration, mints a new one, and rotates the webhook Secret", func(ctx SpecContext) { createSource(ctx, sameTeamRef(), "alertmanager", "prod-alertmanager") reconcileOnce(ctx) reconcileOnce(ctx) Expect(readyCondition(ctx).Status).To(Equal(metav1.ConditionTrue)) as := &terdutv1alpha1.TerdutAlertSource{} Expect(k8sClient.Get(ctx, srcKey, as)).To(Succeed()) oldIntegrationID := as.Status.IntegrationID secretBefore := webhookSecret(ctx) // terdut-server only supports one kind today (DESIGN.md ยง4.5), // so there's no second real value to rotate to -- this fake // server doesn't validate kind at all, so re-POSTing under the // same literal string still exercises the full // delete-then-create rotation path and produces a fresh id // and key, which is everything this test needs to verify. as.Spec.Kind = "alertmanager" as.Status.LastAppliedKind = "something-else" Expect(k8sClient.Status().Update(ctx, as)).To(Succeed()) reconcileOnce(ctx) Expect(fake.integrationDelete[oldIntegrationID]).To(BeTrue()) Expect(k8sClient.Get(ctx, srcKey, as)).To(Succeed()) Expect(as.Status.IntegrationID).NotTo(Equal(oldIntegrationID)) Expect(as.Status.LastAppliedKind).To(Equal("alertmanager")) secretAfter := webhookSecret(ctx) Expect(secretAfter.Data[webhookSecretKeyDataKey]).NotTo(Equal(secretBefore.Data[webhookSecretKeyDataKey]), "a kind rotation must mint a new webhook key") }) }) Describe("crash recovery between POST and status write", func() { It("adopts the webhook Secret's own record instead of minting a second integration", func(ctx SpecContext) { createSource(ctx, sameTeamRef(), "alertmanager", "prod-alertmanager") reconcileOnce(ctx) // finalizer only -- status.integrationID stays 0 // Simulate a previous reconcile that got as far as writing the // Secret (the only durable record this resource can have, per // its own controller comment) but crashed before status. secret := &corev1.Secret{ ObjectMeta: metav1.ObjectMeta{Name: srcName + "-terdut-webhook", Namespace: operatorNamespace}, Data: map[string][]byte{ webhookSecretURLKey: []byte("https://terdut.example.invalid/api/integrations/orphaned-key/alertmanager"), webhookSecretKeyDataKey: []byte("orphaned-key"), webhookSecretIntegrationIDKey: []byte(strconv.FormatInt(999, 10)), }, } Expect(k8sClient.Create(ctx, secret)).To(Succeed()) reconcileOnce(ctx) as := &terdutv1alpha1.TerdutAlertSource{} Expect(k8sClient.Get(ctx, srcKey, as)).To(Succeed()) Expect(as.Status.IntegrationID).To(Equal(int64(999))) Expect(fake.integrations[team.Status.TeamID]).To(BeEmpty(), "should never have called POST at all") Expect(readyCondition(ctx).Status).To(Equal(metav1.ConditionTrue)) }) }) Describe("webhook Secret loss", func() { It("reports WebhookSecretLost and does not mint a replacement", func(ctx SpecContext) { createSource(ctx, sameTeamRef(), "alertmanager", "prod-alertmanager") reconcileOnce(ctx) reconcileOnce(ctx) Expect(readyCondition(ctx).Status).To(Equal(metav1.ConditionTrue)) as := &terdutv1alpha1.TerdutAlertSource{} Expect(k8sClient.Get(ctx, srcKey, as)).To(Succeed()) integrationID := as.Status.IntegrationID Expect(k8sClient.Delete(ctx, &corev1.Secret{ObjectMeta: metav1.ObjectMeta{ Name: srcName + "-terdut-webhook", Namespace: operatorNamespace, }})).To(Succeed()) reconcileOnce(ctx) cond := readyCondition(ctx) Expect(cond.Status).To(Equal(metav1.ConditionFalse)) Expect(cond.Reason).To(Equal(terdutv1alpha1.ReasonWebhookSecretLost)) Expect(fake.integrationDelete[integrationID]).To(BeFalse(), "fail-closed: must not touch the still-live integration server-side either") }) }) Describe("waiting on the referenced TerdutTeam", func() { It("reports TeamRefNotFound when the TerdutTeam doesn't exist", func(ctx SpecContext) { createSource(ctx, terdutv1alpha1.TerdutTeamRef{Name: testRefNotFoundName}, "alertmanager", "prod-alertmanager") 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("asteam-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) }) createSource(ctx, terdutv1alpha1.TerdutTeamRef{Name: unreadyName}, "alertmanager", "prod-alertmanager") reconcileOnce(ctx) // finalizer reconcileOnce(ctx) Expect(readyCondition(ctx).Reason).To(Equal(terdutv1alpha1.ReasonWaitingForTeam)) }) }) Describe("deletion", func() { It("deletes the integration server-side and removes the finalizer", func(ctx SpecContext) { createSource(ctx, sameTeamRef(), "alertmanager", "prod-alertmanager") reconcileOnce(ctx) reconcileOnce(ctx) Expect(readyCondition(ctx).Status).To(Equal(metav1.ConditionTrue)) as := &terdutv1alpha1.TerdutAlertSource{} Expect(k8sClient.Get(ctx, srcKey, as)).To(Succeed()) integrationID := as.Status.IntegrationID Expect(k8sClient.Delete(ctx, as)).To(Succeed()) reconcileOnce(ctx) // runs the finalizer Expect(fake.integrationDelete[integrationID]).To(BeTrue()) err := k8sClient.Get(ctx, srcKey, as) Expect(err).To(HaveOccurred(), "the TerdutAlertSource itself should be gone once the finalizer clears") }) }) })