Add the Proxy reconciler state machine with action-table, phase, and envtest suites
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -17,77 +17,250 @@ limitations under the License.
|
||||
package controller
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
"k8s.io/apimachinery/pkg/api/errors"
|
||||
"k8s.io/apimachinery/pkg/types"
|
||||
"sigs.k8s.io/controller-runtime/pkg/reconcile"
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
var _ = Describe("Proxy Controller", func() {
|
||||
Context("When reconciling a resource", func() {
|
||||
const (
|
||||
resourceName = "test-resource"
|
||||
resourceNamespace = "default"
|
||||
)
|
||||
// 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"
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
typeNamespacedName := types.NamespacedName{
|
||||
Name: resourceName,
|
||||
Namespace: resourceNamespace,
|
||||
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,
|
||||
}
|
||||
proxy := &crawlv1alpha1.Proxy{}
|
||||
}
|
||||
|
||||
BeforeEach(func() {
|
||||
By("creating the custom resource for the Kind Proxy")
|
||||
err := k8sClient.Get(ctx, typeNamespacedName, proxy)
|
||||
if err != nil && errors.IsNotFound(err) {
|
||||
resource := &crawlv1alpha1.Proxy{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: resourceName,
|
||||
Namespace: resourceNamespace,
|
||||
},
|
||||
// A minimal, schema-valid spec so this placeholder test survives the
|
||||
// CEL/CRD validation added in Step 1. Rewritten wholesale in Step 4
|
||||
// alongside the real reconciler and envtest suite.
|
||||
Spec: crawlv1alpha1.ProxySpec{
|
||||
Mode: crawlv1alpha1.ModeExternal,
|
||||
Endpoint: &crawlv1alpha1.EndpointSpec{Host: "10.0.0.1"},
|
||||
},
|
||||
}
|
||||
Expect(k8sClient.Create(ctx, resource)).To(Succeed())
|
||||
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
|
||||
}
|
||||
})
|
||||
|
||||
AfterEach(func() {
|
||||
// TODO(user): Cleanup logic after each test, like removing the resource instance.
|
||||
resource := &crawlv1alpha1.Proxy{}
|
||||
err := k8sClient.Get(ctx, typeNamespacedName, resource)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
By("Cleanup the specific resource instance Proxy")
|
||||
Expect(k8sClient.Delete(ctx, resource)).To(Succeed())
|
||||
})
|
||||
It("should successfully reconcile the resource", func() {
|
||||
By("Reconciling the created resource")
|
||||
controllerReconciler := &ProxyReconciler{
|
||||
Client: k8sClient,
|
||||
Scheme: k8sClient.Scheme(),
|
||||
if apierrors.IsNotFound(k8sClient.Get(ctx, types.NamespacedName{Namespace: ns, Name: name}, p)) {
|
||||
return
|
||||
}
|
||||
}
|
||||
Fail("cleanup did not drive the proxy " + name + " to deletion")
|
||||
}
|
||||
|
||||
_, err := controllerReconciler.Reconcile(ctx, reconcile.Request{
|
||||
NamespacedName: typeNamespacedName,
|
||||
})
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
// TODO(user): Add more specific assertions depending on your controller's reconciliation logic.
|
||||
// Example: If you expect a certain status condition after reconciliation, verify it here.
|
||||
})
|
||||
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())
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user