package controller import ( "context" "testing" "time" corev1 "k8s.io/api/core/v1" 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/runtime" "k8s.io/apimachinery/pkg/types" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/client/fake" crawlv1alpha1 "gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator/api/v1alpha1" "gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator/internal/provider" ) // Distinct per-interval values so an asserted ctrl.Result is unambiguous // about which action-table row produced it. const ( tProvisioningPoll = 11 * time.Second tDriftPoll = 22 * time.Second tDeletionPoll = 33 * time.Second tQuotaRetry = 44 * time.Second tRequeueNow = 55 * time.Millisecond ) const ( testProxyName = "p1" testNamespace = "default" testUID = types.UID("11111111-2222-3333-4444-555555555555") ) // stubProvider is the plan's in-test Provider stub: a handful of lines, no // config format, no fault-injection surface beyond settable fields. type stubProvider struct { createID string createErr error getInst *provider.Instance getErr error deleteErr error createCalls, getCalls, deleteCalls int lastCreate provider.CreateRequest } func (s *stubProvider) Create(_ context.Context, req provider.CreateRequest) (string, error) { s.createCalls++ s.lastCreate = req if s.createErr != nil { return "", s.createErr } return s.createID, nil } func (s *stubProvider) Get(_ context.Context, _ string) (*provider.Instance, error) { s.getCalls++ if s.getErr != nil { return nil, s.getErr } return s.getInst, nil } func (s *stubProvider) Delete(_ context.Context, _ string) error { s.deleteCalls++ return s.deleteErr } func (s *stubProvider) ListByTag(context.Context) ([]provider.Instance, error) { return nil, nil } func notFoundErr() error { return provider.Wrap(provider.ErrNotFound, "get", "stub", "some-id", nil) } func testScheme(t *testing.T) *runtime.Scheme { t.Helper() s := runtime.NewScheme() if err := crawlv1alpha1.AddToScheme(s); err != nil { t.Fatalf("adding crawl scheme: %v", err) } if err := corev1.AddToScheme(s); err != nil { t.Fatalf("adding core scheme: %v", err) } return s } // managedProxy returns a Managed proxy that already carries the finalizer — // the state most action-table rows start from. Mutators adjust from there. func managedProxy(mut ...func(*crawlv1alpha1.Proxy)) *crawlv1alpha1.Proxy { p := &crawlv1alpha1.Proxy{ ObjectMeta: metav1.ObjectMeta{ Name: testProxyName, Namespace: testNamespace, UID: testUID, Generation: 1, Finalizers: []string{crawlv1alpha1.FinalizerName}, }, Spec: crawlv1alpha1.ProxySpec{ Mode: crawlv1alpha1.ModeManaged, Provider: "stub", }, } for _, m := range mut { m(p) } return p } func withProviderID(id string) func(*crawlv1alpha1.Proxy) { return func(p *crawlv1alpha1.Proxy) { p.Status.ProviderID = id } } func withSpecHashAnnotation(hash string) func(*crawlv1alpha1.Proxy) { return func(p *crawlv1alpha1.Proxy) { if p.Annotations == nil { p.Annotations = map[string]string{} } p.Annotations[crawlv1alpha1.AnnotationSpecHash] = hash } } func deleting() func(*crawlv1alpha1.Proxy) { return func(p *crawlv1alpha1.Proxy) { now := metav1.Now() p.DeletionTimestamp = &now } } func newTestReconciler(t *testing.T, stub *stubProvider, objs ...client.Object) *ProxyReconciler { t.Helper() c := fake.NewClientBuilder(). WithScheme(testScheme(t)). WithStatusSubresource(&crawlv1alpha1.Proxy{}). WithObjects(objs...). Build() return &ProxyReconciler{ Client: c, Providers: map[string]provider.Provider{"stub": stub}, ProvisioningPoll: tProvisioningPoll, DriftPoll: tDriftPoll, DeletionPoll: tDeletionPoll, QuotaRetry: tQuotaRetry, RequeueNow: tRequeueNow, } } func doReconcile(t *testing.T, r *ProxyReconciler) (ctrl.Result, error) { t.Helper() return r.Reconcile(context.Background(), ctrl.Request{ NamespacedName: types.NamespacedName{Namespace: testNamespace, Name: testProxyName}, }) } func getProxy(t *testing.T, r *ProxyReconciler) *crawlv1alpha1.Proxy { t.Helper() var p crawlv1alpha1.Proxy if err := r.Get(context.Background(), types.NamespacedName{Namespace: testNamespace, Name: testProxyName}, &p); err != nil { t.Fatalf("getting proxy: %v", err) } return &p } func assertCondition(t *testing.T, p *crawlv1alpha1.Proxy, condType string, status metav1.ConditionStatus, reason string) { t.Helper() cond := apimeta.FindStatusCondition(p.Status.Conditions, condType) if cond == nil { t.Fatalf("condition %s missing, have %+v", condType, p.Status.Conditions) } if cond.Status != status || cond.Reason != reason { t.Errorf("condition %s = %s/%s, want %s/%s", condType, cond.Status, cond.Reason, status, reason) } if cond.ObservedGeneration != p.Generation { t.Errorf("condition %s observedGeneration = %d, want %d", condType, cond.ObservedGeneration, p.Generation) } } // TestReconcile_actionTable exercises every row of the plan's action table // by calling Reconcile directly against a fake client. Caveat (documented in // the plan): the fake client runs neither CEL validation nor structural // defaulting — the envtest suite covers those. func TestReconcile_actionTable(t *testing.T) { t.Parallel() // The fixture's hash: no placement, no cloud-init, defaulted port. freshHash := specHash(managedProxy(), "") tests := []struct { name string proxy *crawlv1alpha1.Proxy extraObjs []client.Object stub *stubProvider wantResult ctrl.Result wantErr bool verify func(t *testing.T, r *ProxyReconciler, stub *stubProvider) }{ { name: "managed without finalizer gets one and stops", proxy: managedProxy(func(p *crawlv1alpha1.Proxy) { p.Finalizers = nil }), stub: &stubProvider{}, wantResult: ctrl.Result{}, verify: func(t *testing.T, r *ProxyReconciler, stub *stubProvider) { p := getProxy(t, r) if len(p.Finalizers) != 1 || p.Finalizers[0] != crawlv1alpha1.FinalizerName { t.Errorf("finalizers = %v, want [%s]", p.Finalizers, crawlv1alpha1.FinalizerName) } if stub.createCalls+stub.getCalls+stub.deleteCalls != 0 { t.Errorf("provider was called before the finalizer was persisted") } }, }, { name: "empty providerID creates the instance", proxy: managedProxy(), stub: &stubProvider{createID: "stub-id-1"}, wantResult: ctrl.Result{RequeueAfter: tProvisioningPoll}, verify: func(t *testing.T, r *ProxyReconciler, stub *stubProvider) { p := getProxy(t, r) if p.Status.ProviderID != "stub-id-1" { t.Errorf("providerID = %q, want stub-id-1", p.Status.ProviderID) } if got := p.Annotations[crawlv1alpha1.AnnotationSpecHash]; got != freshHash { t.Errorf("spec-hash annotation = %q, want %q", got, freshHash) } assertCondition(t, p, crawlv1alpha1.ConditionProvisioned, metav1.ConditionFalse, ReasonProvisioning) if p.Status.Phase != crawlv1alpha1.PhaseProvisioning { t.Errorf("phase = %s, want Provisioning", p.Status.Phase) } want := provider.CreateRequest{ Name: provider.NameFromUID(testUID), UID: string(testUID), Namespace: testNamespace, ProxyName: testProxyName, Port: crawlv1alpha1.DefaultPort, } if stub.lastCreate != want { t.Errorf("CreateRequest = %+v, want %+v", stub.lastCreate, want) } }, }, { name: "provisioning instance polls again", proxy: managedProxy(withProviderID("stub-id-1"), withSpecHashAnnotation(freshHash)), stub: &stubProvider{getInst: &provider.Instance{ID: "stub-id-1", State: provider.StateProvisioning}}, wantResult: ctrl.Result{RequeueAfter: tProvisioningPoll}, verify: func(t *testing.T, r *ProxyReconciler, _ *stubProvider) { p := getProxy(t, r) if p.Status.IP != "" { t.Errorf("ip = %q, want empty while provisioning", p.Status.IP) } assertCondition(t, p, crawlv1alpha1.ConditionProvisioned, metav1.ConditionFalse, ReasonProvisioning) }, }, { name: "running instance publishes IP", proxy: managedProxy(withProviderID("stub-id-1"), withSpecHashAnnotation(freshHash)), stub: &stubProvider{ getInst: &provider.Instance{ID: "stub-id-1", IP: "10.1.2.3", State: provider.StateRunning}, }, wantResult: ctrl.Result{RequeueAfter: tDriftPoll}, verify: func(t *testing.T, r *ProxyReconciler, _ *stubProvider) { p := getProxy(t, r) if p.Status.IP != "10.1.2.3" { t.Errorf("ip = %q, want 10.1.2.3", p.Status.IP) } assertCondition(t, p, crawlv1alpha1.ConditionProvisioned, metav1.ConditionTrue, ReasonCreated) if p.Status.Phase != crawlv1alpha1.PhaseProvisioning { t.Errorf("phase = %s, want Provisioning until a health verdict exists", p.Status.Phase) } }, }, { name: "stopped instance is deleted for recreation", proxy: managedProxy(withProviderID("stub-id-1"), withSpecHashAnnotation(freshHash)), stub: &stubProvider{getInst: &provider.Instance{ID: "stub-id-1", State: provider.StateStopped}}, wantResult: ctrl.Result{RequeueAfter: tDeletionPoll}, verify: func(t *testing.T, r *ProxyReconciler, stub *stubProvider) { if stub.deleteCalls != 1 { t.Errorf("deleteCalls = %d, want 1", stub.deleteCalls) } assertCondition(t, getProxy(t, r), crawlv1alpha1.ConditionProvisioned, metav1.ConditionFalse, ReasonRecreating) }, }, { name: "vanished instance clears ID for recreation", proxy: managedProxy(withProviderID("stub-id-1"), withSpecHashAnnotation(freshHash), func(p *crawlv1alpha1.Proxy) { p.Status.IP = "10.1.2.3" }), stub: &stubProvider{getErr: notFoundErr()}, wantResult: ctrl.Result{RequeueAfter: tRequeueNow}, verify: func(t *testing.T, r *ProxyReconciler, _ *stubProvider) { p := getProxy(t, r) if p.Status.ProviderID != "" || p.Status.IP != "" { t.Errorf("providerID/ip = %q/%q, want both cleared", p.Status.ProviderID, p.Status.IP) } }, }, { name: "hash mismatch deletes the old instance", proxy: managedProxy(withProviderID("stub-id-1"), withSpecHashAnnotation("stale-hash")), stub: &stubProvider{ getInst: &provider.Instance{ID: "stub-id-1", IP: "10.1.2.3", State: provider.StateRunning}, }, wantResult: ctrl.Result{RequeueAfter: tDeletionPoll}, verify: func(t *testing.T, r *ProxyReconciler, stub *stubProvider) { if stub.deleteCalls != 1 { t.Errorf("deleteCalls = %d, want 1", stub.deleteCalls) } p := getProxy(t, r) // The hash must not advance until the old instance is gone, // or a crash would strand a half-replaced proxy. if got := p.Annotations[crawlv1alpha1.AnnotationSpecHash]; got != "stale-hash" { t.Errorf("spec-hash annotation = %q, want still stale-hash", got) } assertCondition(t, p, crawlv1alpha1.ConditionProvisioned, metav1.ConditionFalse, ReasonReplacing) }, }, { name: "empty annotation adopts instead of replacing", proxy: managedProxy(withProviderID("stub-id-1")), stub: &stubProvider{}, wantResult: ctrl.Result{RequeueAfter: tRequeueNow}, verify: func(t *testing.T, r *ProxyReconciler, stub *stubProvider) { p := getProxy(t, r) if got := p.Annotations[crawlv1alpha1.AnnotationSpecHash]; got != freshHash { t.Errorf("spec-hash annotation = %q, want %q", got, freshHash) } if p.Status.ProviderID != "stub-id-1" { t.Errorf("providerID = %q, want untouched stub-id-1", p.Status.ProviderID) } if stub.deleteCalls != 0 { t.Errorf("deleteCalls = %d, want 0 — adoption must not replace", stub.deleteCalls) } }, }, { name: "mismatch with instance gone advances the hash", proxy: managedProxy(withProviderID("stub-id-1"), withSpecHashAnnotation("stale-hash")), stub: &stubProvider{getErr: notFoundErr()}, wantResult: ctrl.Result{RequeueAfter: tRequeueNow}, verify: func(t *testing.T, r *ProxyReconciler, _ *stubProvider) { p := getProxy(t, r) if p.Status.ProviderID != "" { t.Errorf("providerID = %q, want cleared", p.Status.ProviderID) } if got := p.Annotations[crawlv1alpha1.AnnotationSpecHash]; got != freshHash { t.Errorf("spec-hash annotation = %q, want advanced to %q", got, freshHash) } }, }, { name: "deletion with no providerID removes the finalizer", proxy: managedProxy(deleting()), stub: &stubProvider{}, wantResult: ctrl.Result{}, verify: func(t *testing.T, r *ProxyReconciler, stub *stubProvider) { assertProxyGone(t, r) if stub.deleteCalls != 0 { t.Errorf("deleteCalls = %d, want 0", stub.deleteCalls) } }, }, { name: "deletion with instance already gone removes the finalizer", proxy: managedProxy(deleting(), withProviderID("stub-id-1")), stub: &stubProvider{getErr: notFoundErr()}, wantResult: ctrl.Result{}, verify: func(t *testing.T, r *ProxyReconciler, _ *stubProvider) { assertProxyGone(t, r) }, }, { name: "deletion deletes the instance and polls", proxy: managedProxy(deleting(), withProviderID("stub-id-1")), stub: &stubProvider{ getInst: &provider.Instance{ID: "stub-id-1", State: provider.StateRunning}, }, wantResult: ctrl.Result{RequeueAfter: tDeletionPoll}, verify: func(t *testing.T, r *ProxyReconciler, stub *stubProvider) { if stub.deleteCalls != 1 { t.Errorf("deleteCalls = %d, want 1", stub.deleteCalls) } p := getProxy(t, r) if p.Status.Phase != crawlv1alpha1.PhaseDeleting { t.Errorf("phase = %s, want Deleting", p.Status.Phase) } }, }, { name: "external proxy tracks its endpoint without a finalizer", proxy: managedProxy(func(p *crawlv1alpha1.Proxy) { p.Finalizers = nil p.Spec = crawlv1alpha1.ProxySpec{ Mode: crawlv1alpha1.ModeExternal, Endpoint: &crawlv1alpha1.EndpointSpec{Host: "203.0.113.7", Port: 8080}, } }), stub: &stubProvider{}, wantResult: ctrl.Result{}, verify: func(t *testing.T, r *ProxyReconciler, stub *stubProvider) { p := getProxy(t, r) if p.Status.IP != "203.0.113.7" { t.Errorf("ip = %q, want the endpoint host", p.Status.IP) } if len(p.Finalizers) != 0 { t.Errorf("finalizers = %v, want none on External", p.Finalizers) } assertCondition(t, p, crawlv1alpha1.ConditionProvisioned, metav1.ConditionTrue, ReasonExternalEndpoint) if stub.createCalls+stub.getCalls+stub.deleteCalls != 0 { t.Errorf("provider was called for an External proxy") } }, }, { name: "quota error backs off slowly without failing", proxy: managedProxy(), stub: &stubProvider{ createErr: provider.Wrap(provider.ErrQuotaExceeded, "create", "stub", "", nil), }, wantResult: ctrl.Result{RequeueAfter: tQuotaRetry}, verify: func(t *testing.T, r *ProxyReconciler, _ *stubProvider) { p := getProxy(t, r) assertCondition(t, p, crawlv1alpha1.ConditionProvisioned, metav1.ConditionFalse, ReasonQuotaExceeded) if p.Status.Phase == crawlv1alpha1.PhaseFailed { t.Errorf("phase = Failed, want anything but — quota is a wait, not a failure") } }, }, { name: "permanent error latches Failed and stops calling the provider", proxy: managedProxy(), stub: &stubProvider{ createErr: provider.Wrap(provider.ErrPermanent, "create", "stub", "", nil), }, wantResult: ctrl.Result{}, verify: func(t *testing.T, r *ProxyReconciler, stub *stubProvider) { p := getProxy(t, r) assertCondition(t, p, crawlv1alpha1.ConditionProvisioned, metav1.ConditionFalse, ReasonPermanentError) if p.Status.Phase != crawlv1alpha1.PhaseFailed { t.Errorf("phase = %s, want Failed", p.Status.Phase) } if res, err := doReconcile(t, r); err != nil || res != (ctrl.Result{}) { t.Errorf("second reconcile = %+v, %v; want empty result, nil", res, err) } if stub.createCalls != 1 { t.Errorf("createCalls = %d after latch, want 1", stub.createCalls) } }, }, { name: "transient error is returned for workqueue backoff", proxy: managedProxy(), stub: &stubProvider{ createErr: provider.Wrap(provider.ErrTransient, "create", "stub", "", nil), }, wantResult: ctrl.Result{}, wantErr: true, verify: func(t *testing.T, r *ProxyReconciler, _ *stubProvider) { p := getProxy(t, r) if p.Status.Phase != crawlv1alpha1.PhasePending { t.Errorf("phase = %s, want still Pending", p.Status.Phase) } }, }, { name: "unconfigured provider is a permanent failure", proxy: managedProxy(func(p *crawlv1alpha1.Proxy) { p.Spec.Provider = "no-such-provider" }), stub: &stubProvider{}, wantResult: ctrl.Result{}, verify: func(t *testing.T, r *ProxyReconciler, _ *stubProvider) { p := getProxy(t, r) assertCondition(t, p, crawlv1alpha1.ConditionProvisioned, metav1.ConditionFalse, ReasonPermanentError) if p.Status.Phase != crawlv1alpha1.PhaseFailed { t.Errorf("phase = %s, want Failed", p.Status.Phase) } }, }, { name: "cloud-init secret is resolved into the create request", proxy: managedProxy(func(p *crawlv1alpha1.Proxy) { p.Spec.CloudInit = &crawlv1alpha1.CloudInitSpec{ SecretRef: &crawlv1alpha1.SecretKeySelector{Name: "ci-secret"}, } }), extraObjs: []client.Object{ &corev1.Secret{ ObjectMeta: metav1.ObjectMeta{Name: "ci-secret", Namespace: testNamespace}, Data: map[string][]byte{"user-data": []byte("#cloud-config\npackages: [squid]")}, }, }, stub: &stubProvider{createID: "stub-id-1"}, wantResult: ctrl.Result{RequeueAfter: tProvisioningPoll}, verify: func(t *testing.T, r *ProxyReconciler, stub *stubProvider) { if want := "#cloud-config\npackages: [squid]"; stub.lastCreate.CloudInit != want { t.Errorf("CreateRequest.CloudInit = %q, want the resolved secret content", stub.lastCreate.CloudInit) } }, }, { name: "missing cloud-init secret errors and marks the condition", proxy: managedProxy(func(p *crawlv1alpha1.Proxy) { p.Spec.CloudInit = &crawlv1alpha1.CloudInitSpec{ SecretRef: &crawlv1alpha1.SecretKeySelector{Name: "absent"}, } }), stub: &stubProvider{}, wantResult: ctrl.Result{}, wantErr: true, verify: func(t *testing.T, r *ProxyReconciler, stub *stubProvider) { assertCondition(t, getProxy(t, r), crawlv1alpha1.ConditionProvisioned, metav1.ConditionFalse, ReasonCloudInitError) if stub.createCalls != 0 { t.Errorf("createCalls = %d, want 0 with unresolved cloud-init", stub.createCalls) } }, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { t.Parallel() objs := append([]client.Object{tc.proxy}, tc.extraObjs...) r := newTestReconciler(t, tc.stub, objs...) res, err := doReconcile(t, r) if (err != nil) != tc.wantErr { t.Fatalf("Reconcile error = %v, wantErr %v", err, tc.wantErr) } if res != tc.wantResult { t.Errorf("Result = %+v, want %+v", res, tc.wantResult) } tc.verify(t, r, tc.stub) }) } } func assertProxyGone(t *testing.T, r *ProxyReconciler) { t.Helper() var p crawlv1alpha1.Proxy err := r.Get(context.Background(), types.NamespacedName{Namespace: testNamespace, Name: testProxyName}, &p) if !apierrors.IsNotFound(err) { t.Errorf("proxy still exists (err=%v), want NotFound after finalizer removal", err) } }