/* Copyright 2026. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ package controller import ( "time" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" apierrors "k8s.io/apimachinery/pkg/api/errors" apimeta "k8s.io/apimachinery/pkg/api/meta" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" ctrl "sigs.k8s.io/controller-runtime" crawlv1alpha1 "gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator/api/v1alpha1" "gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator/internal/provider" ) // These specs drive the reconciler against a real envtest API server, so // CRD structural defaulting and CEL validation are live — the parts the // fake-client action-table tests can't cover. The provider stays a stub: // envtest has no kubelet or cloud, so instance state is simulated by // mutating the stub between reconciles. var _ = Describe("Proxy controller", func() { const ns = "default" newEnvtestReconciler := func(stub *stubProvider) *ProxyReconciler { return &ProxyReconciler{ Client: k8sClient, Scheme: k8sClient.Scheme(), Providers: map[string]provider.Provider{"stub": stub}, ProvisioningPoll: 50 * time.Millisecond, DriftPoll: 100 * time.Millisecond, DeletionPoll: 50 * time.Millisecond, QuotaRetry: 200 * time.Millisecond, RequeueNow: 10 * time.Millisecond, } } envReconcile := func(r *ProxyReconciler, name string) (ctrl.Result, error) { return r.Reconcile(ctx, ctrl.Request{ NamespacedName: types.NamespacedName{Namespace: ns, Name: name}, }) } fetch := func(name string) *crawlv1alpha1.Proxy { p := &crawlv1alpha1.Proxy{} Expect(k8sClient.Get(ctx, types.NamespacedName{Namespace: ns, Name: name}, p)).To(Succeed()) return p } // cleanup drives a Managed proxy's finalizer to completion so one spec's // leftovers can't leak into another. Registered via DeferCleanup so it // runs even when the spec body fails mid-way. cleanup := func(r *ProxyReconciler, stub *stubProvider, name string) { p := &crawlv1alpha1.Proxy{} err := k8sClient.Get(ctx, types.NamespacedName{Namespace: ns, Name: name}, p) if apierrors.IsNotFound(err) { return } Expect(err).NotTo(HaveOccurred()) Expect(k8sClient.Delete(ctx, p)).To(Succeed()) stub.getErr = provider.Wrap(provider.ErrNotFound, "get", "stub", "", nil) for range 3 { if _, err := envReconcile(r, name); err != nil { break } if apierrors.IsNotFound(k8sClient.Get(ctx, types.NamespacedName{Namespace: ns, Name: name}, p)) { return } } Fail("cleanup did not drive the proxy " + name + " to deletion") } managedSpec := func() crawlv1alpha1.ProxySpec { return crawlv1alpha1.ProxySpec{ Mode: crawlv1alpha1.ModeManaged, Provider: "stub", } } It("provisions a Managed proxy through to Running", func() { const name = "e2e-provision" stub := &stubProvider{createID: "inst-1"} r := newEnvtestReconciler(stub) DeferCleanup(func() { cleanup(r, stub, name) }) Expect(k8sClient.Create(ctx, &crawlv1alpha1.Proxy{ ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: ns}, Spec: managedSpec(), })).To(Succeed()) By("adding the finalizer on the first pass") res, err := envReconcile(r, name) Expect(err).NotTo(HaveOccurred()) Expect(res).To(Equal(ctrl.Result{})) Expect(fetch(name).Finalizers).To(ContainElement(crawlv1alpha1.FinalizerName)) By("creating the instance on the second pass") res, err = envReconcile(r, name) Expect(err).NotTo(HaveOccurred()) Expect(res.RequeueAfter).To(Equal(r.ProvisioningPoll)) p := fetch(name) Expect(p.Status.ProviderID).To(Equal("inst-1")) Expect(p.Annotations).To(HaveKey(crawlv1alpha1.AnnotationSpecHash)) // The real API server defaulted spec.port; the create request must // have seen it. Expect(p.Spec.Port).To(Equal(crawlv1alpha1.DefaultPort)) Expect(stub.lastCreate.Port).To(Equal(crawlv1alpha1.DefaultPort)) Expect(stub.lastCreate.Name).To(Equal(provider.NameFromUID(p.UID))) By("polling while the instance provisions") stub.getInst = &provider.Instance{ID: "inst-1", State: provider.StateProvisioning} res, err = envReconcile(r, name) Expect(err).NotTo(HaveOccurred()) Expect(res.RequeueAfter).To(Equal(r.ProvisioningPoll)) Expect(fetch(name).Status.Phase).To(Equal(crawlv1alpha1.PhaseProvisioning)) By("publishing the IP once the instance runs") stub.getInst = &provider.Instance{ID: "inst-1", IP: "10.9.8.7", State: provider.StateRunning} res, err = envReconcile(r, name) Expect(err).NotTo(HaveOccurred()) Expect(res.RequeueAfter).To(Equal(r.DriftPoll)) p = fetch(name) Expect(p.Status.IP).To(Equal("10.9.8.7")) cond := apimeta.FindStatusCondition(p.Status.Conditions, crawlv1alpha1.ConditionProvisioned) Expect(cond).NotTo(BeNil()) Expect(cond.Status).To(Equal(metav1.ConditionTrue)) Expect(p.Status.ObservedGeneration).To(Equal(p.Generation)) }) It("replaces the instance when the spec changes", func() { const name = "e2e-replace" stub := &stubProvider{createID: "inst-old"} r := newEnvtestReconciler(stub) DeferCleanup(func() { cleanup(r, stub, name) }) Expect(k8sClient.Create(ctx, &crawlv1alpha1.Proxy{ ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: ns}, Spec: managedSpec(), })).To(Succeed()) _, err := envReconcile(r, name) Expect(err).NotTo(HaveOccurred()) _, err = envReconcile(r, name) Expect(err).NotTo(HaveOccurred()) stub.getInst = &provider.Instance{ID: "inst-old", IP: "10.0.0.1", State: provider.StateRunning} _, err = envReconcile(r, name) Expect(err).NotTo(HaveOccurred()) oldHash := fetch(name).Annotations[crawlv1alpha1.AnnotationSpecHash] By("editing a replacement-triggering field") p := fetch(name) p.Spec.Port = 8080 Expect(k8sClient.Update(ctx, p)).To(Succeed()) By("deleting the old instance first") res, err := envReconcile(r, name) Expect(err).NotTo(HaveOccurred()) Expect(res.RequeueAfter).To(Equal(r.DeletionPoll)) Expect(stub.deleteCalls).To(Equal(1)) p = fetch(name) Expect(p.Annotations[crawlv1alpha1.AnnotationSpecHash]).To(Equal(oldHash), "hash must not advance while the old instance still exists") cond := apimeta.FindStatusCondition(p.Status.Conditions, crawlv1alpha1.ConditionProvisioned) Expect(cond.Reason).To(Equal(ReasonReplacing)) By("advancing the hash once the old instance is gone") stub.getErr = provider.Wrap(provider.ErrNotFound, "get", "stub", "inst-old", nil) _, err = envReconcile(r, name) Expect(err).NotTo(HaveOccurred()) p = fetch(name) Expect(p.Status.ProviderID).To(BeEmpty()) Expect(p.Annotations[crawlv1alpha1.AnnotationSpecHash]).NotTo(Equal(oldHash)) By("creating the replacement") stub.createID = "inst-new" stub.getErr = nil stub.getInst = nil res, err = envReconcile(r, name) Expect(err).NotTo(HaveOccurred()) Expect(res.RequeueAfter).To(Equal(r.ProvisioningPoll)) Expect(stub.createCalls).To(Equal(2)) Expect(fetch(name).Status.ProviderID).To(Equal("inst-new")) Expect(stub.lastCreate.Port).To(Equal(int32(8080))) }) It("cleans up the instance on delete via the finalizer", func() { const name = "e2e-delete" stub := &stubProvider{createID: "inst-del"} r := newEnvtestReconciler(stub) DeferCleanup(func() { cleanup(r, stub, name) }) Expect(k8sClient.Create(ctx, &crawlv1alpha1.Proxy{ ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: ns}, Spec: managedSpec(), })).To(Succeed()) _, err := envReconcile(r, name) Expect(err).NotTo(HaveOccurred()) _, err = envReconcile(r, name) Expect(err).NotTo(HaveOccurred()) stub.getInst = &provider.Instance{ID: "inst-del", IP: "10.0.0.2", State: provider.StateRunning} _, err = envReconcile(r, name) Expect(err).NotTo(HaveOccurred()) By("deleting the CR — the finalizer holds it") Expect(k8sClient.Delete(ctx, fetch(name))).To(Succeed()) res, err := envReconcile(r, name) Expect(err).NotTo(HaveOccurred()) Expect(res.RequeueAfter).To(Equal(r.DeletionPoll)) Expect(stub.deleteCalls).To(Equal(1)) Expect(fetch(name).Status.Phase).To(Equal(crawlv1alpha1.PhaseDeleting)) By("removing the finalizer once the instance is gone") stub.getErr = provider.Wrap(provider.ErrNotFound, "get", "stub", "inst-del", nil) _, err = envReconcile(r, name) Expect(err).NotTo(HaveOccurred()) err = k8sClient.Get(ctx, types.NamespacedName{Namespace: ns, Name: name}, &crawlv1alpha1.Proxy{}) Expect(apierrors.IsNotFound(err)).To(BeTrue(), "proxy should be fully deleted") }) It("tracks an External proxy without touching providers", func() { const name = "e2e-external" stub := &stubProvider{} r := newEnvtestReconciler(stub) Expect(k8sClient.Create(ctx, &crawlv1alpha1.Proxy{ ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: ns}, Spec: crawlv1alpha1.ProxySpec{ Mode: crawlv1alpha1.ModeExternal, Endpoint: &crawlv1alpha1.EndpointSpec{Host: "203.0.113.7"}, }, })).To(Succeed()) res, err := envReconcile(r, name) Expect(err).NotTo(HaveOccurred()) Expect(res).To(Equal(ctrl.Result{})) p := fetch(name) Expect(p.Status.IP).To(Equal("203.0.113.7")) Expect(p.Finalizers).To(BeEmpty()) cond := apimeta.FindStatusCondition(p.Status.Conditions, crawlv1alpha1.ConditionProvisioned) Expect(cond).NotTo(BeNil()) Expect(cond.Reason).To(Equal(ReasonExternalEndpoint)) Expect(stub.createCalls + stub.getCalls + stub.deleteCalls).To(BeZero()) By("deleting without any finalizer round-trip") Expect(k8sClient.Delete(ctx, p)).To(Succeed()) err = k8sClient.Get(ctx, types.NamespacedName{Namespace: ns, Name: name}, &crawlv1alpha1.Proxy{}) Expect(apierrors.IsNotFound(err)).To(BeTrue()) }) })