267 lines
10 KiB
Go
267 lines
10 KiB
Go
/*
|
|
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())
|
|
})
|
|
})
|