742 lines
28 KiB
Go
742 lines
28 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 (
|
|
"fmt"
|
|
"sync/atomic"
|
|
|
|
. "github.com/onsi/ginkgo/v2"
|
|
. "github.com/onsi/gomega"
|
|
|
|
appsv1 "k8s.io/api/apps/v1"
|
|
corev1 "k8s.io/api/core/v1"
|
|
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
|
"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/controller/controllerutil"
|
|
|
|
lmcachev1alpha1 "github.com/LMCache/LMCache/api/v1alpha1"
|
|
"github.com/LMCache/LMCache/internal/resources"
|
|
)
|
|
|
|
// nsSeq is a monotonic counter appended to every generated namespace
|
|
// name so concurrent specs (or repeated runs against the same envtest
|
|
// API server within a suite) never collide. envtest doesn't garbage-
|
|
// collect a deleted namespace's children, so per-spec namespaces
|
|
// double as a cheap form of test isolation.
|
|
var nsSeq atomic.Int64
|
|
|
|
// uniqueNS returns a fresh namespace name unique within this test
|
|
// process. The prefix makes it easy to spot the source of a stray
|
|
// object if a test crashes mid-flight.
|
|
func uniqueNS(prefix string) string {
|
|
return fmt.Sprintf("%s-%d", prefix, nsSeq.Add(1))
|
|
}
|
|
|
|
// mustCreateNS creates a namespace and registers cleanup. Returns the
|
|
// name so callers can put resources in it. We don't rely on namespace
|
|
// teardown to clean the contents — envtest leaves them as Terminating
|
|
// without ever finalising the delete — so individual tests still must
|
|
// register cleanup for each resource they create.
|
|
func mustCreateNS(name string) string {
|
|
GinkgoHelper()
|
|
ns := &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: name}}
|
|
Expect(k8sClient.Create(ctx, ns)).To(Succeed())
|
|
DeferCleanup(func() {
|
|
// Best-effort namespace delete. Don't fail the test on errors
|
|
// — the suite tears down the whole apiserver after AfterSuite
|
|
// anyway.
|
|
_ = k8sClient.Delete(ctx, ns)
|
|
})
|
|
return name
|
|
}
|
|
|
|
// newEngine builds an LMCacheEngine fixture and Creates it in the
|
|
// cluster (which populates APIVersion / Kind / UID — required for
|
|
// ctrl.SetControllerReference to work in the function-under-test).
|
|
// Returns the Created object so callers can mutate Spec further.
|
|
func newEngine(namespace, name string) *lmcachev1alpha1.LMCacheEngine {
|
|
GinkgoHelper()
|
|
e := &lmcachev1alpha1.LMCacheEngine{
|
|
ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: namespace},
|
|
Spec: lmcachev1alpha1.LMCacheEngineSpec{L1: lmcachev1alpha1.L1BackendSpec{SizeGB: 10}},
|
|
}
|
|
Expect(k8sClient.Create(ctx, e)).To(Succeed())
|
|
DeferCleanup(func() { _ = k8sClient.Delete(ctx, e) })
|
|
// Re-fetch so TypeMeta is populated for SetControllerReference.
|
|
Expect(k8sClient.Get(ctx, types.NamespacedName{Name: name, Namespace: namespace}, e)).To(Succeed())
|
|
return e
|
|
}
|
|
|
|
// newReconciler wires a LMCacheEngineReconciler around the suite's
|
|
// envtest client. Kept as a one-liner because every spec needs one.
|
|
func newReconciler() *LMCacheEngineReconciler {
|
|
return &LMCacheEngineReconciler{Client: k8sClient, Scheme: k8sClient.Scheme()}
|
|
}
|
|
|
|
// ----- pure helpers (no envtest) -----------------------------------
|
|
|
|
var _ = Describe("conditionBool", func() {
|
|
It("maps true → ConditionTrue and false → ConditionFalse", func() {
|
|
Expect(conditionBool(true)).To(Equal(metav1.ConditionTrue))
|
|
Expect(conditionBool(false)).To(Equal(metav1.ConditionFalse))
|
|
})
|
|
})
|
|
|
|
var _ = Describe("reasonFromReady", func() {
|
|
It("returns the true-reason when ready, the false-reason otherwise", func() {
|
|
Expect(reasonFromReady(true, "Ready", "NotReady")).To(Equal("Ready"))
|
|
Expect(reasonFromReady(false, "Ready", "NotReady")).To(Equal("NotReady"))
|
|
})
|
|
})
|
|
|
|
// ----- reconcileRESPAuthSecret -------------------------------------
|
|
|
|
var _ = Describe("reconcileRESPAuthSecret", func() {
|
|
var (
|
|
r *LMCacheEngineReconciler
|
|
nsName string
|
|
engineName string
|
|
)
|
|
|
|
BeforeEach(func() {
|
|
r = newReconciler()
|
|
nsName = mustCreateNS(uniqueNS("resp-auth"))
|
|
engineName = "test-engine"
|
|
})
|
|
|
|
It("no-ops when authSecretRef is nil", func() {
|
|
// Cover the early-return branch: L2Backend.RESP.AuthSecretRef
|
|
// nil means there's nothing to mirror, AND the call should
|
|
// also clean up any stale managed secret (delegated to
|
|
// deleteRESPAuthSecretIfExists).
|
|
engine := newEngine(nsName, engineName)
|
|
Expect(r.reconcileRESPAuthSecret(ctx, engine)).To(Succeed())
|
|
|
|
// No managed secret should have been created.
|
|
got := &corev1.Secret{}
|
|
err := k8sClient.Get(ctx, types.NamespacedName{
|
|
Name: resources.RESPAuthSecretName(engineName),
|
|
Namespace: nsName,
|
|
}, got)
|
|
Expect(apierrors.IsNotFound(err)).To(BeTrue(), "expected NotFound, got %v", err)
|
|
})
|
|
|
|
It("deletes a stale managed secret when authSecretRef is removed", func() {
|
|
// Pre-create a managed secret owned by the engine, simulating
|
|
// a previous reconcile that wired up auth. Then drop the spec
|
|
// reference and confirm the helper cleans up. Use the
|
|
// controllerutil helper to wire the OwnerReference via the
|
|
// scheme — manually-built refs miss APIVersion/Kind because
|
|
// envtest's client doesn't populate TypeMeta on Get.
|
|
engine := newEngine(nsName, engineName)
|
|
stale := &corev1.Secret{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: resources.RESPAuthSecretName(engineName),
|
|
Namespace: nsName,
|
|
},
|
|
Data: map[string][]byte{"password": []byte("old")},
|
|
}
|
|
Expect(controllerutil.SetControllerReference(engine, stale, k8sClient.Scheme())).To(Succeed())
|
|
Expect(k8sClient.Create(ctx, stale)).To(Succeed())
|
|
|
|
Expect(r.reconcileRESPAuthSecret(ctx, engine)).To(Succeed())
|
|
|
|
// Stale managed secret must be gone.
|
|
err := k8sClient.Get(ctx, types.NamespacedName{Name: stale.Name, Namespace: nsName}, &corev1.Secret{})
|
|
Expect(apierrors.IsNotFound(err)).To(BeTrue(), "expected stale managed secret to be deleted, got err=%v", err)
|
|
})
|
|
|
|
It("leaves a non-owned secret of the same name alone", func() {
|
|
// Defensive: if an admin pre-created a Secret at the managed
|
|
// name without an ownerRef, the operator must NOT delete it
|
|
// — that's user data. deleteRESPAuthSecretIfExists checks
|
|
// IsControlledBy and bails out on false.
|
|
engine := newEngine(nsName, engineName)
|
|
unrelated := &corev1.Secret{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: resources.RESPAuthSecretName(engineName),
|
|
Namespace: nsName,
|
|
// No OwnerReferences: this Secret is not "owned" by anyone.
|
|
},
|
|
Data: map[string][]byte{"password": []byte("user-owned")},
|
|
}
|
|
Expect(k8sClient.Create(ctx, unrelated)).To(Succeed())
|
|
DeferCleanup(func() { _ = k8sClient.Delete(ctx, unrelated) })
|
|
|
|
Expect(r.reconcileRESPAuthSecret(ctx, engine)).To(Succeed())
|
|
|
|
// Still there.
|
|
got := &corev1.Secret{}
|
|
Expect(k8sClient.Get(ctx, types.NamespacedName{Name: unrelated.Name, Namespace: nsName}, got)).To(Succeed())
|
|
Expect(got.Data["password"]).To(Equal([]byte("user-owned")))
|
|
})
|
|
|
|
It("creates a managed copy when the source secret exists (same-namespace)", func() {
|
|
engine := newEngine(nsName, engineName)
|
|
source := &corev1.Secret{
|
|
ObjectMeta: metav1.ObjectMeta{Name: "redis-auth", Namespace: nsName},
|
|
Data: map[string][]byte{
|
|
"username": []byte("admin"),
|
|
"password": []byte("s3cret"), // pragma: allowlist secret
|
|
},
|
|
}
|
|
Expect(k8sClient.Create(ctx, source)).To(Succeed())
|
|
DeferCleanup(func() { _ = k8sClient.Delete(ctx, source) })
|
|
|
|
engine.Spec.L2Backend = &lmcachev1alpha1.L2BackendSpec{
|
|
RESP: &lmcachev1alpha1.RESPL2AdapterSpec{
|
|
Host: "redis",
|
|
Port: 6379,
|
|
AuthSecretRef: &lmcachev1alpha1.SecretReference{Name: "redis-auth"},
|
|
},
|
|
}
|
|
Expect(r.reconcileRESPAuthSecret(ctx, engine)).To(Succeed())
|
|
|
|
got := &corev1.Secret{}
|
|
Expect(k8sClient.Get(ctx, types.NamespacedName{
|
|
Name: resources.RESPAuthSecretName(engineName),
|
|
Namespace: nsName,
|
|
}, got)).To(Succeed())
|
|
Expect(got.Data).To(HaveKeyWithValue("password", []byte("s3cret")))
|
|
Expect(got.Data).To(HaveKeyWithValue("username", []byte("admin")))
|
|
Expect(metav1.IsControlledBy(got, engine)).To(BeTrue(), "managed secret must be owned by the engine")
|
|
})
|
|
|
|
It("creates a managed copy from a cross-namespace source", func() {
|
|
// The whole point of this helper — pulling a Secret from
|
|
// another namespace and mirroring it. Use a separate source
|
|
// namespace to prove the cross-NS read path executes.
|
|
sourceNS := mustCreateNS(uniqueNS("resp-auth-src"))
|
|
engine := newEngine(nsName, engineName)
|
|
source := &corev1.Secret{
|
|
ObjectMeta: metav1.ObjectMeta{Name: "shared-redis-auth", Namespace: sourceNS},
|
|
Data: map[string][]byte{"password": []byte("xns-pw")}, // pragma: allowlist secret
|
|
}
|
|
Expect(k8sClient.Create(ctx, source)).To(Succeed())
|
|
DeferCleanup(func() { _ = k8sClient.Delete(ctx, source) })
|
|
|
|
engine.Spec.L2Backend = &lmcachev1alpha1.L2BackendSpec{
|
|
RESP: &lmcachev1alpha1.RESPL2AdapterSpec{
|
|
Host: "redis",
|
|
Port: 6379,
|
|
AuthSecretRef: &lmcachev1alpha1.SecretReference{
|
|
Name: "shared-redis-auth",
|
|
Namespace: sourceNS,
|
|
},
|
|
},
|
|
}
|
|
Expect(r.reconcileRESPAuthSecret(ctx, engine)).To(Succeed())
|
|
|
|
got := &corev1.Secret{}
|
|
Expect(k8sClient.Get(ctx, types.NamespacedName{
|
|
Name: resources.RESPAuthSecretName(engineName),
|
|
Namespace: nsName, // mirror lands in the engine's namespace, not the source's
|
|
}, got)).To(Succeed())
|
|
Expect(got.Data).To(HaveKeyWithValue("password", []byte("xns-pw")))
|
|
Expect(got.Data).NotTo(HaveKey("username"), "username should be absent when source omits it")
|
|
})
|
|
|
|
It("updates an existing managed copy when the source password changes", func() {
|
|
// First reconcile establishes the mirror; second reconcile
|
|
// after a source-side rotation must update the managed copy.
|
|
// This exercises the Patch path (separate from the Create
|
|
// path the previous spec covers).
|
|
engine := newEngine(nsName, engineName)
|
|
source := &corev1.Secret{
|
|
ObjectMeta: metav1.ObjectMeta{Name: "rotate-auth", Namespace: nsName},
|
|
Data: map[string][]byte{"password": []byte("v1")},
|
|
}
|
|
Expect(k8sClient.Create(ctx, source)).To(Succeed())
|
|
DeferCleanup(func() { _ = k8sClient.Delete(ctx, source) })
|
|
|
|
engine.Spec.L2Backend = &lmcachev1alpha1.L2BackendSpec{
|
|
RESP: &lmcachev1alpha1.RESPL2AdapterSpec{
|
|
Host: "redis", Port: 6379,
|
|
AuthSecretRef: &lmcachev1alpha1.SecretReference{Name: "rotate-auth"},
|
|
},
|
|
}
|
|
Expect(r.reconcileRESPAuthSecret(ctx, engine)).To(Succeed())
|
|
|
|
// Rotate the source.
|
|
Expect(k8sClient.Get(ctx, types.NamespacedName{Name: "rotate-auth", Namespace: nsName}, source)).To(Succeed())
|
|
source.Data["password"] = []byte("v2")
|
|
Expect(k8sClient.Update(ctx, source)).To(Succeed())
|
|
|
|
// Re-reconcile should propagate.
|
|
Expect(r.reconcileRESPAuthSecret(ctx, engine)).To(Succeed())
|
|
|
|
got := &corev1.Secret{}
|
|
Expect(k8sClient.Get(ctx, types.NamespacedName{
|
|
Name: resources.RESPAuthSecretName(engineName),
|
|
Namespace: nsName,
|
|
}, got)).To(Succeed())
|
|
Expect(got.Data).To(HaveKeyWithValue("password", []byte("v2")))
|
|
})
|
|
|
|
It("errors when the source secret is missing", func() {
|
|
engine := newEngine(nsName, engineName)
|
|
engine.Spec.L2Backend = &lmcachev1alpha1.L2BackendSpec{
|
|
RESP: &lmcachev1alpha1.RESPL2AdapterSpec{
|
|
Host: "redis", Port: 6379,
|
|
AuthSecretRef: &lmcachev1alpha1.SecretReference{Name: "does-not-exist"},
|
|
},
|
|
}
|
|
err := r.reconcileRESPAuthSecret(ctx, engine)
|
|
Expect(err).To(HaveOccurred())
|
|
Expect(err.Error()).To(ContainSubstring("does-not-exist"))
|
|
})
|
|
|
|
It("errors when the source secret is missing the password key", func() {
|
|
// Defensive against operator mis-configuration: a Secret
|
|
// pointed at by authSecretRef MUST have a "password" entry.
|
|
// If it doesn't, surface a clear error rather than write a
|
|
// broken managed copy.
|
|
engine := newEngine(nsName, engineName)
|
|
source := &corev1.Secret{
|
|
ObjectMeta: metav1.ObjectMeta{Name: "wrong-keys", Namespace: nsName},
|
|
// "password" missing; only "user" provided.
|
|
Data: map[string][]byte{"user": []byte("admin")},
|
|
}
|
|
Expect(k8sClient.Create(ctx, source)).To(Succeed())
|
|
DeferCleanup(func() { _ = k8sClient.Delete(ctx, source) })
|
|
|
|
engine.Spec.L2Backend = &lmcachev1alpha1.L2BackendSpec{
|
|
RESP: &lmcachev1alpha1.RESPL2AdapterSpec{
|
|
Host: "redis", Port: 6379,
|
|
AuthSecretRef: &lmcachev1alpha1.SecretReference{Name: "wrong-keys"},
|
|
},
|
|
}
|
|
err := r.reconcileRESPAuthSecret(ctx, engine)
|
|
Expect(err).To(HaveOccurred())
|
|
Expect(err.Error()).To(ContainSubstring("password"))
|
|
})
|
|
})
|
|
|
|
// ----- handleFinalizer ---------------------------------------------
|
|
|
|
var _ = Describe("handleFinalizer", func() {
|
|
var (
|
|
r *LMCacheEngineReconciler
|
|
nsName string
|
|
)
|
|
|
|
BeforeEach(func() {
|
|
r = newReconciler()
|
|
nsName = mustCreateNS(uniqueNS("finalizer"))
|
|
})
|
|
|
|
It("is a no-op on a fresh CR without the legacy finalizer", func() {
|
|
engine := newEngine(nsName, "fresh")
|
|
err, done := r.handleFinalizer(ctx, engine)
|
|
Expect(err).NotTo(HaveOccurred())
|
|
Expect(done).To(BeFalse(), "fresh CR should not short-circuit reconcile")
|
|
})
|
|
|
|
It("strips the legacy finalizer on the create/update path and requeues", func() {
|
|
// Old-operator CRs may still carry the lmcache.ai/cleanup
|
|
// finalizer. handleFinalizer must remove it so future
|
|
// deletes don't deadlock. Per the source comment, the
|
|
// function returns done=true after the strip so the caller
|
|
// re-fetches with the new resourceVersion.
|
|
engine := newEngine(nsName, "legacy")
|
|
engine.Finalizers = []string{finalizerName}
|
|
Expect(k8sClient.Update(ctx, engine)).To(Succeed())
|
|
// Re-fetch to pick up the resourceVersion bump.
|
|
Expect(k8sClient.Get(ctx, types.NamespacedName{Name: engine.Name, Namespace: nsName}, engine)).To(Succeed())
|
|
|
|
err, done := r.handleFinalizer(ctx, engine)
|
|
Expect(err).NotTo(HaveOccurred())
|
|
Expect(done).To(BeTrue(), "expected the caller to short-circuit after the migration")
|
|
|
|
// Confirm the finalizer is gone in the server's view.
|
|
out := &lmcachev1alpha1.LMCacheEngine{}
|
|
Expect(k8sClient.Get(ctx, types.NamespacedName{Name: engine.Name, Namespace: nsName}, out)).To(Succeed())
|
|
Expect(out.Finalizers).NotTo(ContainElement(finalizerName))
|
|
})
|
|
|
|
It("clears the legacy finalizer on the deletion path", func() {
|
|
// envtest's API server enforces the same "blocks deletion
|
|
// while finalizers exist" semantics as a real cluster. So
|
|
// Create with the legacy finalizer, Delete (which sets
|
|
// DeletionTimestamp but does not actually remove the
|
|
// object), then call handleFinalizer and verify the
|
|
// finalizer is cleared so the API server can finish the GC.
|
|
engine := newEngine(nsName, "deleting")
|
|
engine.Finalizers = []string{finalizerName}
|
|
Expect(k8sClient.Update(ctx, engine)).To(Succeed())
|
|
Expect(k8sClient.Delete(ctx, engine)).To(Succeed())
|
|
|
|
// Re-fetch — the object is still there (Terminating), with
|
|
// DeletionTimestamp set, because the finalizer blocks GC.
|
|
Expect(k8sClient.Get(ctx, types.NamespacedName{Name: engine.Name, Namespace: nsName}, engine)).To(Succeed())
|
|
Expect(engine.DeletionTimestamp).NotTo(BeNil())
|
|
|
|
err, done := r.handleFinalizer(ctx, engine)
|
|
Expect(err).NotTo(HaveOccurred())
|
|
Expect(done).To(BeTrue(), "on the deletion path the caller must short-circuit")
|
|
|
|
// After the finalizer is removed the API server reaps the
|
|
// object; the Get may either return NotFound or, if envtest
|
|
// is mid-flight, the object with an empty finalizer list.
|
|
// Either is acceptable.
|
|
out := &lmcachev1alpha1.LMCacheEngine{}
|
|
err = k8sClient.Get(ctx, types.NamespacedName{Name: engine.Name, Namespace: nsName}, out)
|
|
if err == nil {
|
|
Expect(out.Finalizers).NotTo(ContainElement(finalizerName))
|
|
} else {
|
|
Expect(apierrors.IsNotFound(err)).To(BeTrue())
|
|
}
|
|
})
|
|
|
|
It("is a no-op on the deletion path when no legacy finalizer is present", func() {
|
|
// Newer CRs that never had the legacy finalizer should still
|
|
// short-circuit the rest of reconcile on the deletion path,
|
|
// but without an Update call.
|
|
engine := newEngine(nsName, "del-clean")
|
|
Expect(k8sClient.Delete(ctx, engine)).To(Succeed())
|
|
|
|
// On envtest without finalizers, the object may already be
|
|
// gone after Delete. Re-fetch tolerantly.
|
|
err := k8sClient.Get(ctx, types.NamespacedName{Name: engine.Name, Namespace: nsName}, engine)
|
|
if apierrors.IsNotFound(err) {
|
|
Skip("envtest GC'd the object before we could run handleFinalizer; nothing to assert")
|
|
}
|
|
Expect(err).NotTo(HaveOccurred())
|
|
Expect(engine.DeletionTimestamp).NotTo(BeNil())
|
|
|
|
err, done := r.handleFinalizer(ctx, engine)
|
|
Expect(err).NotTo(HaveOccurred())
|
|
Expect(done).To(BeTrue())
|
|
})
|
|
})
|
|
|
|
// ----- validateAndSetCondition -------------------------------------
|
|
|
|
var _ = Describe("validateAndSetCondition", func() {
|
|
var (
|
|
r *LMCacheEngineReconciler
|
|
nsName string
|
|
)
|
|
|
|
BeforeEach(func() {
|
|
r = newReconciler()
|
|
nsName = mustCreateNS(uniqueNS("validate"))
|
|
})
|
|
|
|
It("returns nil and does NOT set ConfigValid for a valid spec", func() {
|
|
// Per the source comment, on the happy path the helper does
|
|
// NOT set ConfigValid=True itself — that happens later in
|
|
// updateStatus to avoid a resourceVersion conflict. So a
|
|
// valid spec leaves the condition list untouched here.
|
|
engine := newEngine(nsName, "good")
|
|
Expect(r.validateAndSetCondition(ctx, engine)).To(Succeed())
|
|
|
|
got := &lmcachev1alpha1.LMCacheEngine{}
|
|
Expect(k8sClient.Get(ctx, types.NamespacedName{Name: engine.Name, Namespace: nsName}, got)).To(Succeed())
|
|
Expect(meta.FindStatusCondition(got.Status.Conditions, lmcachev1alpha1.ConditionConfigValid)).To(BeNil())
|
|
})
|
|
|
|
It("sets ConfigValid=False with ValidationFailed reason on an invalid spec", func() {
|
|
// Build a CR that bypasses kubebuilder's CRD-level checks
|
|
// (otherwise the API server would reject it at admission)
|
|
// but trips ValidateSpec's L2-backend rule.
|
|
engine := newEngine(nsName, "bad")
|
|
Expect(k8sClient.Get(ctx, types.NamespacedName{Name: engine.Name, Namespace: nsName}, engine)).To(Succeed())
|
|
engine.Spec.L2Backend = &lmcachev1alpha1.L2BackendSpec{
|
|
// Both RESP and Raw set — ValidateSpec rejects this as
|
|
// "exactly one of resp or raw must be set, got multiple".
|
|
RESP: &lmcachev1alpha1.RESPL2AdapterSpec{Host: "redis", Port: 6379},
|
|
Raw: &lmcachev1alpha1.RawL2AdapterSpec{Type: "mock"},
|
|
}
|
|
Expect(k8sClient.Update(ctx, engine)).To(Succeed())
|
|
Expect(k8sClient.Get(ctx, types.NamespacedName{Name: engine.Name, Namespace: nsName}, engine)).To(Succeed())
|
|
|
|
err := r.validateAndSetCondition(ctx, engine)
|
|
Expect(err).To(HaveOccurred(), "expected validateAndSetCondition to return the validation error")
|
|
|
|
// Status condition must be set to False with the documented
|
|
// reason; phase moves to Failed.
|
|
got := &lmcachev1alpha1.LMCacheEngine{}
|
|
Expect(k8sClient.Get(ctx, types.NamespacedName{Name: engine.Name, Namespace: nsName}, got)).To(Succeed())
|
|
cond := meta.FindStatusCondition(got.Status.Conditions, lmcachev1alpha1.ConditionConfigValid)
|
|
Expect(cond).NotTo(BeNil())
|
|
Expect(cond.Status).To(Equal(metav1.ConditionFalse))
|
|
Expect(cond.Reason).To(Equal("ValidationFailed"))
|
|
Expect(got.Status.Phase).To(Equal(lmcachev1alpha1.PhaseFailed))
|
|
Expect(got.Status.ObservedGeneration).To(Equal(got.Generation))
|
|
})
|
|
})
|
|
|
|
// ----- reconcileServiceMonitor -------------------------------------
|
|
|
|
var _ = Describe("reconcileServiceMonitor", func() {
|
|
// NOTE: envtest in this suite does NOT install the
|
|
// monitoring.coreos.com ServiceMonitor CRD (CRDDirectoryPaths
|
|
// only points at config/crd/bases, which is just the
|
|
// LMCacheEngine CRD). So we exercise the two branches that
|
|
// don't require the CRD: (a) ServiceMonitor disabled and
|
|
// nothing to clean up, and (b) ServiceMonitor enabled but the
|
|
// CRD is absent (the meta.IsNoMatchError branch). The
|
|
// create / update / delete paths against a real ServiceMonitor
|
|
// are covered by the e2e suite (S-3 / ServiceMonitor spec).
|
|
var (
|
|
r *LMCacheEngineReconciler
|
|
nsName string
|
|
)
|
|
|
|
BeforeEach(func() {
|
|
r = newReconciler()
|
|
nsName = mustCreateNS(uniqueNS("smon"))
|
|
})
|
|
|
|
It("no-ops when ServiceMonitor is disabled and nothing exists", func() {
|
|
engine := newEngine(nsName, "no-mon")
|
|
// Default spec has prometheus.serviceMonitor.enabled = nil
|
|
// (i.e. disabled), so the helper enters the "delete if
|
|
// exists" branch, hits NotFound or NoMatch, and returns nil.
|
|
Expect(r.reconcileServiceMonitor(ctx, engine)).To(Succeed())
|
|
})
|
|
|
|
It("returns nil when ServiceMonitor is enabled but the CRD is absent", func() {
|
|
// On clusters without prometheus-operator the create path
|
|
// must short-circuit cleanly so the rest of reconcile
|
|
// continues. The CRD-absent case surfaces as
|
|
// meta.IsNoMatchError, which the helper swallows.
|
|
engine := newEngine(nsName, "enabled-no-crd")
|
|
Expect(k8sClient.Get(ctx, types.NamespacedName{Name: engine.Name, Namespace: nsName}, engine)).To(Succeed())
|
|
enabled := true
|
|
engine.Spec.Prometheus = &lmcachev1alpha1.PrometheusSpec{
|
|
Enabled: &enabled,
|
|
ServiceMonitor: &lmcachev1alpha1.ServiceMonitorSpec{
|
|
Enabled: &enabled,
|
|
},
|
|
}
|
|
Expect(k8sClient.Update(ctx, engine)).To(Succeed())
|
|
Expect(k8sClient.Get(ctx, types.NamespacedName{Name: engine.Name, Namespace: nsName}, engine)).To(Succeed())
|
|
|
|
Expect(r.reconcileServiceMonitor(ctx, engine)).To(Succeed())
|
|
})
|
|
})
|
|
|
|
// ----- resource-reconcile helpers (Create + Patch) -----------------
|
|
//
|
|
// All four functions (reconcileDaemonSet / reconcileLookupService /
|
|
// reconcileMetricsService / reconcileConnectionConfigMap) share the
|
|
// same shape:
|
|
//
|
|
// 1. Build desired via resources.BuildX(engine).
|
|
// 2. r.Get the existing — if NotFound, Create with ownerRef.
|
|
// 3. Otherwise Patch the subset of fields the helper owns.
|
|
//
|
|
// Each Describe below pairs a "create on first reconcile" spec with
|
|
// a "patch on second reconcile after a spec change" spec — that pair
|
|
// covers both branches and proves the diff actually propagates to
|
|
// the child resource (the helper isn't a no-op patch).
|
|
|
|
var _ = Describe("reconcileDaemonSet", func() {
|
|
var (
|
|
r *LMCacheEngineReconciler
|
|
nsName string
|
|
)
|
|
|
|
BeforeEach(func() {
|
|
r = newReconciler()
|
|
nsName = mustCreateNS(uniqueNS("ds"))
|
|
})
|
|
|
|
It("creates a DaemonSet on first reconcile", func() {
|
|
engine := newEngine(nsName, "ds-create")
|
|
Expect(r.reconcileDaemonSet(ctx, engine)).To(Succeed())
|
|
|
|
got := &appsv1.DaemonSet{}
|
|
Expect(k8sClient.Get(ctx, types.NamespacedName{Name: engine.Name, Namespace: nsName}, got)).To(Succeed())
|
|
Expect(metav1.IsControlledBy(got, engine)).To(BeTrue(), "DaemonSet must be owned by the engine")
|
|
Expect(got.Spec.Template.Spec.Containers).To(HaveLen(1))
|
|
// Default port flows through to the container args.
|
|
Expect(got.Spec.Template.Spec.Containers[0].Args).To(ContainElements("--port", "5555"))
|
|
})
|
|
|
|
It("patches the DaemonSet when spec.server.port changes", func() {
|
|
engine := newEngine(nsName, "ds-patch")
|
|
Expect(r.reconcileDaemonSet(ctx, engine)).To(Succeed())
|
|
|
|
// Mutate the local engine — reconcileDaemonSet works off the
|
|
// passed-in object, no need to round-trip through Update.
|
|
newPort := int32(6555)
|
|
engine.Spec.Server = &lmcachev1alpha1.ServerSpec{Port: &newPort}
|
|
Expect(r.reconcileDaemonSet(ctx, engine)).To(Succeed())
|
|
|
|
got := &appsv1.DaemonSet{}
|
|
Expect(k8sClient.Get(ctx, types.NamespacedName{Name: engine.Name, Namespace: nsName}, got)).To(Succeed())
|
|
Expect(got.Spec.Template.Spec.Containers[0].Args).To(ContainElements("--port", "6555"))
|
|
// Existing object's selector is preserved across the patch
|
|
// (DaemonSet selectors are immutable; reconcileDaemonSet
|
|
// copies it onto desired before patching).
|
|
Expect(got.Spec.Selector).NotTo(BeNil())
|
|
})
|
|
})
|
|
|
|
var _ = Describe("reconcileLookupService", func() {
|
|
var (
|
|
r *LMCacheEngineReconciler
|
|
nsName string
|
|
)
|
|
|
|
BeforeEach(func() {
|
|
r = newReconciler()
|
|
nsName = mustCreateNS(uniqueNS("svc-lookup"))
|
|
})
|
|
|
|
It("creates the lookup Service on first reconcile", func() {
|
|
engine := newEngine(nsName, "svc-create")
|
|
Expect(r.reconcileLookupService(ctx, engine)).To(Succeed())
|
|
|
|
got := &corev1.Service{}
|
|
Expect(k8sClient.Get(ctx, types.NamespacedName{Name: engine.Name, Namespace: nsName}, got)).To(Succeed())
|
|
Expect(metav1.IsControlledBy(got, engine)).To(BeTrue())
|
|
Expect(got.Spec.InternalTrafficPolicy).NotTo(BeNil())
|
|
Expect(*got.Spec.InternalTrafficPolicy).To(Equal(corev1.ServiceInternalTrafficPolicyLocal))
|
|
// Default port is exposed under a port named "server".
|
|
hasServerPort := false
|
|
for _, p := range got.Spec.Ports {
|
|
if p.Name == "server" && p.Port == 5555 {
|
|
hasServerPort = true
|
|
}
|
|
}
|
|
Expect(hasServerPort).To(BeTrue(), "expected a 'server' port at 5555, got %+v", got.Spec.Ports)
|
|
})
|
|
|
|
It("patches the lookup Service when spec.server.port changes", func() {
|
|
engine := newEngine(nsName, "svc-patch")
|
|
Expect(r.reconcileLookupService(ctx, engine)).To(Succeed())
|
|
|
|
newPort := int32(7777)
|
|
engine.Spec.Server = &lmcachev1alpha1.ServerSpec{Port: &newPort}
|
|
Expect(r.reconcileLookupService(ctx, engine)).To(Succeed())
|
|
|
|
got := &corev1.Service{}
|
|
Expect(k8sClient.Get(ctx, types.NamespacedName{Name: engine.Name, Namespace: nsName}, got)).To(Succeed())
|
|
hasNewPort := false
|
|
for _, p := range got.Spec.Ports {
|
|
if p.Name == "server" && p.Port == newPort {
|
|
hasNewPort = true
|
|
}
|
|
}
|
|
Expect(hasNewPort).To(BeTrue(), "expected 'server' port to reflect the new value, got %+v", got.Spec.Ports)
|
|
})
|
|
})
|
|
|
|
var _ = Describe("reconcileMetricsService", func() {
|
|
var (
|
|
r *LMCacheEngineReconciler
|
|
nsName string
|
|
)
|
|
|
|
BeforeEach(func() {
|
|
r = newReconciler()
|
|
nsName = mustCreateNS(uniqueNS("svc-metrics"))
|
|
})
|
|
|
|
It("creates the metrics Service on first reconcile", func() {
|
|
engine := newEngine(nsName, "metrics-create")
|
|
Expect(r.reconcileMetricsService(ctx, engine)).To(Succeed())
|
|
|
|
// resources.BuildMetricsService names the service
|
|
// "<engine>-metrics" (see internal/resources/service.go).
|
|
got := &corev1.Service{}
|
|
Expect(k8sClient.Get(ctx, types.NamespacedName{
|
|
Name: engine.Name + "-metrics",
|
|
Namespace: nsName,
|
|
}, got)).To(Succeed())
|
|
Expect(metav1.IsControlledBy(got, engine)).To(BeTrue())
|
|
hasMetricsPort := false
|
|
for _, p := range got.Spec.Ports {
|
|
if p.Port == 9090 {
|
|
hasMetricsPort = true
|
|
}
|
|
}
|
|
Expect(hasMetricsPort).To(BeTrue(), "expected port 9090, got %+v", got.Spec.Ports)
|
|
})
|
|
|
|
It("patches the metrics Service when spec.prometheus.port changes", func() {
|
|
engine := newEngine(nsName, "metrics-patch")
|
|
Expect(r.reconcileMetricsService(ctx, engine)).To(Succeed())
|
|
|
|
newPort := int32(9191)
|
|
engine.Spec.Prometheus = &lmcachev1alpha1.PrometheusSpec{Port: &newPort}
|
|
Expect(r.reconcileMetricsService(ctx, engine)).To(Succeed())
|
|
|
|
got := &corev1.Service{}
|
|
Expect(k8sClient.Get(ctx, types.NamespacedName{Name: engine.Name + "-metrics", Namespace: nsName}, got)).To(Succeed())
|
|
hasNewPort := false
|
|
for _, p := range got.Spec.Ports {
|
|
if p.Port == newPort {
|
|
hasNewPort = true
|
|
}
|
|
}
|
|
Expect(hasNewPort).To(BeTrue(), "expected port %d, got %+v", newPort, got.Spec.Ports)
|
|
})
|
|
})
|
|
|
|
var _ = Describe("reconcileConnectionConfigMap", func() {
|
|
var (
|
|
r *LMCacheEngineReconciler
|
|
nsName string
|
|
)
|
|
|
|
BeforeEach(func() {
|
|
r = newReconciler()
|
|
nsName = mustCreateNS(uniqueNS("cm"))
|
|
})
|
|
|
|
It("creates the connection ConfigMap on first reconcile", func() {
|
|
engine := newEngine(nsName, "cm-create")
|
|
Expect(r.reconcileConnectionConfigMap(ctx, engine)).To(Succeed())
|
|
|
|
got := &corev1.ConfigMap{}
|
|
Expect(k8sClient.Get(ctx, types.NamespacedName{
|
|
Name: engine.Name + "-connection",
|
|
Namespace: nsName,
|
|
}, got)).To(Succeed())
|
|
Expect(metav1.IsControlledBy(got, engine)).To(BeTrue())
|
|
Expect(got.Data).To(HaveKey("kv-transfer-config.json"))
|
|
// Default port (5555) shows up in the JSON blob.
|
|
Expect(got.Data["kv-transfer-config.json"]).To(ContainSubstring("5555"))
|
|
})
|
|
|
|
It("patches the ConfigMap when spec.server.port changes", func() {
|
|
engine := newEngine(nsName, "cm-patch")
|
|
Expect(r.reconcileConnectionConfigMap(ctx, engine)).To(Succeed())
|
|
|
|
newPort := int32(6555)
|
|
engine.Spec.Server = &lmcachev1alpha1.ServerSpec{Port: &newPort}
|
|
Expect(r.reconcileConnectionConfigMap(ctx, engine)).To(Succeed())
|
|
|
|
got := &corev1.ConfigMap{}
|
|
Expect(k8sClient.Get(ctx, types.NamespacedName{
|
|
Name: engine.Name + "-connection",
|
|
Namespace: nsName,
|
|
}, got)).To(Succeed())
|
|
Expect(got.Data["kv-transfer-config.json"]).To(ContainSubstring("6555"))
|
|
Expect(got.Data["kv-transfer-config.json"]).NotTo(ContainSubstring("\"5555\""),
|
|
"old port should be replaced, not appended")
|
|
})
|
|
})
|