diff --git a/api/v1alpha1/cachebackend_effective.go b/api/v1alpha1/cachebackend_effective.go index 92c49745..525be419 100644 --- a/api/v1alpha1/cachebackend_effective.go +++ b/api/v1alpha1/cachebackend_effective.go @@ -1,126 +1,41 @@ package v1alpha1 -import ( - "strings" +import metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" -) - -// UsesCanonicalCacheHierarchy reports whether the resource uses the separated -// runtime/cache/storage API. Legacy resources are detected by the absence of -// these fields and retain their historical implicit provider mapping. -func (s *CacheBackendSpec) UsesCanonicalCacheHierarchy() bool { - return s.Runtime != "" || s.LMCache != nil || s.RemoteStorage != nil -} - -// EffectiveRuntime returns the canonical inference runtime while preserving -// integration.engine as a read-time compatibility input. +// EffectiveRuntime returns the configured inference runtime. func (s *CacheBackendSpec) EffectiveRuntime() CacheBackendRuntime { - if s.Runtime != "" { - return normalizeRuntime(s.Runtime) - } - if s.Integration != nil { - if runtime := normalizeRuntime(CacheBackendRuntime(s.Integration.Engine)); runtime != "" { - return runtime - } - } - return CacheBackendRuntimeVLLM + return s.Runtime } -// EffectiveCacheType returns the engine-side cache implementation. Legacy -// Mooncake and External values represented remote-provider concerns in -// spec.type; both use LMCache engine wiring and normalize to LMCache here. +// EffectiveCacheType returns the engine-side cache implementation, defaulting +// an omitted value to LMCache for callers that do not pass through admission. func (s *CacheBackendSpec) EffectiveCacheType() CacheBackendType { - switch s.Type { - case CacheBackendTypeMooncake, CacheBackendTypeExternal: - return CacheBackendTypeLMCache - case "": + if s.Type == "" { return CacheBackendTypeLMCache - default: - return s.Type } + return s.Type } -// EffectiveRemoteStorage returns the explicit remote-storage declaration, or a -// synthesized declaration for a legacy resource. In the canonical API a nil -// remoteStorage remains nil: host-only caching must never select a provider as -// an adapter side effect. +// EffectiveRemoteStorage returns the explicit remote-storage declaration. A +// nil remoteStorage remains nil: host-only caching must never select a provider +// as an adapter side effect. func (s *CacheBackendSpec) EffectiveRemoteStorage() *CacheBackendRemoteStorageSpec { - if s.RemoteStorage != nil { - return s.RemoteStorage - } - if s.UsesCanonicalCacheHierarchy() { - return nil - } - - switch s.Type { - case CacheBackendTypeExternal: - return &CacheBackendRemoteStorageSpec{ - Provider: CacheBackendRemoteStorageProviderLMCacheServer, - Ownership: CacheBackendRemoteStorageOwnershipExternal, - Endpoint: s.Endpoint, - } - case CacheBackendTypeMooncake: - return &CacheBackendRemoteStorageSpec{ - Provider: CacheBackendRemoteStorageProviderMooncake, - Ownership: CacheBackendRemoteStorageOwnershipManaged, - Mooncake: &MooncakeRemoteStorageSpec{ - Image: s.BackendConfig["serverImage"], - Resources: s.Resources, - }, - } - case CacheBackendTypeLMCache, "": - if s.EffectiveRuntime() == CacheBackendRuntimeSGLang { - return &CacheBackendRemoteStorageSpec{ - Provider: CacheBackendRemoteStorageProviderRedis, - Ownership: CacheBackendRemoteStorageOwnershipManaged, - Redis: &RedisRemoteStorageSpec{ - Image: s.BackendConfig["redisImage"], - Resources: s.Resources, - }, - } - } - return &CacheBackendRemoteStorageSpec{ - Provider: CacheBackendRemoteStorageProviderLMCacheServer, - Ownership: CacheBackendRemoteStorageOwnershipManaged, - LMCacheServer: &LMCacheServerRemoteStorageSpec{ - Image: s.BackendConfig["serverImage"], - Resources: s.Resources, - }, - } - default: - return nil - } + return s.RemoteStorage } // EffectiveObservationModelID returns the independently-owned observation -// model id while retaining backendConfig.model for legacy resources. +// model id. func (s *CacheBackendSpec) EffectiveObservationModelID() string { if s.Observation != nil { return s.Observation.ModelID } - return s.BackendConfig["model"] + return "" } -// EffectiveFirstEventTimeout returns the observation-owned timeout, falling -// back to the deprecated integration field for compatibility. +// EffectiveFirstEventTimeout returns the observation-owned timeout. func (s *CacheBackendSpec) EffectiveFirstEventTimeout() *metav1.Duration { if s.Observation != nil && s.Observation.FirstEventTimeout != nil { return s.Observation.FirstEventTimeout } - if s.Integration != nil { - return s.Integration.FirstEventTimeout - } return nil } - -func normalizeRuntime(value CacheBackendRuntime) CacheBackendRuntime { - switch strings.ToLower(string(value)) { - case "vllm": - return CacheBackendRuntimeVLLM - case "sglang": - return CacheBackendRuntimeSGLang - default: - return value - } -} diff --git a/api/v1alpha1/cachebackend_effective_test.go b/api/v1alpha1/cachebackend_effective_test.go index 36d6ac7f..05433287 100644 --- a/api/v1alpha1/cachebackend_effective_test.go +++ b/api/v1alpha1/cachebackend_effective_test.go @@ -2,68 +2,35 @@ package v1alpha1 import "testing" -func TestEffectiveRemoteStorageSeparatesCanonicalAndLegacyHierarchy(t *testing.T) { - t.Run("canonical omission is host-only", func(t *testing.T) { - spec := CacheBackendSpec{ - Runtime: CacheBackendRuntimeSGLang, - Type: CacheBackendTypeLMCache, - } - if got := spec.EffectiveRemoteStorage(); got != nil { - t.Fatalf("EffectiveRemoteStorage() = %+v, want nil", got) - } - }) - - t.Run("legacy sglang lmcache keeps redis compatibility", func(t *testing.T) { - spec := CacheBackendSpec{ - Type: CacheBackendTypeLMCache, - Integration: &CacheBackendIntegrationSpec{ - Engine: "sglang", - }, - } - got := spec.EffectiveRemoteStorage() - if got == nil || - got.Provider != CacheBackendRemoteStorageProviderRedis || - got.Ownership != CacheBackendRemoteStorageOwnershipManaged { - t.Fatalf("EffectiveRemoteStorage() = %+v, want Managed Redis", got) - } - }) +func TestEffectiveRemoteStorageUsesOnlyExplicitDeclaration(t *testing.T) { + spec := CacheBackendSpec{Type: CacheBackendTypeLMCache} + if got := spec.EffectiveRemoteStorage(); got != nil { + t.Fatalf("EffectiveRemoteStorage() = %+v, want nil", got) + } - t.Run("legacy mooncake normalizes engine cache separately", func(t *testing.T) { - spec := CacheBackendSpec{Type: CacheBackendTypeMooncake} - if got := spec.EffectiveCacheType(); got != CacheBackendTypeLMCache { - t.Fatalf("EffectiveCacheType() = %q, want LMCache", got) - } - storage := spec.EffectiveRemoteStorage() - if storage == nil || storage.Provider != CacheBackendRemoteStorageProviderMooncake { - t.Fatalf("EffectiveRemoteStorage() = %+v, want Mooncake", storage) - } - }) + want := &CacheBackendRemoteStorageSpec{ + Provider: CacheBackendRemoteStorageProviderMooncake, + Ownership: CacheBackendRemoteStorageOwnershipManaged, + } + spec.RemoteStorage = want + if got := spec.EffectiveRemoteStorage(); got != want { + t.Fatalf("EffectiveRemoteStorage() = %+v, want explicit declaration %+v", got, want) + } } -func TestEffectiveRuntimePrefersCanonicalField(t *testing.T) { - spec := CacheBackendSpec{ - Runtime: CacheBackendRuntimeSGLang, - Integration: &CacheBackendIntegrationSpec{ - Engine: "vllm", - }, - } +func TestEffectiveRuntimeReturnsConfiguredField(t *testing.T) { + spec := CacheBackendSpec{Runtime: CacheBackendRuntimeSGLang} if got := spec.EffectiveRuntime(); got != CacheBackendRuntimeSGLang { t.Fatalf("EffectiveRuntime() = %q, want SGLang", got) } } -func TestObservationDoesNotSelectCanonicalHierarchy(t *testing.T) { +func TestObservationDoesNotSynthesizeRemoteStorage(t *testing.T) { spec := CacheBackendSpec{ Type: CacheBackendTypeLMCache, Observation: &CacheBackendObservationSpec{ModelID: "model-a"}, } - if spec.UsesCanonicalCacheHierarchy() { - t.Fatal("typed observation must remain independent from cache/provider hierarchy selection") - } - got := spec.EffectiveRemoteStorage() - if got == nil || - got.Provider != CacheBackendRemoteStorageProviderLMCacheServer || - got.Ownership != CacheBackendRemoteStorageOwnershipManaged { - t.Fatalf("EffectiveRemoteStorage() = %+v, want legacy Managed LMCacheServer", got) + if got := spec.EffectiveRemoteStorage(); got != nil { + t.Fatalf("EffectiveRemoteStorage() = %+v, want nil", got) } } diff --git a/api/v1alpha1/cachebackend_types.go b/api/v1alpha1/cachebackend_types.go index cb47dd4e..24d0ee96 100644 --- a/api/v1alpha1/cachebackend_types.go +++ b/api/v1alpha1/cachebackend_types.go @@ -18,16 +18,14 @@ const ( CacheBackendRuntimeSGLang CacheBackendRuntime = "SGLang" ) +// +kubebuilder:validation:Enum=LMCache;SGLangHiCache + // CacheBackendType identifies the backing cache implementation. type CacheBackendType string const ( CacheBackendTypeLMCache CacheBackendType = "LMCache" CacheBackendTypeSGLangHiCache CacheBackendType = "SGLangHiCache" - CacheBackendTypeAIBrix CacheBackendType = "AIBrix" - CacheBackendTypeMooncake CacheBackendType = "Mooncake" - CacheBackendTypeNIXL CacheBackendType = "NIXL" - CacheBackendTypeExternal CacheBackendType = "External" ) // +kubebuilder:validation:Enum=Redis;LMCacheServer;Mooncake @@ -286,6 +284,7 @@ type CacheBackendObservationSpec struct { // FirstEventTimeout bounds how long readiness waits for the first KV event. // +optional + // +kubebuilder:default="5m" FirstEventTimeout *metav1.Duration `json:"firstEventTimeout,omitempty"` } @@ -294,20 +293,14 @@ type CacheBackendObservationSpec struct { // The autoscaling spec (spec.autoscaling) is reconciled into a // HorizontalPodAutoscaler for managed backends. type CacheBackendSpec struct { - // Runtime identifies the inference runtime. New resources should use this - // field; integration.engine remains as a deprecated compatibility input. - // Values are case-sensitive: use VLLM or SGLang. Lowercase normalization - // applies only to the deprecated integration.engine field. - // +optional - Runtime CacheBackendRuntime `json:"runtime,omitempty"` + // Runtime identifies the inference runtime. Values are case-sensitive: use + // VLLM or SGLang. + Runtime CacheBackendRuntime `json:"runtime"` // Type identifies the engine-side cache implementation and defaults to - // LMCache. Canonical resources select provider technology and ownership - // independently through remoteStorage; omitting remoteStorage requests a - // host-only hierarchy. Legacy Mooncake and External values remain readable - // as compatibility inputs. The CRD does not constrain Type to an enum - // today; admission is the authoritative reject for unsupported pairs and - // for legacy provider values used in canonical resources. + // LMCache. Supported values are LMCache and SGLangHiCache. Provider + // technology and ownership are selected independently through remoteStorage; + // omitting remoteStorage requests a host-only hierarchy. // +optional // +kubebuilder:default=LMCache Type CacheBackendType `json:"type,omitempty"` @@ -417,99 +410,10 @@ type CacheBackendSpec struct { // +optional HiCache *SGLangHiCacheSpec `json:"hiCache,omitempty"` - // BackendConfig contains deprecated compatibility settings. Canonical - // resources use the typed LMCache, RemoteStorage, and Observation blocks. - // +optional - BackendConfig map[string]string `json:"backendConfig,omitempty"` - // Template provides pod-level overrides for managed backend workloads. // +optional Template *CacheBackendPodSpecOverride `json:"template,omitempty"` - // Resources are the deprecated compatibility resources requested + limited - // on a legacy managed backend workload. Canonical resources configure this - // under remoteStorage..resources. The provider adapter passes - // the admitted Requests/Limits maps through to Container.Resources; - // the mutating webhook stamps a conservative 4Gi request / 8Gi memory - // limit on the legacy minimal-YAML path (when the field is OMITTED) so - // the cache server is bounded by the cgroup rather than - // node-pressure OOM-killed by the kubelet under heavy T2 write load — - // a cache-stress benchmark against an unlimited lmcache-server - // repeatedly OOM-killed the pod within minutes of T2 traffic, which - // the default limit eliminates. Operators tune per-deployment by - // overriding the field; an explicit empty `spec.resources: {}` is - // honored as suppression of the webhook-stamped memory request/limit - // (no memory request, no memory limit rendered). When spec.autoscaling - // is set the runtime adapter still fills in a CPU request fallback - // (the HPA-utilization denominator) on top of the empty struct — - // that fallback is orthogonal to the memory default this field - // controls. - // - // Admission narrows the surface relative to the upstream - // ResourceRequirements shape: a non-empty `resources.claims` slice - // is rejected (the runtime adapter does not yet plumb pod-level - // `spec.resourceClaims`); strictly-negative `requests` / `limits` - // quantities are rejected (the CRD schema admits a leading "-" but - // the kubelet would later reject the pod); `requests` / `limits` - // keys that are not valid container resource names are rejected - // (standard names cpu/memory/ephemeral-storage admit; a - // `hugepages-` name admits only when the size suffix parses - // as a strictly-positive quantity, e.g. "hugepages-2Mi"; any other - // name must be third-party vendor-prefixed like "nvidia.com/gpu" — - // the K8s-reserved `kubernetes.io/` and `requests.kubernetes.io/` - // prefixes are rejected); and the request/limit relationship is - // resource-aware — overcommittable resources (cpu, memory, - // ephemeral-storage) admit `limits[X] >= requests[X]`, while - // non-overcommittable resources (hugepages-*, vendor-prefixed - // extended resources) require `limits[X] == requests[X]` when both - // are set. Vendor-prefixed extended-resource quantities (e.g. - // nvidia.com/gpu) must be integer values — K8s allocates extended - // resources by whole units. See - // docs/design/cachebackend-api.md#resources for the full validator - // table. - // - // When spec.autoscaling is set, the adapter additionally fills in a - // CPU request fallback (250m) if this field omits one — a - // CPU-utilization HPA needs a *positive* CPU request as its - // denominator. The fallback never overwrites a positive - // operator-supplied value; a non-positive value (e.g. - // `requests.cpu: "0"`, which the admission validator admits as a - // valid kubelet shape for non-autoscaled pods) is treated as - // absent and replaced, because the HPA cannot use 0 as a - // denominator. - // - // This deprecated field is accepted only by legacy resource shapes. - // Canonical resources must configure resources under the selected - // remoteStorage provider; admission rejects this field when runtime, - // lmCache, or remoteStorage selects the canonical API. - // External and SGLangHiCache legacy backends provision no workload of - // their own, so the field remains inert for those legacy types. - // - // +optional - Resources *corev1.ResourceRequirements `json:"resources,omitempty"` - - // Endpoint is the operator-supplied network address for an - // External backend the controller does NOT provision. The field - // is type-scoped: it is REQUIRED when spec.type is External and - // REJECTED at admission for every other type (managed backends - // learn their endpoint from the controller-rendered Service and - // would silently overwrite a user-supplied value, so admission - // surfaces the misconfiguration loudly at write time). - // - // Allowed shapes for External (both forms require a non-empty - // port — the LMCache connector dials TCP, so admission rejects - // portless hosts): - // - bare host:port (canonical; the LMCache engine adapter - // prepends the lm:// scheme on injection) - // - lm://host:port (operators who prefer to be explicit) - // IPv6 literals must be bracketed: [::1]:8200. Other schemes - // (https://, http://, ...) and path/query/fragment components - // are rejected at admission — they would produce an invalid - // LMCACHE_REMOTE_URL when concatenated with the lm:// prefix at - // injection time. - // +optional - Endpoint string `json:"endpoint,omitempty"` - // AllowCrossNamespace opts the CacheBackend into referencing an Endpoint // that resolves into a Kubernetes Service in a different namespace from // this object. Without this opt-in admission rejects such Endpoints, @@ -563,19 +467,6 @@ type CacheBackendAutoscalingSpec struct { // CachePolicy.spec.lookupTimeoutMs and CachePolicy.spec.minimumPrefixTokens, // which are the surfaces actually wired into the server's ResolvedPolicy. type CacheBackendIntegrationSpec struct { - // Engine is the deprecated inference-runtime identity retained for legacy - // manifests. The mutating webhook derives it from spec.runtime for - // canonical resources and defaults it to vllm for legacy resources. Both - // vllm and sglang have shipping adapters - // (vllm+LMCache and sglang+LMCache); the supported (engine, type) pairs - // are whatever the installed runtime adapters accept, and admission lists - // them in its rejection message for an unsupported pair. Keeping this - // default in the webhook instead of the CRD schema prevents a canonical - // runtime=SGLang object with a partially populated integration block from - // being schema-defaulted into a conflicting engine=vllm value. - // +optional - Engine string `json:"engine,omitempty"` - // Mode selects which cache tiers the engine is wired for. Defaults to // Offload — cache-aware routing (tier-1) PLUS the KV-offload connector // (tier-2), with a controller-provisioned backend server. EventsOnly wires @@ -596,10 +487,9 @@ type CacheBackendIntegrationSpec struct { Mode CacheBackendIntegrationMode `json:"mode,omitempty"` // Role controls whether the engine reads from, writes to, or fully - // participates in the cache. Defaults to ReadWrite — full participation, - // matching the [enginewire.IntegrationRole] read-time fallback for an - // omitted integration block. ReadOnly / WriteOnly are specialised - // producer/consumer roles operators opt into explicitly. + // participates in the cache. Defaults to ReadWrite — full participation. + // ReadOnly / WriteOnly are specialised producer/consumer roles operators + // opt into explicitly. // // Engine support is per-adapter: vLLM maps the role onto its LMCache // connector's kv_role (ReadOnly→kv_consumer, WriteOnly→kv_producer, @@ -611,52 +501,6 @@ type CacheBackendIntegrationSpec struct { // +kubebuilder:default=ReadWrite Role CacheBackendIntegrationRole `json:"role,omitempty"` - // FirstEventTimeout bounds how long a backend may sit - // Ready=False with reason AwaitingFirstKVEvent — the backend is up (a - // managed workload is Available, or an events-only backend is wired) but no - // KV event has been observed yet — before the controller flips it to - // Ready=False/Degraded=True with reason NoKVEventsObserved. It applies to - // both Offload-managed and EventsOnly backends. - // - // The KV-event readiness gate holds Ready until at least one KV event - // has been observed for this backend's replicas - // (status.indexParticipation.lastEventAt, projected from engine-pod - // reports). That proves the engine's ZMQ KV-event publisher is actually - // publishing — not merely that the managed workload rolled out. An engine - // can be serving HTTP while its publisher is silent (mis-configured - // --kv-events-config, ZMQ bind failure, in-process publisher crash), or no - // engine pods may be attached to the backend at all; either way the cache - // plane silently degrades to NO_HINT on every lookup, and this gate makes - // that loud. - // - // The timeout clock starts when the backend becomes "up": for an - // Offload-managed backend, when its workload first reports Available; for an - // events-only backend (which provisions no workload), on the first reconcile - // it is wired — including a re-anchor to the flip moment when a backend - // transitions into events-only from a server-bearing mode. The gate is on by - // default and opt-out per CacheBackend via the annotation - // inferencecache.io/require-kv-events: "false". Backends of spec.type - // External are always exempt (their readiness is determined by admission - // accepting the endpoint, and they never enter this gate). - // - // A zero or negative value is treated as unset and falls back to the 5m - // default — the field carries no meaningful "wait forever" or "fail - // immediately" semantics. - // - // The first SGLangHiCache implementation publishes no Ready condition, so - // this field is inert for that engine-local backend until its separate - // readiness contract is implemented. - // - // The value is a Go duration string (e.g. "90s", "5m", "1h"). The CRD - // schema types it as a string; a malformed value is rejected when - // admission decodes the object into this typed field, and if admission is - // bypassed the controller's typed read fails loudly (it never silently - // mis-parses). This matches how the API treats every metav1.Duration - // field; no extra CRD-level format constraint is imposed. - // +optional - // +kubebuilder:default="5m" - FirstEventTimeout *metav1.Duration `json:"firstEventTimeout,omitempty"` - // FailOpen controls whether the engine treats cache lookups as a soft // dependency. When true (the default), an unreachable or degraded cache // backend MUST fall back to local prefill and never fail a serving diff --git a/api/v1alpha1/cachebackend_types_test.go b/api/v1alpha1/cachebackend_types_test.go index 26c6d4ec..9b7650c7 100644 --- a/api/v1alpha1/cachebackend_types_test.go +++ b/api/v1alpha1/cachebackend_types_test.go @@ -33,15 +33,17 @@ func TestCacheBackendCRDSchemaFieldsAndEnums(t *testing.T) { "integration", "engineSelector", "hiCache", - "backendConfig", "template", - "endpoint", "allowCrossNamespace", } { if !hasProperty(specSchema, field) { t.Fatalf("spec.%s is missing from CRD schema", field) } } + requireNoProperty(t, specSchema, "endpoint") + requireNoProperty(t, specSchema, "backendConfig") + requireNoProperty(t, specSchema, "resources") + requireRequired(t, specSchema, "runtime") // indexEntries was removed in #57 (it duplicated status.indexParticipation.prefixCount); // health was removed in an earlier change; capacity is removed in this PR. @@ -69,7 +71,7 @@ func TestCacheBackendCRDSchemaFieldsAndEnums(t *testing.T) { t.Fatalf("status.capacity is present in CRD schema; it was retired alongside spec.storage") } - requireNoEnum(t, mustProperty(t, specSchema, "type")) + requireEnum(t, mustProperty(t, specSchema, "type"), []string{"LMCache", "SGLangHiCache"}) requireEnum(t, mustProperty(t, specSchema, "runtime"), []string{"VLLM", "SGLang"}) requireEnum(t, mustProperty(t, specSchema, "deploymentKind"), []string{ "Deployment", @@ -141,9 +143,9 @@ func TestCacheBackendCRDSchemaFieldsAndEnums(t *testing.T) { "page_first_kv_split", "page_head", }) - firstEventTimeoutSchema := mustPath[map[string]any](t, integrationSchema, "properties", "firstEventTimeout") + firstEventTimeoutSchema := mustPath[map[string]any](t, observationSchema, "properties", "firstEventTimeout") if got, ok := firstEventTimeoutSchema["default"].(string); !ok || got != "5m" { - t.Fatalf("integration.firstEventTimeout default = %v, want \"5m\"", firstEventTimeoutSchema["default"]) + t.Fatalf("observation.firstEventTimeout default = %v, want \"5m\"", firstEventTimeoutSchema["default"]) } requireMinimum(t, mustProperty(t, templateSchema, "terminationGracePeriodSeconds"), 0) @@ -160,12 +162,8 @@ func TestCacheBackendCRDSchemaFieldsAndEnums(t *testing.T) { if got, ok := mustProperty(t, specSchema, "replicas")["default"]; !ok || !reflect.DeepEqual(got, float64(1)) { t.Fatalf("spec.replicas default = %v (type %T), want 1", mustProperty(t, specSchema, "replicas")["default"], mustProperty(t, specSchema, "replicas")["default"]) } - if got, ok := mustProperty(t, integrationSchema, "engine")["default"]; ok { - t.Fatalf("spec.integration.engine has schema default %v; runtime-aware defaulting belongs to the webhook", got) - } - if got, ok := mustProperty(t, specSchema, "resources")["default"]; ok { - t.Fatalf("spec.resources has schema default %v; the legacy-only default belongs to the webhook", got) - } + requireNoProperty(t, integrationSchema, "engine") + requireNoProperty(t, integrationSchema, "firstEventTimeout") if got, ok := mustProperty(t, integrationSchema, "role")["default"].(string); !ok || got != "ReadWrite" { t.Fatalf("spec.integration.role default = %v, want \"ReadWrite\"", mustProperty(t, integrationSchema, "role")["default"]) } @@ -232,7 +230,6 @@ func TestCacheBackendDeepCopyCopiesNestedFields(t *testing.T) { hitRate := "0.50" t2HitRate := "0.66" matchedEnginePods := int32(7) - firstEventTimeout := metav1.Duration{Duration: 5 * time.Minute} firstKVEventAt := metav1.NewTime(time.Unix(1_700_000_000, 0).UTC()) firstAvailableAt := metav1.NewTime(time.Unix(1_700_000_500, 0).UTC()) runAsNonRoot := true @@ -278,9 +275,7 @@ func TestCacheBackendDeepCopyCopiesNestedFields(t *testing.T) { TargetCPUUtilizationPercent: &autoscalingTargetCPU, }, Integration: &CacheBackendIntegrationSpec{ - Engine: "SGLang", - Role: CacheBackendIntegrationRoleReadWrite, - FirstEventTimeout: &firstEventTimeout, + Role: CacheBackendIntegrationRoleReadWrite, }, EngineSelector: &CacheBackendEngineSelector{ MatchLabels: map[string]string{"inferencecache.io/cache-enabled": "true"}, @@ -289,7 +284,6 @@ func TestCacheBackendDeepCopyCopiesNestedFields(t *testing.T) { SizeGB: &hiCacheSize, WritePolicy: SGLangHiCacheWriteThrough, }, - BackendConfig: map[string]string{"evictionPolicy": "LRU"}, Template: &CacheBackendPodSpecOverride{ NodeSelector: map[string]string{"pool": "cache"}, Tolerations: []corev1.Toleration{{ @@ -302,7 +296,6 @@ func TestCacheBackendDeepCopyCopiesNestedFields(t *testing.T) { RuntimeClassName: &runtimeClassName, TerminationGracePeriodSeconds: &terminationGracePeriodSeconds, }, - Endpoint: "external-cache.default.svc:8080", }, Status: CacheBackendStatus{ Endpoint: "cache.default.svc:8080", @@ -338,9 +331,7 @@ func TestCacheBackendDeepCopyCopiesNestedFields(t *testing.T) { backend.Spec.RemoteStorage.LMCacheServer.Resources.Limits[corev1.ResourceMemory] = resource.MustParse("4Gi") backend.Spec.Observation.ModelID = "changed" backend.Spec.Observation.FirstEventTimeout.Duration = time.Hour - backend.Spec.Integration.FirstEventTimeout.Duration = time.Hour *backend.Spec.HiCache.SizeGB = 128 - backend.Spec.BackendConfig["evictionPolicy"] = "FIFO" backend.Spec.EngineSelector.MatchLabels["inferencecache.io/cache-enabled"] = "false" backend.Spec.Template.NodeSelector["pool"] = "general" backend.Spec.Template.Tolerations[0].Key = "general" @@ -399,15 +390,9 @@ func TestCacheBackendDeepCopyCopiesNestedFields(t *testing.T) { if copied.Spec.Integration == nil { t.Fatalf("integration was not deep-copied") } - if copied.Spec.Integration.Engine != "SGLang" { - t.Fatalf("integration.engine was not deep-copied") - } if copied.Spec.HiCache == nil || copied.Spec.HiCache.SizeGB == nil || *copied.Spec.HiCache.SizeGB != 64 { t.Fatalf("hiCache.sizeGB was not deep-copied") } - if copied.Spec.BackendConfig["evictionPolicy"] != "LRU" { - t.Fatalf("backendConfig was not deep-copied") - } if copied.Spec.EngineSelector == nil { t.Fatalf("engineSelector was not deep-copied") } @@ -453,9 +438,6 @@ func TestCacheBackendDeepCopyCopiesNestedFields(t *testing.T) { if copied.Status.EngineSelectorMessage != "spec.engineSelector.matchLabels={app:engine}; no Pods in namespace match" { t.Fatalf("status.engineSelectorMessage was not deep-copied") } - if copied.Spec.Integration.FirstEventTimeout == nil || copied.Spec.Integration.FirstEventTimeout.Duration != 5*time.Minute { - t.Fatalf("integration.firstEventTimeout was not deep-copied") - } if copied.Status.FirstKVEventObservedAt == nil || !copied.Status.FirstKVEventObservedAt.Time.Equal(time.Unix(1_700_000_000, 0).UTC()) { t.Fatalf("status.firstKVEventObservedAt was not deep-copied") } @@ -472,8 +454,8 @@ func TestCacheBackendJSONOmitEmptySpecPointers(t *testing.T) { if err != nil { t.Fatalf("marshal empty spec: %v", err) } - if string(data) != "{}" { - t.Fatalf("empty spec JSON = %s, want {}", data) + if string(data) != `{"runtime":""}` { + t.Fatalf("empty spec JSON = %s, want required runtime field", data) } } diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index 4397bc79..38c5c3c2 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -152,11 +152,6 @@ func (in *CacheBackendIndexParticipation) DeepCopy() *CacheBackendIndexParticipa // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *CacheBackendIntegrationSpec) DeepCopyInto(out *CacheBackendIntegrationSpec) { *out = *in - if in.FirstEventTimeout != nil { - in, out := &in.FirstEventTimeout, &out.FirstEventTimeout - *out = new(metav1.Duration) - **out = **in - } if in.FailOpen != nil { in, out := &in.FailOpen, &out.FailOpen *out = new(bool) @@ -365,23 +360,11 @@ func (in *CacheBackendSpec) DeepCopyInto(out *CacheBackendSpec) { *out = new(SGLangHiCacheSpec) (*in).DeepCopyInto(*out) } - if in.BackendConfig != nil { - in, out := &in.BackendConfig, &out.BackendConfig - *out = make(map[string]string, len(*in)) - for key, val := range *in { - (*out)[key] = val - } - } if in.Template != nil { in, out := &in.Template, &out.Template *out = new(CacheBackendPodSpecOverride) (*in).DeepCopyInto(*out) } - if in.Resources != nil { - in, out := &in.Resources, &out.Resources - *out = new(v1.ResourceRequirements) - (*in).DeepCopyInto(*out) - } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new CacheBackendSpec. diff --git a/cmd/inferencecache/doctor_integration_test.go b/cmd/inferencecache/doctor_integration_test.go index 65c179ad..034504b6 100644 --- a/cmd/inferencecache/doctor_integration_test.go +++ b/cmd/inferencecache/doctor_integration_test.go @@ -67,6 +67,7 @@ func TestDoctorEnvtest(t *testing.T) { cb := &cachev1alpha1.CacheBackend{ ObjectMeta: metav1.ObjectMeta{Name: "mismatched", Namespace: ns}, Spec: cachev1alpha1.CacheBackendSpec{ + Runtime: cachev1alpha1.CacheBackendRuntimeVLLM, EngineSelector: &cachev1alpha1.CacheBackendEngineSelector{ MatchLabels: map[string]string{"app": "nonexistent-engine"}, }, diff --git a/config/crd/bases/inferencecache.io_cachebackends.yaml b/config/crd/bases/inferencecache.io_cachebackends.yaml index 89ced3b0..49daf3d2 100644 --- a/config/crd/bases/inferencecache.io_cachebackends.yaml +++ b/config/crd/bases/inferencecache.io_cachebackends.yaml @@ -120,13 +120,6 @@ spec: x-kubernetes-validations: - message: minReplicas must not exceed maxReplicas rule: '!has(self.minReplicas) || self.minReplicas <= self.maxReplicas' - backendConfig: - additionalProperties: - type: string - description: |- - BackendConfig contains deprecated compatibility settings. Canonical - resources use the typed LMCache, RemoteStorage, and Observation blocks. - type: object deploymentKind: default: Deployment description: |- @@ -138,28 +131,6 @@ spec: - Deployment - StatefulSet type: string - endpoint: - description: |- - Endpoint is the operator-supplied network address for an - External backend the controller does NOT provision. The field - is type-scoped: it is REQUIRED when spec.type is External and - REJECTED at admission for every other type (managed backends - learn their endpoint from the controller-rendered Service and - would silently overwrite a user-supplied value, so admission - surfaces the misconfiguration loudly at write time). - - Allowed shapes for External (both forms require a non-empty - port — the LMCache connector dials TCP, so admission rejects - portless hosts): - - bare host:port (canonical; the LMCache engine adapter - prepends the lm:// scheme on injection) - - lm://host:port (operators who prefer to be explicit) - IPv6 literals must be bracketed: [::1]:8200. Other schemes - (https://, http://, ...) and path/query/fragment components - are rejected at admission — they would produce an invalid - LMCACHE_REMOTE_URL when concatenated with the lm:// prefix at - injection time. - type: string engineSelector: description: |- EngineSelector selects which engine pods this CacheBackend claims via @@ -261,19 +232,6 @@ spec: description: Integration describes how inference engines should use the cache backend. properties: - engine: - description: |- - Engine is the deprecated inference-runtime identity retained for legacy - manifests. The mutating webhook derives it from spec.runtime for - canonical resources and defaults it to vllm for legacy resources. Both - vllm and sglang have shipping adapters - (vllm+LMCache and sglang+LMCache); the supported (engine, type) pairs - are whatever the installed runtime adapters accept, and admission lists - them in its rejection message for an unsupported pair. Keeping this - default in the webhook instead of the CRD schema prevents a canonical - runtime=SGLang object with a partially populated integration block from - being schema-defaulted into a conflicting engine=vllm value. - type: string engineHostNetwork: description: |- EngineHostNetwork opts engine pods bound to this backend into host @@ -539,52 +497,6 @@ spec: SGLangHiCache accepts only the default true value and does not inject this env var because native HiCache exposes no equivalent fail-closed control. type: boolean - firstEventTimeout: - default: 5m - description: |- - FirstEventTimeout bounds how long a backend may sit - Ready=False with reason AwaitingFirstKVEvent — the backend is up (a - managed workload is Available, or an events-only backend is wired) but no - KV event has been observed yet — before the controller flips it to - Ready=False/Degraded=True with reason NoKVEventsObserved. It applies to - both Offload-managed and EventsOnly backends. - - The KV-event readiness gate holds Ready until at least one KV event - has been observed for this backend's replicas - (status.indexParticipation.lastEventAt, projected from engine-pod - reports). That proves the engine's ZMQ KV-event publisher is actually - publishing — not merely that the managed workload rolled out. An engine - can be serving HTTP while its publisher is silent (mis-configured - --kv-events-config, ZMQ bind failure, in-process publisher crash), or no - engine pods may be attached to the backend at all; either way the cache - plane silently degrades to NO_HINT on every lookup, and this gate makes - that loud. - - The timeout clock starts when the backend becomes "up": for an - Offload-managed backend, when its workload first reports Available; for an - events-only backend (which provisions no workload), on the first reconcile - it is wired — including a re-anchor to the flip moment when a backend - transitions into events-only from a server-bearing mode. The gate is on by - default and opt-out per CacheBackend via the annotation - inferencecache.io/require-kv-events: "false". Backends of spec.type - External are always exempt (their readiness is determined by admission - accepting the endpoint, and they never enter this gate). - - A zero or negative value is treated as unset and falls back to the 5m - default — the field carries no meaningful "wait forever" or "fail - immediately" semantics. - - The first SGLangHiCache implementation publishes no Ready condition, so - this field is inert for that engine-local backend until its separate - readiness contract is implemented. - - The value is a Go duration string (e.g. "90s", "5m", "1h"). The CRD - schema types it as a string; a malformed value is rejected when - admission decodes the object into this typed field, and if admission is - bypassed the controller's typed read fails loudly (it never silently - mis-parses). This matches how the API treats every metav1.Duration - field; no extra CRD-level format constraint is imposed. - type: string mode: default: Offload description: |- @@ -611,10 +523,9 @@ spec: default: ReadWrite description: |- Role controls whether the engine reads from, writes to, or fully - participates in the cache. Defaults to ReadWrite — full participation, - matching the [enginewire.IntegrationRole] read-time fallback for an - omitted integration block. ReadOnly / WriteOnly are specialised - producer/consumer roles operators opt into explicitly. + participates in the cache. Defaults to ReadWrite — full participation. + ReadOnly / WriteOnly are specialised producer/consumer roles operators + opt into explicitly. Engine support is per-adapter: vLLM maps the role onto its LMCache connector's kv_role (ReadOnly→kv_consumer, WriteOnly→kv_producer, @@ -678,6 +589,7 @@ spec: offload and provider lifecycle. properties: firstEventTimeout: + default: 5m description: FirstEventTimeout bounds how long readiness waits for the first KV event. type: string @@ -951,129 +863,10 @@ spec: format: int32 minimum: 0 type: integer - resources: - description: |- - Resources are the deprecated compatibility resources requested + limited - on a legacy managed backend workload. Canonical resources configure this - under remoteStorage..resources. The provider adapter passes - the admitted Requests/Limits maps through to Container.Resources; - the mutating webhook stamps a conservative 4Gi request / 8Gi memory - limit on the legacy minimal-YAML path (when the field is OMITTED) so - the cache server is bounded by the cgroup rather than - node-pressure OOM-killed by the kubelet under heavy T2 write load — - a cache-stress benchmark against an unlimited lmcache-server - repeatedly OOM-killed the pod within minutes of T2 traffic, which - the default limit eliminates. Operators tune per-deployment by - overriding the field; an explicit empty `spec.resources: {}` is - honored as suppression of the webhook-stamped memory request/limit - (no memory request, no memory limit rendered). When spec.autoscaling - is set the runtime adapter still fills in a CPU request fallback - (the HPA-utilization denominator) on top of the empty struct — - that fallback is orthogonal to the memory default this field - controls. - - Admission narrows the surface relative to the upstream - ResourceRequirements shape: a non-empty `resources.claims` slice - is rejected (the runtime adapter does not yet plumb pod-level - `spec.resourceClaims`); strictly-negative `requests` / `limits` - quantities are rejected (the CRD schema admits a leading "-" but - the kubelet would later reject the pod); `requests` / `limits` - keys that are not valid container resource names are rejected - (standard names cpu/memory/ephemeral-storage admit; a - `hugepages-` name admits only when the size suffix parses - as a strictly-positive quantity, e.g. "hugepages-2Mi"; any other - name must be third-party vendor-prefixed like "nvidia.com/gpu" — - the K8s-reserved `kubernetes.io/` and `requests.kubernetes.io/` - prefixes are rejected); and the request/limit relationship is - resource-aware — overcommittable resources (cpu, memory, - ephemeral-storage) admit `limits[X] >= requests[X]`, while - non-overcommittable resources (hugepages-*, vendor-prefixed - extended resources) require `limits[X] == requests[X]` when both - are set. Vendor-prefixed extended-resource quantities (e.g. - nvidia.com/gpu) must be integer values — K8s allocates extended - resources by whole units. See - docs/design/cachebackend-api.md#resources for the full validator - table. - - When spec.autoscaling is set, the adapter additionally fills in a - CPU request fallback (250m) if this field omits one — a - CPU-utilization HPA needs a *positive* CPU request as its - denominator. The fallback never overwrites a positive - operator-supplied value; a non-positive value (e.g. - `requests.cpu: "0"`, which the admission validator admits as a - valid kubelet shape for non-autoscaled pods) is treated as - absent and replaced, because the HPA cannot use 0 as a - denominator. - - This deprecated field is accepted only by legacy resource shapes. - Canonical resources must configure resources under the selected - remoteStorage provider; admission rejects this field when runtime, - lmCache, or remoteStorage selects the canonical API. - External and SGLangHiCache legacy backends provision no workload of - their own, so the field remains inert for those legacy types. - properties: - claims: - description: |- - Claims lists the names of resources, defined in spec.resourceClaims, - that are used by this container. - - This field depends on the - DynamicResourceAllocation feature gate. - - This field is immutable. It can only be set for containers. - items: - description: ResourceClaim references one entry in PodSpec.ResourceClaims. - properties: - name: - description: |- - Name must match the name of one entry in pod.spec.resourceClaims of - the Pod where this field is used. It makes that resource available - inside a container. - type: string - request: - description: |- - Request is the name chosen for a request in the referenced claim. - If empty, everything from the claim is made available, otherwise - only the result of this request. - type: string - required: - - name - type: object - type: array - x-kubernetes-list-map-keys: - - name - x-kubernetes-list-type: map - limits: - additionalProperties: - anyOf: - - type: integer - - type: string - pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ - x-kubernetes-int-or-string: true - description: |- - Limits describes the maximum amount of compute resources allowed. - More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ - type: object - requests: - additionalProperties: - anyOf: - - type: integer - - type: string - pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ - x-kubernetes-int-or-string: true - description: |- - Requests describes the minimum amount of compute resources required. - If Requests is omitted for a container, it defaults to Limits if that is explicitly specified, - otherwise to an implementation-defined value. Requests cannot exceed Limits. - More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ - type: object - type: object runtime: description: |- - Runtime identifies the inference runtime. New resources should use this - field; integration.engine remains as a deprecated compatibility input. - Values are case-sensitive: use VLLM or SGLang. Lowercase normalization - applies only to the deprecated integration.engine field. + Runtime identifies the inference runtime. Values are case-sensitive: use + VLLM or SGLang. enum: - VLLM - SGLang @@ -2503,13 +2296,15 @@ spec: default: LMCache description: |- Type identifies the engine-side cache implementation and defaults to - LMCache. Canonical resources select provider technology and ownership - independently through remoteStorage; omitting remoteStorage requests a - host-only hierarchy. Legacy Mooncake and External values remain readable - as compatibility inputs. The CRD does not constrain Type to an enum - today; admission is the authoritative reject for unsupported pairs and - for legacy provider values used in canonical resources. + LMCache. Supported values are LMCache and SGLangHiCache. Provider + technology and ownership are selected independently through remoteStorage; + omitting remoteStorage requests a host-only hierarchy. + enum: + - LMCache + - SGLangHiCache type: string + required: + - runtime type: object status: description: CacheBackendStatus defines the observed state of a cache diff --git a/config/samples/_test/cachebackend-invalid-scale-to-zero-no-min.yaml b/config/samples/_test/cachebackend-invalid-scale-to-zero-no-min.yaml index 29149c0b..9ee887c0 100644 --- a/config/samples/_test/cachebackend-invalid-scale-to-zero-no-min.yaml +++ b/config/samples/_test/cachebackend-invalid-scale-to-zero-no-min.yaml @@ -20,6 +20,7 @@ kind: CacheBackend metadata: name: cachebackend-invalid-scale-to-zero-no-min spec: + runtime: VLLM type: LMCache replicas: 0 autoscaling: diff --git a/config/samples/cache_v1alpha1_cachebackend.yaml b/config/samples/cache_v1alpha1_cachebackend.yaml index d1c17b9e..5460b496 100644 --- a/config/samples/cache_v1alpha1_cachebackend.yaml +++ b/config/samples/cache_v1alpha1_cachebackend.yaml @@ -5,7 +5,7 @@ # The runtime, engine cache, and remote provider are explicit so this minimum # sample also demonstrates the canonical API hierarchy. Admission still # defaults deploymentKind=Deployment, replicas=1, integration.role=ReadWrite, -# integration.failOpen=true, and integration.firstEventTimeout=5m. +# integration.failOpen=true, and observation.firstEventTimeout=5m. apiVersion: inferencecache.io/v1alpha1 kind: CacheBackend metadata: diff --git a/config/samples/cachebackend-cpu-override.yaml b/config/samples/cachebackend-cpu-override.yaml index dfd25932..f83e5f75 100644 --- a/config/samples/cachebackend-cpu-override.yaml +++ b/config/samples/cachebackend-cpu-override.yaml @@ -3,7 +3,7 @@ # webhook injects. # # The override surface is engine-agnostic K8s vocabulary (args + env), so it -# also extends to future SGLang / Mooncake adapters with no CRD churn. +# also extends to future runtime adapters and remote bindings with no CRD churn. # # Admission HARD-REJECTS overrides that overlap the adapter's reserved # args/env — for the vLLM+LMCache adapter today: `--kv-transfer-config`, diff --git a/docs/cli/doctor.md b/docs/cli/doctor.md index 2c735ffe..99ee0247 100644 --- a/docs/cli/doctor.md +++ b/docs/cli/doctor.md @@ -89,8 +89,8 @@ Notes: `lastEventAt` has been cleared by the poller. `CB004` fires only when `lastEventAt` IS present but has gone stale — an idle backend with a fresh event is healthy (`CB006`). -- **Externally owned remote storage** (`spec.remoteStorage.ownership=External`, - including the legacy `spec.type=External` shape) is checked for `Ready` and +- **Externally owned remote storage** (`spec.remoteStorage.ownership=External`) + is checked for `Ready` and endpoint reachability only. Engine-pod matching (`CB002`) and index participation (`CB003`/`CB004`) are managed-backend concerns and are skipped, so a valid external config is not spuriously flagged. diff --git a/docs/concepts/cachebackend-engine-binding.md b/docs/concepts/cachebackend-engine-binding.md index b2de6d87..56dbc3d1 100644 --- a/docs/concepts/cachebackend-engine-binding.md +++ b/docs/concepts/cachebackend-engine-binding.md @@ -43,7 +43,7 @@ Three actors participate in the binding: The match is evaluated **once at pod CREATE** by the mutating webhook. The wiring is sticky to the life of the pod; relabeling an existing pod does not re-evaluate it. To opt a pod out regardless of label match, set `inferencecache.io/skip-inject: "true"` on the pod template. Skipped pods are stamped with `inferencecache.io/inject-skipped: "skip-inject-annotation"` and receive a `SkippedByOperator` Event, so an intentional opt-out is distinguishable from selector drift. -`*` The kvevent-subscriber sidecar is opt-in. It is appended only when the controller is started with `--kvevent-subscriber-image` set (empty by default) AND the matched CacheBackend has `spec.observation.modelID` configured; otherwise the engine is wired without it. The deprecated `backendConfig.model` remains a read fallback for legacy resources. The default install does not auto-attach the sidecar. +`*` The kvevent-subscriber sidecar is opt-in. It is appended only when the controller is started with `--kvevent-subscriber-image` set (empty by default) AND the matched CacheBackend has `spec.observation.modelID` configured; otherwise the engine is wired without it. The default install does not auto-attach the sidecar. > **Native SGLang HiCache exception.** `type: SGLangHiCache` is engine-local: > the controller creates no backend workload or endpoint, and the webhook does diff --git a/docs/concepts/cachebackend-engine-overrides.md b/docs/concepts/cachebackend-engine-overrides.md index ea2cd626..3e1a7420 100644 --- a/docs/concepts/cachebackend-engine-overrides.md +++ b/docs/concepts/cachebackend-engine-overrides.md @@ -257,7 +257,7 @@ Two surfaces today: (and the other three) to read each primitive's per-field merge semantics. - The reserved list for the vLLM + LMCache adapter lives in the adapter - source: `pkg/adapters/runtime/vllm_lmcache.go`'s `ReservedArgs()` and + source: `internal/adapters/builtin/runtime/vllm_lmcache.go`'s `ReservedArgs()` and `ReservedEnv()` methods. Each entry is commented with WHY it is reserved. diff --git a/docs/design/cachebackend-api.md b/docs/design/cachebackend-api.md index 417366d0..71cb2745 100644 --- a/docs/design/cachebackend-api.md +++ b/docs/design/cachebackend-api.md @@ -84,62 +84,15 @@ binding (`lm`, `resp`, or `mooncakestore`). The engine adapter declares which bindings it accepts. Admission rejects unsupported combinations before an engine Pod is created. -### Legacy compatibility +### Cache type validation -The old fields remain readable during `v1alpha1`. When none of `runtime`, -`lmCache`, or `remoteStorage` is present, the compatibility resolver preserves -the historical behavior: +`spec.type` is a closed CRD enum containing `LMCache` and `SGLangHiCache`. +Remote-provider technology and lifecycle ownership are not cache types: +Mooncake is selected through `remoteStorage.provider`, and externally managed +infrastructure through `remoteStorage.ownership`. The API server rejects the +old `type: Mooncake` and `type: External` spellings before admission. -| Legacy shape | Effective engine cache | Effective remote storage | -|---|---|---| -| `type: LMCache`, `integration.engine: vllm` | LMCache | Managed LMCacheServer | -| `type: LMCache`, `integration.engine: sglang` | LMCache | Managed Redis | -| `type: Mooncake` | LMCache | Managed Mooncake | -| `type: External`, `endpoint: ...` | LMCache | External LMCacheServer | - -New manifests should use the canonical fields. In particular, setting -`runtime: SGLang` plus `type: LMCache` no longer implies Redis; a -`remoteStorage` block must request it explicitly. - -### Migrating a legacy resource - -Migration is an all-at-once spec replacement, not a field-by-field transition. -Adding `runtime`, `lmCache`, or `remoteStorage` selects the canonical hierarchy; -that same update must remove deprecated `backendConfig` and top-level -`resources`, move the model ID to `observation.modelID`, and express provider -ownership under `remoteStorage`. A partial update is rejected at admission so -the controller never has to interpret a mixed ownership model. - -For example, migrate a legacy managed LMCache backend from: - -```yaml -spec: - type: LMCache - integration: - engine: vllm - backendConfig: - model: Qwen/Qwen2.5-0.5B-Instruct - serverImage: lmcache/standalone:v0.4.7 -``` - -to this complete canonical shape in one `kubectl apply`: - -```yaml -spec: - runtime: VLLM - type: LMCache - observation: - modelID: Qwen/Qwen2.5-0.5B-Instruct - remoteStorage: - provider: LMCacheServer - ownership: Managed - lmCacheServer: - image: lmcache/standalone:v0.4.7 -``` - -`runtime` enum values are case-sensitive (`VLLM` and `SGLang`); the old -`integration.engine` field alone remains case-insensitive. Converted External -and Mooncake examples are available in +The canonical External and Mooncake examples are available in [`config/samples/cachebackend-external.yaml`](../../config/samples/cachebackend-external.yaml) and [`config/samples/cachebackend-mooncake.yaml`](../../config/samples/cachebackend-mooncake.yaml). @@ -147,13 +100,13 @@ and [`config/samples/cachebackend-mooncake.yaml`](../../config/samples/cacheback | Field | Type | Purpose | |---|---|---| -| `runtime` | enum | Inference runtime: `VLLM` or `SGLang`. Values are case-sensitive. New manifests use this instead of `integration.engine`, whose legacy reader remains case-insensitive. | -| `type` | string | Engine-side cache implementation identifier. Defaults to `LMCache`. `Mooncake` and `External` remain accepted only as legacy compatibility values. | +| `runtime` | enum | Required inference runtime: `VLLM` or `SGLang`. Values are case-sensitive. | +| `type` | enum | Engine-side cache implementation: `LMCache` or `SGLangHiCache`. Defaults to `LMCache`. | | `lmCache` | object | Typed LMCache engine configuration: chunk size, host-memory capacity, MP-worker image/port, and remote serde. | | `remoteStorage` | object | Optional remote tier. Omitting it means host-only and provisions no provider workload. | | `remoteStorage.provider` | enum | `Redis`, `LMCacheServer`, or `Mooncake`. | | `remoteStorage.ownership` | enum | `Managed` or `External`. | -| `remoteStorage.endpoint` | string | Required for `External`, rejected for `Managed`; managed endpoints are controller-observed in status. Bare `host:port` is portable across all providers. `LMCacheServer` also accepts `lm://host:port`, `Mooncake` also accepts `mooncakestore://host:port`, and `Redis` accepts only bare `host:port` with a numeric port in `1-65535`. Admission rejects schemes belonging to another provider. | +| `remoteStorage.endpoint` | string | Required for `External`, rejected for `Managed`; managed endpoints are controller-observed in status. Bare `host:port` is portable across all providers. `LMCacheServer` also accepts `lm://host:port`, `Mooncake` also accepts `mooncakestore://host:port`, and `Redis` accepts only bare `host:port`. Every provider requires a numeric port in `1-65535`; admission rejects schemes belonging to another provider. | | `remoteStorage.redis` | object | Redis-owned image and resource configuration. | | `remoteStorage.lmCacheServer` | object | Standalone LMCache-server-owned image, command, and resource configuration. | | `remoteStorage.mooncake` | object | Mooncake-owned image, command, and resource configuration. | @@ -163,19 +116,14 @@ and [`config/samples/cachebackend-mooncake.yaml`](../../config/samples/cacheback | `autoscaling.minReplicas` | integer | Lower bound for HPA replica count. Auto-defaulted to `spec.replicas` on FIRST APPLY ONLY by the admission defaulter when `spec.autoscaling` is set and `minReplicas` is left unset (see [Defaulting](#defaulting-mutating) for the first-apply-only semantics); subsequent edits to `spec.replicas` do NOT move this floor. Minimum `1`. | | `autoscaling.maxReplicas` | integer | Upper bound for HPA replica count. Required when `autoscaling` is set. Minimum `1`. Cross-field validation: `minReplicas <= maxReplicas`. | | `autoscaling.targetCPUUtilizationPercent` | integer | Target average per-pod CPU utilization for the HPA. Defaults to `80` when unset. Range `[1, 100]`. | -| `integration.engine` | string | Deprecated runtime identity retained for legacy manifests. Use `runtime`. | -| `integration.mode` | enum | Which cache tiers the engine is wired for: `Offload` (default) or `EventsOnly`. `Offload` is full participation — cache-aware routing (tier-1) plus the KV-offload connector (tier-2). It may remain host-only, connect to externally owned remote storage, or provision a provider workload when `remoteStorage.ownership` is `Managed`. `EventsOnly` wires routing only: the kvevent-subscriber sidecar is injected (when the controller runs with `--kvevent-subscriber-image` set and an observation model ID is present via canonical `observation.modelID` or legacy `backendConfig.model` — otherwise the append is skipped fail-open), but no KV connector is loaded into the engine and no backend server is provisioned. See [Events-only mode](#events-only-mode-specintegrationmode--eventsonly). | +| `integration.mode` | enum | Which cache tiers the engine is wired for: `Offload` (default) or `EventsOnly`. `Offload` is full participation — cache-aware routing (tier-1) plus the KV-offload connector (tier-2). It may remain host-only, connect to externally owned remote storage, or provision a provider workload when `remoteStorage.ownership` is `Managed`. `EventsOnly` wires routing only: the kvevent-subscriber sidecar is injected when the controller runs with `--kvevent-subscriber-image` set and `observation.modelID` is present; otherwise the append is skipped fail-open. No KV connector or backend server is created. See [Events-only mode](#events-only-mode-specintegrationmode--eventsonly). | | `integration.role` | enum | Engine participation mode: `ReadOnly`, `WriteOnly`, or `ReadWrite`. Defaults to `ReadWrite`. | | `integration.failOpen` | boolean | Default `true`. When `true`, engine pods fall back to local prefill on cache unreachability — the cache is an optimization, never a serving dependency. Setting it to `false` is an advanced opt-in to fail-closed serving (the cache becomes a serving dependency); the controller surfaces this as a Warning Kubernetes Event on the owning `CacheBackend`. **Pair-specific exception — `(sglang, LMCache)`:** SGLang has no cacheless code path while `--enable-lmcache` is on, so its co-scheduled MP worker is a *serving prerequisite* (a worker that never starts wedges the engine), not a remote dependency that degrades to local prefill. `failOpen` is still honored at the tier that can actually be "unavailable" — the shared L2 (the worker comes up L1-only when Redis is unreachable). This is a documented, accepted boundary; see the fail-open semantics in [`sglang-lmcache-mp-mode.md`](sglang-lmcache-mp-mode.md) and [SGLang engine support](#sglang-engine-support). | -| `integration.firstEventTimeout` | duration | Deprecated observation timeout retained for legacy manifests. Use `observation.firstEventTimeout`. | | `integration.engineOverrides` | object | Optional engine-injection overrides applied to the args/env the pod-mutating webhook would otherwise inject into the engine container. See [Engine-injection overrides](#engine-injection-overrides-specintegrationengineoverrides). | | `engineSelector.matchLabels` | map | Equality-based label selector matched against engine **pod** labels (the pod template's `metadata.labels`, not Deployment, DaemonSet, or any other workload-level labels). Every key/value here must appear on the pod for it to match. `matchExpressions` is intentionally not exposed in v1alpha1 — the surface is `matchLabels` only. | | `hiCache` | object | Typed SGLang native HiCache configuration. Required only for `type: SGLangHiCache`; see [SGLang native HiCache](#sglang-native-hicache). | -| `backendConfig` | map | Deprecated compatibility map. New configuration belongs under `lmCache`, `remoteStorage.`, or `observation`. | | `template` | object | Optional pod-level overrides for managed backend pods. This is a narrow override surface, not a full `PodSpec`; backend containers come from controller defaults. | -| `resources` | object | Deprecated compatibility resources for legacy provider shapes. Canonical manifests use `remoteStorage.redis.resources`, `remoteStorage.lmCacheServer.resources`, or `remoteStorage.mooncake.resources`. | -| `endpoint` | string | Deprecated compatibility endpoint for legacy `type: External`. Canonical manifests use `remoteStorage.endpoint` with `ownership: External`. | -| `allowCrossNamespace` | boolean | Opt-in flag that allows canonical `spec.remoteStorage.endpoint` or deprecated `spec.endpoint` to resolve to a Kubernetes Service in a different namespace from the CacheBackend itself. Without it, admission rejects cross-namespace Service-DNS endpoints. External hostnames and IPs are unaffected. Defaults to `false`. | +| `allowCrossNamespace` | boolean | Opt-in flag that allows `spec.remoteStorage.endpoint` to resolve to a Kubernetes Service in a different namespace from the CacheBackend itself. Without it, admission rejects cross-namespace Service-DNS endpoints. External hostnames and IPs are unaffected. Defaults to `false`. | > **Per-namespace lookup tuning lives on CachePolicy, not CacheBackend.** The > lookup latency budget and the minimum-prefix-token gate are configured via @@ -208,25 +156,18 @@ owns the workload: `remoteStorage.redis.resources`, `remoteStorage.lmCacheServer.resources`, or `remoteStorage.mooncake.resources`. The provider renderer deep-copies that block onto its managed container. If the typed block is omitted, the provider -uses the same bounded 4Gi request / 8Gi limit as the legacy default. - -Top-level `spec.resources` remains a compatibility input for legacy shapes -only. Admission rejects it on canonical resources so an operator cannot -supply a value that no canonical provider owns or renders. - -**Legacy webhook default — minimal-YAML legacy CacheBackends remain memory-bounded.** When a legacy resource omits `spec.resources`, the mutating webhook stamps `{requests: {memory: "4Gi"}, limits: {memory: "8Gi"}}`. An explicit `spec.resources: {}` (or any operator-supplied value) takes precedence. A cache-stress benchmark previously OOM-killed an unlimited cache-server pod within minutes of T2 write load; preserving this default closes that cliff for existing manifests. Canonical providers apply the same bounded default inside their renderer when their typed `resources` block is omitted, without persisting the deprecated top-level field. +uses a bounded 4Gi request / 8Gi limit without persisting a default into the CR. **Pass-through to the rendered container.** The provider adapter `DeepCopy`'s the selected typed resource block onto `Container.Resources`. The deep copy is load-bearing: the reconciler reads from an informer cache, and writing through the spec pointer would corrupt the cached object for every subsequent reader. An explicit empty provider `resources: {}` suppresses the provider default. -Legacy `spec.resources: {}` keeps its historical suppression behavior. **`redis-l2`: the memory limit also sizes the L2 keyspace.** The rendered Redis provider derives `--maxmemory` from -`remoteStorage.redis.resources.limits.memory` (or the corresponding legacy -resource block) at roughly 80%, with `allkeys-lru`. +`remoteStorage.redis.resources.limits.memory` at roughly 80%, with +`allkeys-lru`. **Autoscaling CPU-request fallback.** A `targetCPUUtilizationPercent` HPA needs a **positive** CPU request as the denominator for its utilization math, so when `spec.autoscaling` is set the adapter fills in `cpu: 250m` whenever the selected provider resource block's `requests.cpu` is absent OR non-positive. The non-positive case matters because the admission validator admits `requests.cpu: "0"` as a valid kubelet shape (an explicit "no guaranteed minimum" for non-autoscaled pods); without the autoscaling-side replacement, the HPA would dial against a 0 denominator. A positive operator-supplied value (e.g. `requests.cpu: "1"`) survives untouched. The fallback is **CPU-only** — it never synthesises a memory request — and the operator-supplied memory block (or the legacy webhook/provider default) flows through unchanged. @@ -246,67 +187,10 @@ Limits-only shapes admit unchanged for any resource — K8s auto-populates `requ **Resource names must match K8s container-resource rules.** `ResourceList` keys are opaque map keys at the CRD-schema layer; an invalid name like `"foo"` or `""` persists in etcd and only fails when the apiserver later rejects the child pod. The validating webhook (`rejectInvalidResourceNames`) applies the same rules the apiserver applies to a `Container.Resources` map: standard names (`cpu`, `memory`, `ephemeral-storage`) admit unconditionally; a `hugepages-` name admits only when the size suffix parses as a strictly-positive `resource.Quantity` (e.g. `"hugepages-2Mi"`, `"hugepages-1Gi"` — a bare `"hugepages-"` or non-numeric `"hugepages-nope"` is rejected because the apiserver requires the size token); any other name must be **third-party vendor-prefixed** (e.g. `"nvidia.com/gpu"`) and pass `IsQualifiedName`. A bare unqualified `"foo"` is rejected even though `IsQualifiedName` alone admits it, because the apiserver's container-resource layer requires extended resources to carry a vendor identity. Names under the **K8s-reserved prefixes `kubernetes.io/` and `requests.kubernetes.io/`** are also rejected — those prefixes are reserved for native resources, so extended resources may not use them. The rejection names the offending key so multi-key errors surface together. -**Inert for legacy backends with no controller-managed workload.** Legacy `spec.type=External` and `spec.type=SGLangHiCache` provision no cache-server workload of their own, so the compatibility `spec.resources` default has no rendered target. HiCache host memory belongs to the user-owned engine container and must be sized on that workload instead. - -### backendConfig keys (managed LMCache) - -`spec.backendConfig` is a **deprecated, legacy-only** free-form string map. -Canonical resources must instead select the provider explicitly with -`spec.remoteStorage` and configure it under `remoteStorage.redis`, -`remoteStorage.lmCacheServer`, or `remoteStorage.mooncake`; engine-side LMCache -settings live under `spec.lmCache`, and observation settings under -`spec.observation`. Admission rejects `backendConfig` on canonical resources so -the map cannot silently compete with those typed owners. - -The tables below document only the compatibility keys still read from legacy -resources. Under that legacy shape, the vLLM adapter chooses the standalone -`lm://` LMCache server and the SGLang MP adapter chooses Redis L2. Canonical -resources make that ownership choice directly and do not inherit these map -values. - -Legacy server-side keys (consumed while rendering the selected provider pod): - -| Key | Default | Purpose | -|---|---|---| -| `serverImage` | `lmcache/standalone:v0.4.7` *(pinned, non-floating; see version-alignment note below)* | Container image for the standalone lmcache-server. The default is pinned to a specific version — **not** a floating `:latest` — because the server's wire protocol must match the lmcache *client* compiled into the engine; a drifting `:latest` silently breaks tier-2 offload (see [LMCache server / client version alignment](#lmcache-server--client-version-alignment)). Pin to a digest for non-local runs. Deliberately distinct from a bare `image` key (which previously addressed the all-in-one vLLM+LMCache container the prior reconciler rendered): an existing CR carrying `backendConfig.image: vllm/vllm-openai:…` is therefore silently ignored rather than rendering an lmcache-server pod with the wrong image. | -| `serverCommand` | `lmcache_server 0.0.0.0 65432 cpu` | Server command line. Override to switch to the newer `python3 -m lmcache.v1.multiprocess.server` form once it stabilises. The default targets the older `lmcache_server ` form because it has a documented port (65432, the canonical `lm://` port) and arg layout. | -| `redisImage` | `docker.io/library/redis:7.4-alpine` *(versioned default, mutable within its patch line; digest-pin in prod)* | **SGLang only.** Container image for the managed **Redis L2 store** the SGLang LMCache MP worker offloads to (its `resp` `--l2-adapter`); rendered by the provider-owned `ResolveRedisL2Server` for the `(sglang, LMCache)` pair. `lm://` is not a valid MP `--l2-adapter` type, so SGLang cannot reuse the standalone lmcache-server. Production **must** pin an exact release or `@sha256:` digest. | - -Legacy engine-side keys (consumed by `InjectEngineConfig`). The `LMCACHE_*` tunables below are the **vLLM** engine-side env; **SGLang MP mode does not use them** — it tunes the MP worker via `chunkSize` / `l1SizeGB` / `mpPort` / `workerImage` instead (the numeric ones positive-integer-sanitized), see [SGLang engine support](#sglang-engine-support). Canonical equivalents belong under `spec.lmCache` or, for non-reserved environment tuning, `spec.integration.engineOverrides`: - -| Key | Default | Purpose | -|---|---|---| -| `chunkSize` | `256` | `LMCACHE_CHUNK_SIZE` on the engine container. | -| `remoteSerde` | `naive` | `LMCACHE_REMOTE_SERDE` on the engine container. CPU-safe default; `cachegen` is faster but pulls in CUDA-only codepaths and should be opted into via this key on GPU. | -| `localCPU` | `False` | `LMCACHE_LOCAL_CPU` on the engine container. Defaults to `False` (remote-only); `True` enables a hybrid local+remote mode. | -| `maxLocalCPU` | `20` | `LMCACHE_MAX_LOCAL_CPU_SIZE` (GiB) on the engine container; only meaningful when `localCPU=True`. | - -The webhook also injects the flags every vLLM+LMCache engine needs: - -- `--kv-transfer-config '{"kv_connector":"LMCacheConnectorV1","kv_role":""}'` — `` is derived from `spec.integration.role`: `ReadOnly → kv_consumer`, `WriteOnly → kv_producer`, `ReadWrite → kv_both` (also the default when `integration` is unset). -- `LMCACHE_REMOTE_URL` is injected only when the canonical hierarchy has a remote binding. Omitting `spec.remoteStorage` is host-only and deliberately leaves this variable unset. An LMCacheServer binding renders `lm://`; a Mooncake binding renders `mooncakestore://`. For Managed ownership, the endpoint comes from `status.endpoint`, built from the controller-owned Service. For External ownership, the trimmed `spec.remoteStorage.endpoint` is operator-authoritative so a stale status value cannot wire a new pod during an endpoint update. Admission validates the endpoint against the selected provider: LMCacheServer accepts bare `host:port` or `lm://host:port`, Mooncake accepts bare `host:port` or `mooncakestore://host:port`, and Redis requires bare `host:port`. Legacy managed and `type: External` resources synthesize the equivalent LMCacheServer binding from their historical fields. -- `VLLM_USE_V1=1`. -- `INFERENCECACHE_FAIL_OPEN=` — mirrors `spec.integration.failOpen` onto the engine pod (defaults to `true` when the field is unset). The LMCache connector is fail-open by default at runtime regardless of this value; surfacing the bit lets the engine layer enforce fail-closed semantics when that work lands, and keeps the adapter aligned with the contract that this flag is plumbed by the engine adapter. -- `PYTHONHASHSEED=0` — pins the deterministic `NONE_HASH` that seeds vLLM's prefix-cache block-hash chain across the scheduler and TP worker processes. Under TP>1 those are separate OS processes; with the seed unset (or overridden) each derives a different `NONE_HASH`, so the scheduler's reload lookup never matches the workers' stored hashes and LMCache reload silently 0-hits (full recompute, no crash, no error). A correctness invariant, not a tunable — reserved so an override can't re-break it. - -These are not user-overridable via `backendConfig`. - -The retired colocated-rendering keys (`image`, `profile`, `hfTokenSecret`) were specific to a previous all-in-one vLLM+LMCache workload the reconciler templated. The new architecture splits the cache server from the engine: the engine is user-owned (its image/HF-token Secret live on the engine's own Deployment), the cache-server is engine-agnostic. CRs carrying those legacy keys keep validating against the unchanged CRD schema (`backendConfig` is a free-form string map) but the values are silently ignored — operators upgrading from the colocated rendering should drop them, or move them to the engine Deployment they own. - -Canonical resources set `spec.observation.modelID` to the served model identifier -for the `kvevent-subscriber` sidecar's `--model-id` flag. When it is empty, the -adapter skips appending the sidecar because the subscriber binary requires that -flag; the next pod admission after the operator sets it picks it up. Set it to -the identifier the engine container is loaded with (the value that ends up in -the engine's `served_model_name`). Legacy resources retain -`backendConfig.model` as a read-time compatibility source only. - -The auto-attach itself is opt-in: the controller's -`--kvevent-subscriber-image` flag defaults to empty, in which case the adapter -returns no sidecar regardless of `observation.modelID` (or the legacy model -source). Operators turn auto-attach on by passing a real, digest-pinned image. -This default protects an unconfigured install from `ImagePullBackOff` on a -nonexistent sidecar image, which would otherwise block engine pod readiness. +**Inert without a controller-managed workload.** Host-only, externally owned, +and `SGLangHiCache` configurations provision no cache-server workload of their +own. HiCache host memory belongs to the user-owned engine container and must be +sized on that workload instead. ### SGLang engine support @@ -323,7 +207,7 @@ SGLang supports two peer cache integrations: > **SGLang drives LMCache in multiprocess (MP) mode (implemented, GPU-validated end to end).** Unlike vLLM, SGLang reads LMCache config from a **`--lmcache-config-file`** (carrying `mp_host`/`mp_port`), attaches to a **node-local MP worker** over ZMQ + a shared-memory data path, and offloads to a shared **L2 store** (the worker's `--l2-adapter`) — it does NOT use a cluster-reachable `lm://` server (`lm://` is not even a valid MP `--l2-adapter` type). So the `(sglang, LMCache)` data plane differs from vLLM's on **both** halves, and the sections below reflect that. Authoritative design + validation evidence: [`sglang-lmcache-mp-mode.md`](sglang-lmcache-mp-mode.md). SGLang is the second runtime the cache plane supports (`spec.runtime: SGLang`, -`spec.type: LMCache`; adapter at `pkg/adapters/runtime/sglang`). Its engine +`spec.type: LMCache`; adapter at `internal/adapters/builtin/runtime`). Its engine adapter configures the node-local MP worker and accepts either no binding (host-only) or a RESP binding. The independent Redis provider adapter creates a Redis workload only when `spec.remoteStorage` explicitly selects @@ -371,14 +255,11 @@ The old lm:// `LMCACHE_REMOTE_URL` / serde / chunk-size / local-CPU env is | `lmCache.workerPort` | `5555` | `1`–`65535` | Loopback ZMQ port used by the engine and worker. | | `lmCache.workerImage` | engine image | — | Optional MP-worker image override. | -Legacy `backendConfig.chunkSize`, `l1SizeGB`, `mpPort`, and `workerImage` -remain read-time compatibility inputs. - Deliberately **not** injected for SGLang (a real engine difference, not an omission): `VLLM_USE_V1` (a vLLM-internal codepath with no SGLang analogue) and `PYTHONHASHSEED` (vLLM pins it to stabilise its builtin-`hash()`-seeded block-hash chain across TP workers; SGLang derives its prefix hash with `hashlib.sha256` over the token-id bytes, independent of `PYTHONHASHSEED`). **`spec.integration.role` support.** vLLM maps the role onto its LMCache connector's `kv_role` (ReadOnly→`kv_consumer`, WriteOnly→`kv_producer`, ReadWrite→`kv_both`). SGLang's `--enable-lmcache` integration has **no `kv_role` split** — it always both stores and retrieves — so a `(sglang, LMCache)` backend supports only `ReadWrite` (the default). Admission **rejects** `ReadOnly` / `WriteOnly` for SGLang (`rejectUnsupportedSGLangRole`) rather than silently treating them as ReadWrite; the rule lifts if SGLang's LMCache integration gains a producer/consumer split. -**Reserved set** (`pkg/adapters/runtime/sglang`): `ReservedArgs()` = `--enable-lmcache`, `--lmcache-config-file`; `ReservedEnv()` = `LMCACHE_USE_EXPERIMENTAL`, `INFERENCECACHE_FAIL_OPEN`. In MP mode the old lm:// `LMCACHE_REMOTE_URL` is neither injected nor reserved. `VLLM_USE_V1` / `PYTHONHASHSEED` are not reserved because they are never injected. +**Reserved set** (`internal/adapters/builtin/runtime`): `ReservedArgs()` = `--enable-lmcache`, `--lmcache-config-file`; `ReservedEnv()` = `LMCACHE_USE_EXPERIMENTAL`, `INFERENCECACHE_FAIL_OPEN`. In MP mode the old lm:// `LMCACHE_REMOTE_URL` is neither injected nor reserved. `VLLM_USE_V1` / `PYTHONHASHSEED` are not reserved because they are never injected. The two override surfaces are separate: `spec.lmCache` shapes the worker sidecar, while `spec.integration.engineOverrides` edits the engine container's @@ -445,8 +326,8 @@ HiCache host memory is charged to the engine container's cgroup. The operator must size the engine's memory request/limit and node capacity accordingly. Inference-cache does not derive resource changes from `sizeGB` or `ratio`, and does not add `/dev/shm`, hugepages, memlock, hostIPC, or privileged settings. -For this type, `backendConfig` accepts only the optional `model` key used by -the KV-event subscriber. +The KV-event subscriber reads its model identity from +`spec.observation.modelID`, independently of the HiCache configuration. ### Events-only mode (`spec.integration.mode = EventsOnly`) @@ -457,7 +338,7 @@ the KV-event subscriber. - **No provisioned server.** The reconciler creates no Deployment and no Service for an events-only backend, and `status.endpoint` stays empty (there is no server address to publish). Flipping an existing `Offload` backend to `EventsOnly` sheds the previously-provisioned Deployment + Service on the next reconcile. - **No KV connector.** The pod webhook does NOT inject the `--kv-transfer-config` arg or the `LMCACHE_*` env into the engine container — the engine container is left otherwise untouched. Because nothing dials a cache server, no endpoint is required, and the webhook injects an events-only engine pod even though `status.endpoint` is empty (the usual empty-endpoint fail-open is bypassed for this mode). - **Mode wins over host-tier configuration.** If `spec.lmCache` is present, `EventsOnly` still injects no LMCache connector or host-tier settings; the block is ignored for engine wiring. Operators should omit `spec.lmCache` on routing-only resources so the manifest does not imply an active host tier. `spec.remoteStorage` is rejected rather than ignored because it declares a provider that nothing would dial. -- **The kvevent-subscriber sidecar is injected — when wired.** That is the whole point of routing: once the sidecar is appended, `LookupRoute` and the per-backend `status.indexParticipation` slice behave identically to a managed backend; only the offload tier (server + connector) is absent. The append is gated exactly as for a managed backend and is skipped **fail-open** when either gate is unmet: the controller must run with `--kvevent-subscriber-image` set (unset by default, so a default install injects no subscriber) AND canonical `spec.observation.modelID` must be present to supply `--model-id`. Legacy resources may still supply the effective model ID through `backendConfig.model`. When skipped, the webhook leaves the engine pod untouched and stamps no `injected-by` annotation. +- **The kvevent-subscriber sidecar is injected — when wired.** That is the whole point of routing: once the sidecar is appended, `LookupRoute` and the per-backend `status.indexParticipation` slice behave identically to a managed backend; only the offload tier (server + connector) is absent. The append is gated exactly as for a managed backend and is skipped **fail-open** when either gate is unmet: the controller must run with `--kvevent-subscriber-image` set (unset by default, so a default install injects no subscriber) AND `spec.observation.modelID` must be present to supply `--model-id`. When skipped, the webhook leaves the engine pod untouched and stamps no `injected-by` annotation. - **Evictions are tier-aware.** The subscriber tags each prefix with a cache tier from the block lifecycle: `BlockStored` → **T1** (resident in HBM). On a `BlockRemoved`, the two modes diverge. In `Offload` mode the paired LMCache L2 tier still holds the block after the engine evicts it from HBM, so the subscriber (`--ignore-block-removed=true`) **re-reports the evicted prefix at tier T2** (reload-able from host RAM), anchored at the eviction timestamp — the entry is *kept*, not dropped, and honestly tagged colder than HBM; a later `BlockStored` of the same content re-reports it back at T1. In `EventsOnly` mode there is no L2 retaining the block, so a `BlockRemoved` genuinely means the prefix is gone and the hint MUST be pruned — the subscriber omits the flag and forwards the eviction as `PREFIX_EVICTED`. Either way a stale/mis-tagged hint is soft state (a cache miss at worst, never a wrong answer). See `docs/design/kvevent-subscriber-wiring.md` "L2 cache tier semantics". **Readiness is gated on the first KV event, same as managed.** An events-only backend has no workload to wait on, so it is "up" the moment it exists — the `firstEventTimeout` clock starts immediately (`status.firstAvailableAt` is latched on the first reconcile). It then runs the same [KV-event readiness gate](#kv-event-readiness-gate) as a managed backend: `Ready=False/AwaitingFirstKVEvent` until the first event, `Ready=True/KVEventsObserved` once `status.indexParticipation.lastEventAt` is observed, and `Ready=False/NoKVEventsObserved`, `Degraded=True` if the window elapses with no event. The base Ready reason is `EventsOnlyActive`. The managed-only advisory conditions `FunctionalProbeOK`, `EngineKernelsHealthy`, `T2Degraded`, and `EngineCompatibility` are never published on an events-only backend — there is no server to functionally probe, no LMCache native-kernel check (events-only loads no connector, so the `lmcache-kernel-check` init container is never injected), no tier-2 to mark degraded, and no injected KV connector that could be incompatible (events-only injects none, and an Offload→EventsOnly flip clears any prior verdict). @@ -467,18 +348,13 @@ the KV-event subscriber. **Admission constraints.** Because an events-only backend provisions no server, server-shaped configuration is structurally meaningless and is rejected at admission: - `spec.remoteStorage` is forbidden — any Managed or External declaration requests an offload provider that events-only deliberately does not wire. -- Legacy `spec.type=External` is incompatible for the same reason: it synthesizes an operator-run LMCacheServer binding that no connector would dial. The supported engine-cache type is `LMCache`, whose adapter supplies the kvevent-subscriber the routing tier needs. - `spec.autoscaling` is forbidden — there is no workload to scale. The rejection is field-scoped to `spec.autoscaling`. -Legacy `spec.endpoint` needs no events-only-specific rule: it is already rejected -on any non-`External` legacy backend (see [Validating](#validating)). - ### LMCache server / client version alignment The standalone lmcache-server image (`spec.remoteStorage.lmCacheServer.image`, default -`lmcache/standalone:v0.4.7`; legacy resources use -`backendConfig.serverImage`) and the **lmcache client** compiled into the engine +`lmcache/standalone:v0.4.7`) and the **lmcache client** compiled into the engine image (operator-supplied, or pip-installed into the engine at runtime) communicate over a versioned wire protocol. **They must be wire-compatible.** A mismatch does not fail loudly: remote KV stores fail (e.g. `[Errno 32] Broken @@ -491,8 +367,8 @@ The **same silent store-failure signature can also come from an under-provisione Because of this: -- The default `serverImage` is **pinned to a specific, non-floating version**, never `:latest`. A floating tag can drift to a server build whose wire protocol no longer matches the client, reintroducing the silent-disable failure mode on an unrelated pull. (The default tag `v0.4.7` is version-aligned with the validated lmcache 0.4.7 client, but the standalone server image was not independently wire-tested; confirm against a tested build — ideally an `@sha256:` digest — before release. See the `TODO` on `defaultLMCacheServerImage` in `pkg/adapters/backend/provider/lmcache_server.go`.) -- **Pin both sides.** When an operator overrides `remoteStorage.lmCacheServer.image` (or legacy `backendConfig.serverImage`), they must choose an lmcache-server version that is wire-compatible with the lmcache client version their engine image carries, and pin the engine's client too (a `pip install lmcache` at engine startup is itself a floating reference). For non-local runs, prefer an `@sha256:` digest. +- The default `serverImage` is **pinned to a specific, non-floating version**, never `:latest`. A floating tag can drift to a server build whose wire protocol no longer matches the client, reintroducing the silent-disable failure mode on an unrelated pull. (The default tag `v0.4.7` is version-aligned with the validated lmcache 0.4.7 client, but the standalone server image was not independently wire-tested; confirm against a tested build — ideally an `@sha256:` digest — before release. See the `TODO` on `defaultLMCacheServerImage` in `internal/adapters/builtin/storage/lmcache_server.go`.) +- **Pin both sides.** When an operator overrides `remoteStorage.lmCacheServer.image`, they must choose an lmcache-server version that is wire-compatible with the lmcache client version their engine image carries, and pin the engine's client too (a `pip install lmcache` at engine startup is itself a floating reference). For non-local runs, prefer an `@sha256:` digest. - IC **cannot auto-match** these versions: it has no source of truth for the engine's client version (the engine image is operator-supplied and the client may be pip-installed at runtime), so it cannot detect or warn on a skew today. The mitigation is this alignment contract plus the pinned default; runtime detection / a tier-2 health signal is a separate follow-up. ### LMCache client kernels ↔ engine-image CUDA / vLLM alignment @@ -581,11 +457,10 @@ that the round-trip probe cannot see. ### Mooncake provider configuration -Canonical `spec.remoteStorage.mooncake` selects the Mooncake provider adapter -(`pkg/adapters/backend/provider/mooncake.go`) to reconcile the standalone +`spec.remoteStorage.mooncake` selects the Mooncake provider adapter +(`internal/adapters/builtin/storage/mooncake.go`) to reconcile the standalone **Mooncake master** workload. The vLLM runtime adapter -(`pkg/adapters/runtime/vllm_mooncake.go`) separately wires engine pods to it; -legacy `spec.type: Mooncake` maps to the same provider/runtime pair. Mooncake is +separately wires engine pods to it through the LMCache remote-binding contract. Mooncake is the durable / shared cache path — the backend-type expression of the persistence decision in [`docs/design/lmcache-server-persistence.md`](lmcache-server-persistence.md) @@ -596,9 +471,9 @@ scalable one — durability is a backend choice, not a generic volume knob). > > * The namespace must **permit `hostNetwork`** — a Pod Security `restricted` namespace will reject the master pod. > * The master **reserves its ports (50051 / 8080 / 9003) on its node** (the API server defaults `hostPort=containerPort` for hostNetwork pods), and its Deployment uses the `Recreate` rollout strategy — a rolling surge would collide on those ports. -> * The master is a **singleton**. `spec.replicas > 1` and `spec.autoscaling` are **rejected at admission** when `remoteStorage.provider: Mooncake` (and for legacy `type: Mooncake`): a second replica either fails to schedule because its node ports are already bound or comes up as an independent master and silently splits the store. `spec.replicas: 0` (disabled) and `1` remain valid. Because update-validation only rejects *newly introduced* violations, the reconciler also clamps a grandfathered object to one replica and removes any HPA it owns. +> * The master is a **singleton**. `spec.replicas > 1` and `spec.autoscaling` are **rejected at admission** when `remoteStorage.provider: Mooncake`: a second replica either fails to schedule because its node ports are already bound or comes up as an independent master and silently splits the store. `spec.replicas: 0` (disabled) and `1` remain valid. > * **Network exposure — plan for it.** Host networking publishes the master's RPC (`50051`), metadata (`8080`) and metrics (`9003`) ports, plus the transfer engine's dynamically negotiated data ports, directly on the **node's interfaces**, outside the pod network. `NetworkPolicy` selects pods by pod IP and therefore **does not constrain a hostNetwork pod's listeners** — the isolation you get from pod-network policy is simply absent here. Restrict access with node-level controls instead: security-group / firewall rules on the node interfaces, and by constraining which nodes the master and its engines may schedule onto. Treat all of these ports as cluster-internal only; none of them authenticate callers. -> * **Engine pods need host networking too — opt in with `spec.integration.engineHostNetwork: true`.** Mooncake's mesh is dialed *from* the engine, so an overlay engine pod cannot participate. With the flag set, the Pod webhook moves matched engine pods onto the host network (`hostNetwork` + `dnsPolicy: ClusterFirstWithHostNet`) alongside the usual `LMCACHE_*` wiring. Until it is set, admission **warns on every canonical Mooncake `remoteStorage` apply** (and on legacy `type: Mooncake`) and the backend reports `Ready` while transferring **zero KV**. +> * **Engine pods need host networking too — opt in with `spec.integration.engineHostNetwork: true`.** Mooncake's mesh is dialed *from* the engine, so an overlay engine pod cannot participate. With the flag set, the Pod webhook moves matched engine pods onto the host network (`hostNetwork` + `dnsPolicy: ClusterFirstWithHostNet`) alongside the usual `LMCACHE_*` wiring. Until it is set, admission **warns on every Mooncake `remoteStorage` apply** and the backend reports `Ready` while transferring **zero KV**. > > It is opt-in, never injected by default, because it rewrites the networking of a pod **you** own. `hostNetwork` is a privilege, and mutating webhooks run **before** Pod Security validation — so silently adding it would turn a working engine pod into one a `restricted` namespace *rejects*, with an error naming Pod Security rather than this controller. The flag is rejected on backend types that do not need it, so it can never sit inert. > @@ -613,19 +488,19 @@ scalable one — durability is a backend choice, not a generic volume knob). > > This is inherent to Mooncake, not a choice the adapter can avoid. Host-only LMCache and the standalone LMCacheServer provider are unaffected and stay on the pod network. -**Mooncake is wired as an LMCache *remote backend*, not vLLM's native MooncakeStoreConnector.** The engine runs the *same* LMCache connector the LMCache backend uses (`kv_connector=LMCacheConnectorV1`) pointed at a `mooncakestore://host:port` remote store — the Mooncake analog of `lm://`. So the engine-side injected wire is byte-identical to the [managed-LMCache engine-side wire](#backendconfig-keys-managed-lmcache) **except** that `LMCACHE_REMOTE_URL` carries the `mooncakestore://` scheme. The native `MooncakeStoreConnector` is configured exclusively through a `MOONCAKE_CONFIG_PATH` JSON file (it has no env-var surface for the master address), and the pod-mutating webhook can only inject env + args — it cannot write a file into a user-owned engine container — so routing the controller-resolved master endpoint through `LMCACHE_REMOTE_URL=mooncakestore://…` is the only path that lets `status.endpoint` reach the engine via injection alone. Operators who prefer the native connector pre-bake their own config file; this adapter targets the auto-wired path. +**Mooncake is wired as an LMCache *remote backend*, not vLLM's native MooncakeStoreConnector.** The engine runs the *same* LMCache connector the LMCache backend uses (`kv_connector=LMCacheConnectorV1`) pointed at a `mooncakestore://host:port` remote store — the Mooncake analog of `lm://`. So the engine-side injected wire follows the same [pod-webhook engine-wiring contract](#mutating-pod-webhook-engine-wiring) **except** that `LMCACHE_REMOTE_URL` carries the `mooncakestore://` scheme. The native `MooncakeStoreConnector` is configured exclusively through a `MOONCAKE_CONFIG_PATH` JSON file (it has no env-var surface for the master address), and the pod-mutating webhook can only inject env + args — it cannot write a file into a user-owned engine container — so routing the controller-resolved master endpoint through `LMCACHE_REMOTE_URL=mooncakestore://…` is the only path that lets `status.endpoint` reach the engine via injection alone. Operators who prefer the native connector pre-bake their own config file; this adapter targets the auto-wired path. Provider-side fields consumed by `provider.ResolveMooncakeServer`: | Field | Default | Purpose | |---|---|---| -| `remoteStorage.mooncake.image` | `docker.io/kvcacheai/mooncake:0.3.11.post1` *(pinned, non-floating; fully qualified)* | Container image for the standalone Mooncake master. Fully qualified (`docker.io/…`) so CRI-O nodes without short-name resolution configured do not reject it; it is version-aligned with the `mooncake-transfer-engine` 0.3.11.post1 release on PyPI. Pin to an `@sha256:` digest for non-local runs. Legacy resources retain `backendConfig.serverImage` as a read-time compatibility source. | -| `remoteStorage.mooncake.command` | `mooncake_master --rpc_port=50051 --metrics_port=9003 --enable_http_metadata_server=true --http_metadata_server_host=0.0.0.0 --http_metadata_server_port=8080` | Master command and arguments. The default launches RPC, Prometheus metrics, and the embedded HTTP metadata server. **Do not change the RPC (50051) or HTTP metadata (8080) ports through this override**: the rendered Service, readiness probe, status endpoint, and engine binding use those fixed values and are not derived from free-form command text. Legacy resources retain `backendConfig.serverCommand`. | +| `remoteStorage.mooncake.image` | `docker.io/kvcacheai/mooncake:0.3.11.post1` *(pinned, non-floating; fully qualified)* | Container image for the standalone Mooncake master. Fully qualified (`docker.io/…`) so CRI-O nodes without short-name resolution configured do not reject it; it is version-aligned with the `mooncake-transfer-engine` 0.3.11.post1 release on PyPI. Pin to an `@sha256:` digest for non-local runs. | +| `remoteStorage.mooncake.command` | `mooncake_master --rpc_port=50051 --metrics_port=9003 --enable_http_metadata_server=true --http_metadata_server_host=0.0.0.0 --http_metadata_server_port=8080` | Master command and arguments. The default launches RPC, Prometheus metrics, and the embedded HTTP metadata server. **Do not change the RPC (50051) or HTTP metadata (8080) ports through this override**: the rendered Service, readiness probe, status endpoint, and engine binding use those fixed values and are not derived from free-form command text. | | `remoteStorage.mooncake.resources` | memory request `4Gi`, limit `8Gi` | Resources for the managed master container. An explicit typed block replaces the defaults; autoscaling also supplies a `250m` CPU request when no positive CPU request is present. | The Service exposes the master's **RPC port (50051) first** so the reconciler's engine-agnostic `serviceEndpoint` helper publishes it into `status.endpoint`, plus the HTTP metadata port (8080). -Engine-side: the adapter injects the same `--kv-transfer-config '{"kv_connector":"LMCacheConnectorV1","kv_role":""}'` arg and the same `LMCACHE_*` / `VLLM_USE_V1` / `INFERENCECACHE_FAIL_OPEN` / `PYTHONHASHSEED` env as an LMCacheServer binding, with `LMCACHE_REMOTE_URL=mooncakestore://`. Canonical chunk size, serializer, and host-memory settings come from `spec.lmCache`; legacy resources retain their `backendConfig` compatibility keys. The reserved args/env are therefore identical. The kvevent-subscriber sidecar is also identical (the KV-event stream comes from vLLM, not the L2 store; `--hash-scheme=vllm`, `--ignore-block-removed=true`). +Engine-side: the adapter injects the same `--kv-transfer-config '{"kv_connector":"LMCacheConnectorV1","kv_role":""}'` arg and the same `LMCACHE_*` / `VLLM_USE_V1` / `INFERENCECACHE_FAIL_OPEN` / `PYTHONHASHSEED` env as an LMCacheServer binding, with `LMCACHE_REMOTE_URL=mooncakestore://`. Chunk size, serializer, and host-memory settings come from `spec.lmCache`. The reserved args/env are therefore identical. The kvevent-subscriber sidecar is also identical (the KV-event stream comes from vLLM, not the L2 store; `--hash-scheme=vllm`, `--ignore-block-removed=true`). **Transfer-engine tuning is operator-supplied, not env-injected.** Mooncake's static transfer-engine config (`metadata_server`, `protocol` tcp/rdma, `device_name`, segment sizes) lives in LMCache's `extra_config`, which is read from an engine-side config file (`LMCACHE_CONFIG_FILE` / `MOONCAKE_CONFIG_PATH`) — not from env vars, so the webhook cannot inject it. The adapter wires the controller-resolved master address + the connector; the transfer-engine defaults (P2P-handshake metadata) cover the simplest deployment, and operators provide a config file for a real RDMA / HTTP-metadata setup. A kind reference stack that validates the end-to-end Mooncake deployment shape (the A2-equivalent of the LMCache reference stack) is a tracked follow-up. The master image entrypoint + RPC/metadata/metrics ports are now confirmed on a live cluster; until that stack lands, treat the `extra_config` transfer-engine defaults and the full end-to-end deployment shape as not-yet-cluster-validated. @@ -633,7 +508,7 @@ Engine-side: the adapter injects the same `--kv-transfer-config '{"kv_connector" | Field | Type | Purpose | |---|---|---| -| `endpoint` | string | Observed endpoint clients should use. For External ownership this mirrors canonical `spec.remoteStorage.endpoint` (or legacy `spec.endpoint`); for Managed remote storage it is populated from the controller-rendered Service. It stays **empty** for host-only and events-only backends because neither has a remote provider address to publish. | +| `endpoint` | string | Observed endpoint clients should use. For External ownership this mirrors `spec.remoteStorage.endpoint`; for Managed remote storage it is populated from the controller-rendered Service. It stays **empty** for host-only and events-only backends because neither has a remote provider address to publish. | | `matchedEnginePods` | integer | Snapshot count, at the last reconcile, of pods in the CacheBackend's namespace whose labels satisfy `spec.engineSelector`. Pointer in Go so nil ("not yet computed") is distinguishable from an observed `0` ("computed and zero pods match"). Refreshed at reconcile cadence — not a real-time per-pod counter. The steady cadence is 30s; during known churn the reconciler uses a conditional 5s cadence when the observed matching Pod count differs from the desired replica sum of Deployments whose pod-template labels match the selector. This keeps the no-Pod-watch design while reducing stale operator output during rolling restarts. The field stays nil when no claim-capable selector is configured — both when `spec.engineSelector` is absent AND when `spec.engineSelector.matchLabels` is present but empty (the webhook treats an empty match map as no-claim by design, so the count is no-claim too). A CR that previously had a non-empty selector and just lost it gets its prior value cleared back to nil so the printer column does not advertise a stale match. | | `engineSelectorMessage` | string | Operator-facing diagnosis for selector drift. Set when `spec.engineSelector.matchLabels` is configured and `matchedEnginePods` is observed as `0` while engine pods are expected; the message echoes the selector (`spec.engineSelector.matchLabels={...}`) and states that no Pods in the namespace match. If the selector matches a Deployment that is intentionally scaled to zero, `matchedEnginePods` still reports the observed `0`, but this message stays empty because no engine pods are expected. Cleared once at least one pod matches, the matching Deployment is scaled to zero, or the selector is removed. The controller also emits a Normal `EngineSelectorUnmatched` Event when the initial observation is zero, when a previously non-zero match count transitions to zero, or when upgrading an existing zero-count status that did not yet have the diagnostic message; steady-state zero with an unchanged message does not re-emit. | | `failOpen` | boolean | Observed echo of the effective `spec.integration.failOpen`. Represented as a pointer in Go so an explicit `false` is serialized and operators can read the current mode from status alone. | @@ -649,7 +524,7 @@ Engine-side: the adapter injects the same `--kv-transfer-config '{"kv_connector" The set of published condition types depends on the backend's integration mode and type: - **Offload-managed backends** (`spec.integration.mode=Offload` on a managed type, where the controller renders a Deployment + Service) publish up to seven: `Ready`, `Degraded`, `Progressing`, `FunctionalProbeOK`, `EngineKernelsHealthy` (when a matched engine pod runs the lmcache kernel-check), `T2Degraded` (once a tier-2/LMCache backend has been exercised), and `EngineCompatibility` (when an injected engine pod is observed crash-looping after connector injection). -- **Host-only backends** (canonical resources with no `spec.remoteStorage`) publish `Ready`, `Degraded`, and `Progressing`, plus the engine-side advisory conditions when applicable. Their endpoint stays empty. `HostOnlyActive` is the base `Ready=True` reason before the KV-event gate overlays `AwaitingFirstKVEvent`, `KVEventsObserved`, or `NoKVEventsObserved`. +- **Host-only backends** (resources with no `spec.remoteStorage`) publish `Ready`, `Degraded`, and `Progressing`, plus the engine-side advisory conditions when applicable. Their endpoint stays empty. `HostOnlyActive` is the base `Ready=True` reason before the KV-event gate overlays `AwaitingFirstKVEvent`, `KVEventsObserved`, or `NoKVEventsObserved`. - **Events-only backends** (`spec.integration.mode=EventsOnly`) publish exactly three: `Ready`, `Degraded`, `Progressing`. `FunctionalProbeOK`, `T2Degraded`, `EngineKernelsHealthy`, and `EngineCompatibility` are **Offload-managed-only** and are **never** published on an events-only backend — there is no provisioned server to functionally probe, no tier-2 offload to mark degraded, no LMCache native kernels to check, and no injected KV connector that could be incompatible (events-only injects none); an Offload→EventsOnly flip clears all four (see [Events-only mode](#events-only-mode-specintegrationmode--eventsonly)). - **Externally owned remote storage** publishes `Ready` + `Progressing` only (there is no rollout to degrade and no probe to drive; the operator manages the provider out-of-band and the controller only validates and mirrors the endpoint). @@ -671,20 +546,18 @@ When the desired replica count is owned by an HPA (`spec.autoscaling` set) the c **Externally owned remote storage**: -Canonical resources express this shape with +Resources express this shape with `spec.remoteStorage.ownership: External`, the selected provider, and -`spec.remoteStorage.endpoint`. The legacy `spec.type: External` + -`spec.endpoint` shape maps to an external LMCacheServer binding. There is no -Deployment to roll out in either case, so provider-specific endpoint validation +`spec.remoteStorage.endpoint`. There is no Deployment to roll out, so provider-specific endpoint validation is the only readiness signal the controller has. The controller mirrors the trimmed endpoint to `status.endpoint` and publishes both conditions immediately on every reconcile (the KV-event gate never applies to external ownership): | Type | Status | Reason | Meaning | |---|---|---|---| -| `Ready` | `True` | `ExternalEndpointAccepted` | The active endpoint field is non-empty and valid for the selected provider. LMCacheServer accepts `host:port` or `lm://host:port`; Mooncake accepts `host:port` or `mooncakestore://host:port`; Redis accepts bare `host:port`. A port is always required, embedded whitespace and URL path/query/fragment components are rejected, and IPv6 must be bracketed. The controller provisions no provider pod for External ownership, so admission acceptance is the readiness signal. | +| `Ready` | `True` | `ExternalEndpointAccepted` | The active endpoint field is non-empty and valid for the selected provider. LMCacheServer accepts `host:port` or `lm://host:port`; Mooncake accepts `host:port` or `mooncakestore://host:port`; Redis accepts bare `host:port`. A numeric port in `1-65535` is always required, embedded whitespace and URL path/query/fragment components are rejected, and IPv6 must be bracketed. The controller provisions no provider pod for External ownership, so admission acceptance is the readiness signal. | | `Ready` | `False` | `ExternalEndpointMissing` | The active endpoint field is empty or whitespace-only. Current admission rejects this, so the state is reachable only for a CR already stored before the webhook was installed. Status reflects the gap loudly rather than dropping the condition. | -| `Ready` | `False` | `ExternalEndpointInvalid` | The active endpoint is non-empty but fails the selected provider's shape check. Current admission rejects these values; the reason is reachable only for a CR stored before the relevant rule shipped. The message names `spec.remoteStorage.endpoint` for canonical resources or `spec.endpoint` for legacy resources and carries the shape error. The pod webhook applies the same validation and admits the engine pod unwired on failure. | +| `Ready` | `False` | `ExternalEndpointInvalid` | The active endpoint is non-empty but fails the selected provider's shape check. Current admission rejects these values; the reason is reachable only for a CR stored before the relevant rule shipped. The message names `spec.remoteStorage.endpoint` and carries the shape error. The pod webhook applies the same validation and admits the engine pod unwired on failure. | | `Progressing` | `False` | mirrors Ready's reason | External ownership completes admission immediately — there is no rollout the controller is still driving. Always `False`; the reason matches Ready (`ExternalEndpointAccepted` / `ExternalEndpointMissing` / `ExternalEndpointInvalid`) so `kubectl describe` shows a coherent pair. | Reachability of an externally owned endpoint is **not** probed by the controller; @@ -716,7 +589,7 @@ The signal source is `status.indexParticipation.lastEventAt` (written by the Cac - **at least one event ever observed** → `Ready=True/KVEventsObserved`, `Degraded=False`. An event already present on the first reconcile counts — there is no required transition through `AwaitingFirstKVEvent`. "Ever observed" is durable: the first observation is latched into `status.firstKVEventObservedAt`, because the poller's `lastEventAt` is a current-view value it clears when the backend's replicas drain — without the latch a drained-but-healthy backend would wrongly fall back to `AwaitingFirstKVEvent`. The gate is a first-event startup probe, not an ongoing liveness check. - **no event by `firstEventTimeout`** → `Ready=False/NoKVEventsObserved`, `Degraded=True/NoKVEventsObserved`. Once Degraded it stays Degraded until an event arrives, then transitions to Ready. -The gate is **on by default** and opt-out per CR with the annotation `inferencecache.io/require-kv-events: "false"` (alpha soft-rollout knob; an annotation rather than a spec field so it can be retired once the gate is trusted). External ownership is **always exempt** — the control plane does not own a provider workload to gate, so readiness is determined by accepting the provider-specific `spec.remoteStorage.endpoint` (or legacy `spec.endpoint`) as described above. +The gate is **on by default** and opt-out per CR with the annotation `inferencecache.io/require-kv-events: "false"` (alpha soft-rollout knob; an annotation rather than a spec field so it can be retired once the gate is trusted). External ownership is **always exempt** — the control plane does not own a provider workload to gate, so readiness is determined by accepting the provider-specific `spec.remoteStorage.endpoint` as described above. **Operator note.** If a backend is stuck at `Ready=False/AwaitingFirstKVEvent` (and then flips to `Degraded=True/NoKVEventsObserved` after `firstEventTimeout`), either no engine pods are attached to the backend or the engine's KV-event publisher is mis-configured — check that engine pods are wired to the backend, then the engine's `--kv-events-config` and that its ZMQ socket bound. In `kubectl get cachebackend` the `Ready` column shows `False` and the `LASTEVENT` column shows ``; the specific reason (`AwaitingFirstKVEvent` / `NoKVEventsObserved`) and the remediation hint live in the `Ready` / `Degraded` conditions, which `kubectl describe` surfaces along with the `NoKVEventsObserved` Warning Event. @@ -767,15 +640,12 @@ The controller serves two webhooks for CacheBackend, both registered as `failure ### Defaulting (mutating) -Most Phase-1 literal defaults ride on `+kubebuilder:default=` markers stamped by the apiserver before the webhook runs (`spec.type=LMCache`, `spec.deploymentKind=Deployment`, `spec.replicas=1`, `spec.integration.mode=Offload`, `spec.integration.role=ReadWrite`, `spec.integration.failOpen=true`, `spec.integration.firstEventTimeout=5m`). The webhook handles context-dependent defaults; operator-set values are never clobbered. +Most Phase-1 literal defaults ride on `+kubebuilder:default=` markers stamped by the apiserver before the webhook runs (`spec.type=LMCache`, `spec.deploymentKind=Deployment`, `spec.replicas=1`, `spec.integration.mode=Offload`, `spec.integration.role=ReadWrite`, `spec.integration.failOpen=true`, `spec.observation.firstEventTimeout=5m`). The webhook handles context-dependent defaults; operator-set values are never clobbered. | Field | Default | Layer | |---|---|---| -| `spec.type`, `spec.deploymentKind`, `spec.replicas`, `spec.integration.{mode,role,failOpen,firstEventTimeout}` | per-field literals (see field godoc) | `+kubebuilder:default=` markers — apiserver | -| `spec.integration.engine` | canonical: derived from `spec.runtime`; legacy: `vllm` | mutating webhook | -| `spec.observation.firstEventTimeout` | `5m` for canonical resources | mutating webhook | -| legacy `spec.resources` | `{requests: {memory: "4Gi"}, limits: {memory: "8Gi"}}` | mutating webhook; canonical resources leave the deprecated field absent. See [Resources](#resources). | -| `spec.integration.firstEventTimeout` (when `spec.integration` is omitted entirely) | `5m` | webhook materialises `spec.integration` so the nested marker has a parent object to apply to | +| `spec.type`, `spec.deploymentKind`, `spec.replicas`, `spec.integration.{mode,role,failOpen}`, `spec.observation.firstEventTimeout` | per-field literals (see field godoc) | `+kubebuilder:default=` markers — apiserver | +| `spec.observation.firstEventTimeout` (when `spec.observation` is omitted entirely) | `5m` | webhook materialises `spec.observation` so the nested marker has a parent object to apply to | | `spec.autoscaling.minReplicas` (FIRST APPLY ONLY, when `spec.autoscaling != nil` and `spec.autoscaling.minReplicas == nil`) | `= spec.replicas` (post-marker-default; skipped when `spec.replicas` is 0 to avoid violating the schema's `Minimum=1`) | webhook | The `spec.autoscaling.minReplicas` default is **first-apply only**. The defaulter refuses to overwrite a non-nil value, AND once stamped the field is owned by the apiserver field manager, so a subsequent edit to `spec.replicas` does NOT recompute or move `minReplicas`. This matches the standard Kubernetes HPA convention that scaling intent flows through HPA fields once an HPA owns the workload — to widen or narrow the autoscaling band post-apply, edit `spec.autoscaling.minReplicas` directly. (The `replicas=0` + autoscaling + nil minReplicas case is rejected at admission rather than defaulted; see the validator table below.) @@ -786,55 +656,36 @@ Rejects structurally-broken specs that the reconciler cannot do anything useful | Rule | Rejects | |---|---| -| Canonical hierarchy fields cannot conflict | `spec.runtime` conflicts with deprecated `integration.engine`; `type` uses legacy provider/ownership values `Mooncake` or `External`; deprecated top-level `backendConfig` or `resources` is supplied; or a provider-specific typed block does not match `remoteStorage.provider`/`ownership`. | -| External remote storage requires an endpoint | Canonical `remoteStorage.ownership=External` without `remoteStorage.endpoint`, or legacy `spec.type=External` without `spec.endpoint`; managed ownership rejects a user-supplied endpoint. | -| Engine wire must accept the provider binding | Every canonical `(runtime, type)` adapter must explicitly implement the remote-binding contract, and admission rejects a binding it does not accept (`lm`, `resp`, `mooncakestore`, or host-only). Native SGLang HiCache accepts only the nil host-only binding; attaching any `remoteStorage` is rejected. Deprecated legacy resources retain their endpoint-based compatibility fallback. | -| Provider resources must be valid | Typed provider resource blocks are checked with the same request/limit, claims, quantity, resource-name, extended-resource, and hugepage rules as legacy `spec.resources`, with errors reported at the typed provider path. | -| Endpoint ownership is explicit | Canonical `spec.remoteStorage.endpoint` is required for External ownership and rejected for Managed ownership. Legacy `spec.endpoint` is valid only with legacy `spec.type=External`. A managed endpoint always comes from the live Service the controller provisions, so a user-supplied value would be misleading. Whitespace-only values are treated as empty. | -| Cross-namespace endpoint requires opt-in | Canonical `spec.remoteStorage.endpoint` or legacy `spec.endpoint` resolves to a Service in a namespace other than the CacheBackend's, while `spec.allowCrossNamespace` is `false`. Crossing the namespace is a tenancy boundary the operator must acknowledge. Bare hostnames, IPs, and unqualified names pass through because no namespace can be inferred. | +| Cache hierarchy must be internally consistent | A provider-specific typed block does not match `remoteStorage.provider`/`ownership`, `lmCache` is used with a non-LMCache type, or host-only configuration requests workload autoscaling. | +| External remote storage requires an endpoint | `remoteStorage.ownership=External` without `remoteStorage.endpoint`; managed ownership rejects a user-supplied endpoint. | +| Engine wire must accept the provider binding | Every `(runtime, type)` adapter must explicitly implement the remote-binding contract, and admission rejects a binding it does not accept (`lm`, `resp`, `mooncakestore`, or host-only). Native SGLang HiCache accepts only the nil host-only binding; attaching any `remoteStorage` is rejected. | +| Provider resources must be valid | Typed provider resource blocks are checked for request/limit relationships, claims, quantities, resource names, extended resources, and hugepage alignment, with errors reported at the selected provider path. | +| Endpoint ownership is explicit | `spec.remoteStorage.endpoint` is required for External ownership and rejected for Managed ownership. A managed endpoint always comes from the live Service the controller provisions, so a user-supplied value would be misleading. Whitespace-only values are treated as empty. | +| Cross-namespace endpoint requires opt-in | `spec.remoteStorage.endpoint` resolves to a Service in a namespace other than the CacheBackend's, while `spec.allowCrossNamespace` is `false`. Crossing the namespace is a tenancy boundary the operator must acknowledge. Bare hostnames, IPs, and unqualified names pass through because no namespace can be inferred. | | `spec.replicas=0` + autoscaling requires explicit `minReplicas` | `spec.replicas=0` with `spec.autoscaling != nil` and `spec.autoscaling.minReplicas == nil`. The defaulter declines to compute `minReplicas` from a 0 replicas value (it would violate the schema's `Minimum=1`), so without this rule the apiserver accepts the CR and the reconciler's HPA fallback silently picks `1` — overriding the operator's "scale to zero" intent with no notification. The rejection tells the operator to either set `minReplicas` explicitly or remove `spec.autoscaling` to scale to zero unconditionally. | | `spec.integration.engineOverrides` cannot touch reserved args/env | An entry in `engineOverrides.args` / `engineOverrides.suppressArgs` matches a leading flag token the adapter declares as `ReservedArgs()`, or an entry in `engineOverrides.env` / `engineOverrides.suppressEnv` matches a name in `ReservedEnv()`. The rejection names both the offending flag/env and the adapter so the operator can fix the spec rather than wait for the engine to crash. The reserved set is per-adapter (the vLLM+LMCache adapter reserves `--kv-transfer-config`, `VLLM_USE_V1`, `LMCACHE_REMOTE_URL`, `INFERENCECACHE_FAIL_OPEN`, `PYTHONHASHSEED`). | -| `spec.resources.limits` and `requests` must agree per-resource | The request/limit relationship is **resource-aware**. For overcommittable resources (`cpu`, `memory`, `ephemeral-storage`) `limits[X]` must be ≥ `requests[X]`. For non-overcommittable resources (`hugepages-*` and vendor-prefixed extended resources) `limits[X]` must EQUAL `requests[X]` — K8s does not allow overcommitting dedicated pages or devices. Limits-only shapes admit unchanged for any resource (K8s auto-populates requests from limits). The rule mirrors K8s' Pod-level validation so the rejection lands at `kubectl apply` rather than at child-pod scheduling. | -| Requests-only is rejected for non-overcommittable resources | A non-overcommittable resource (`hugepages-*` or vendor-prefixed extended resource) is set in `spec.resources.requests` without a matching entry in `spec.resources.limits`. K8s requires the two halves to be declared together for non-overcommittable resources because the kubelet allocates whole pages or devices. Overcommittable resources (cpu, memory, ephemeral-storage) are unaffected — requests-only is a valid kubelet shape for them. | -| `spec.resources.claims` is not supported | `spec.resources.claims` (Dynamic Resource Allocation binding names) is non-empty. The runtime adapter only copies `Container.Resources` and does not yet plumb pod-level `spec.resourceClaims`, so a claim-bound `container.resources.claims` would render a pod the apiserver rejects (claim name does not resolve at the pod level). Reject at admission until DRA is wired end-to-end. | -| Extended-resource quantities must be integers | A vendor-prefixed extended resource (e.g. `nvidia.com/gpu`) in `spec.resources.requests` or `spec.resources.limits` carries a fractional value (e.g. `500m`). K8s allocates extended resources by whole units, so the apiserver rejects fractional shapes downstream. Standard overcommittable resources (cpu, memory, ephemeral-storage) and hugepages are unaffected by this rule. | -| Hugepage quantities must align to the page size | A `hugepages-` quantity in `spec.resources.requests` or `spec.resources.limits` is a positive value that is not a whole multiple of `` (e.g. `hugepages-2Mi: 3Mi`). The Linux kernel allocates hugepages in page-sized chunks, so K8s rejects the misaligned shape on the rendered Pod. Zero is trivially aligned and admits. | -| `spec.resources.{requests,limits}[*]` must be non-negative | Any quantity in `spec.resources.requests` or `spec.resources.limits` is strictly negative (e.g. `"-1Gi"`). The CRD-schema layer serialises each entry as a `resource.Quantity` string and admits a leading `-` without complaint; the apiserver's Pod resource validator only flags it once the child pod tries to schedule. Reject at admission with a field-scoped error so the regression surfaces at `kubectl apply` rather than chasing it through child Deployment events. Zero is admitted (matches the kubelet's `>= 0` contract — an operator who writes `requests.memory: "0"` is explicitly opting into "no guaranteed minimum"). | -| `spec.resources.{requests,limits}` keys must be valid container resource names | Any key fails the K8s container-resource rules. `ResourceList` keys are opaque map keys at the CRD-schema layer, so an invalid name is admitted by the schema and only surfaces when the apiserver later rejects the child pod. Reject at admission with a field-scoped error so `kubectl apply` is the breadcrumb. Standard names (`cpu`, `memory`, `ephemeral-storage`) admit unconditionally; `hugepages-` admits only when the size suffix parses as a strictly-positive `resource.Quantity` (e.g. `"hugepages-2Mi"`); any other name must be **third-party vendor-prefixed** (e.g. `"nvidia.com/gpu"`) and satisfy `IsQualifiedName`. A bare unqualified name like `"foo"` is rejected because K8s container resources require extended resources to carry a vendor identity. Names under the K8s-reserved prefixes `kubernetes.io/` and `requests.kubernetes.io/` are also rejected — those prefixes are reserved for native resources, not for operator-declared extended resources. | -| Runtime/cache pair must be supported by an installed adapter | The effective `(runtime, engine-cache type)` pair has no registered runtime adapter, so the reconciler cannot observe engine compatibility and the pod webhook would fail open without injecting engine config. Canonical runtime comes from `spec.runtime`; legacy resources fall back to lower-cased `spec.integration.engine`, defaulting to vLLM. The canonical shipping pairs are `VLLM/LMCache`, `SGLang/LMCache`, and `SGLang/SGLangHiCache`; remote provider selection is validated independently through `remoteStorage`. Legacy `type: External` and `type: Mooncake` retain compatibility adapters. The registry's `SupportedPairs` list is authoritative and is included in the field-scoped rejection. | -| Events-only requires `spec.type=LMCache` | `spec.integration.mode=EventsOnly` with any `spec.type` other than `LMCache` (the default). Events-only wires no KV connector, so a backend that provisions an offload store is contradictory: `External` runs an operator-managed offload server the (absent) connector would dial, and a managed `Mooncake` backend stands up a `mooncake_master` store nothing would use. `LMCache` — whose adapter supplies the kvevent-subscriber the routing tier needs, and whose in-memory server is a no-op when no connector is wired — is the only supported events-only managed type. Field-scoped to `spec.integration.mode` (`External` keeps a specific message). Explicitly enforced because the `(vLLM, Mooncake)` adapter is registered — before it shipped, non-`LMCache` managed types were caught by the runtime-adapter check. See [Events-only mode](#events-only-mode-specintegrationmode--eventsonly). | +| Provider resource limits and requests must agree | Under `spec.remoteStorage..resources`, overcommittable resource limits must be ≥ requests; hugepages and extended resources must use equal request/limit values. | +| Requests-only is rejected for non-overcommittable resources | A hugepage or vendor-prefixed extended resource is present in a provider `resources.requests` map without a matching limit. | +| Provider `resources.claims` is not supported | A selected provider resource block contains Dynamic Resource Allocation claim names, but the renderer does not yet create matching pod-level `spec.resourceClaims`. | +| Extended-resource quantities must be integers | A selected provider resource block gives a vendor-prefixed extended resource a fractional value. | +| Hugepage quantities must align to the page size | A selected provider resource block contains a positive `hugepages-` quantity that is not a whole multiple of its page size. | +| Provider resource quantities must be non-negative | A selected provider `resources.requests` or `resources.limits` entry is negative. | +| Provider resource names must be valid | A selected provider resource key is not a valid standard, hugepage, or vendor-prefixed container resource name. | +| Runtime/cache pair must be supported by an installed adapter | The `(runtime, engine-cache type)` pair has no registered runtime adapter, so the reconciler cannot observe engine compatibility and the pod webhook would fail open without injecting engine config. The shipping pairs are `VLLM/LMCache`, `SGLang/LMCache`, and `SGLang/SGLangHiCache`; remote provider selection is validated independently through `remoteStorage`. The registry's `SupportedPairs` list is included in the field-scoped rejection. | +| Events-only requires `spec.type=LMCache` | `spec.integration.mode=EventsOnly` with any `spec.type` other than `LMCache` (the default). Events-only wires no KV connector, so declaring an offload-oriented cache type is contradictory. `LMCache` supplies the kvevent-subscriber that the routing tier needs. See [Events-only mode](#events-only-mode-specintegrationmode--eventsonly). | | Events-only forbids `spec.autoscaling` | `spec.integration.mode=EventsOnly` with `spec.autoscaling` set. An events-only backend provisions no server workload, so there is nothing to autoscale. Field-scoped to `spec.autoscaling`. | The structural rules are an ordered, pluggable list (`CacheBackendValidator.Rules`); the runtime/backend compatibility check runs separately because it needs to consult the shared `adapterruntime.Registry` rather than just the spec. `ValidateUpdate` only rejects violations the update *introduces*: errors that already existed on the previous object are filtered out so an unrelated edit (a label tweak, an annotation) on a CR admitted under a laxer rule set is not suddenly un-updatable. A `kubectl edit` that flips a previously-valid field into an invalid one is still rejected, because the violation is then new to the diff. Errors are compared by `(Type, Field, BadValue, Detail)`, so an operator changing one bad endpoint to a different bad endpoint on the same field counts as a fresh violation — the rule still bites when the operator actively edits the bad field. -### Migration - -The validating rules tighten what `v1alpha1` accepts, so they ship together with the admission webhook itself (a previously-uninstalled webhook). Tightening applies at write time only: - -- Existing stored CacheBackends that were applied before the webhook is installed remain in etcd and are unaffected until they are next created or mutated. -- **Create** still applies the full rule set: a previously-stored-but-now-invalid CR cannot be re-created. -- **Update** only rejects violations the new object *introduces* (the diff-only rule above): an unrelated edit (`kubectl annotate`, a label tweak, an unrelated spec field) on a now-invalid CR is allowed through, so operators aren't locked out of their existing objects. An edit that flips a previously-valid field into an invalid one — or that changes one bad value on a still-invalid field into a different bad value — is still rejected, because the violation is then new to the diff. -- An operator who wants to bring a stored CR into compliance with the new rules can do so incrementally (clear the offending field, switch type, etc.); the diff-only semantics mean the bring-into-compliance edit doesn't have to atomically fix every existing violation. -- The cluster-wide rollout knob is the webhook's `failurePolicy`; future tightenings that need a softer rollout can switch to `Ignore` for one release before flipping to `Fail`. - -**`spec.endpoint` type-scoping** is a specific tightening worth calling out: the field was always documented as "an existing external backend" but admission did not enforce that scoping in earlier builds. Now `spec.endpoint` is REQUIRED on `External` (admission rejects empty) and REJECTED on every other type (admission rejects non-empty). The locked design contract is that admission is loud about misconfigurations at write time rather than silently overwriting a user-supplied endpoint with the controller-rendered one. The diff-only update semantics above mean existing stored CRs with the legacy `(LMCache, endpoint=foo)` combination remain editable for unrelated changes; only a new CREATE or an edit that introduces (or changes) the offending combination is rejected. Operators bringing a stored CR into compliance clear `spec.endpoint` or switch `spec.type` to `External` — both can be done at update time, no special migration tool required. - -**`spec.resources` legacy webhook default** remains operationally significant: an existing legacy CacheBackend with `spec.resources = nil` receives `{requests: {memory: "4Gi"}, limits: {memory: "8Gi"}}` on its next admitted update. The controller then renders the lmcache-server container with those bounds, which can trigger a rolling update. The replacement pod carries a 4Gi memory request, so operators on tightly packed nodes should plan capacity or pre-stamp one of the opt-outs below: -- **Stamp `spec.resources: {}` on the existing CR before the upgrade rolls past** — the empty struct is honored as suppression of the default (no requests, no limits rendered). -- **Pre-stamp the operator's intended values** — supply `spec.resources` with the limits/requests the operator actually wants. The defaulter does not clobber non-nil operator values. - -Canonical resources reject top-level `spec.resources`; configure -`spec.remoteStorage..resources` instead. If that typed field is -omitted, the provider renderer applies the bounded default without persisting -legacy configuration into the CR. +### Breaking API cleanup -The diff-only update semantics also apply: an existing CR with a now-invalid `resources` shape (e.g. `claims` set from before this PR landed, an invalid resource name carried over from a hand-edited manifest) remains editable for unrelated fields and only fails admission on an edit that introduces or worsens the offending value — operators are never locked out of bringing the CR into compliance incrementally. +Inference-cache has not been formally deployed, so this version does not ship a resource conversion or compatibility reader. Manifests must use `spec.runtime`, typed `spec.lmCache`, `spec.remoteStorage.`, `spec.observation`, and provider-owned `resources`. The removed `spec.integration.engine`, `spec.integration.firstEventTimeout`, `spec.backendConfig`, and top-level `spec.resources` fields are not part of the served CRD schema. ### Engine-injection overrides (`spec.integration.engineOverrides`) -`spec.integration.engineOverrides` lets the operator amend the non-reserved args/env the pod-mutating webhook injects into the engine container — without forking an adapter. It is the user-facing seam that today's CPU-vLLM-with-LMCache use case and other adapters (the vLLM+Mooncake and SGLang+LMCache adapters today) reach to tune adapter-injected knobs (chunk size, max model length, serdes) that the canonical injection would otherwise hard-code. The reserved set (per locked decision #5/#6 below) makes this surface unsuitable for turning the integration *off*: operators who need to skip injection entirely on a pod should use the `inferencecache.io/skip-inject` annotation instead. +`spec.integration.engineOverrides` lets the operator amend the non-reserved args/env the pod-mutating webhook injects into the engine container — without forking an adapter. It is the user-facing seam that today's CPU-vLLM-with-LMCache use case and the SGLang+LMCache adapter reach to tune adapter-injected knobs (chunk size, max model length, serdes) that the canonical injection would otherwise hard-code. The reserved set (per locked decision #5/#6 below) makes this surface unsuitable for turning the integration *off*: operators who need to skip injection entirely on a pod should use the `inferencecache.io/skip-inject` annotation instead. Shape, in `corev1` vocabulary: @@ -847,7 +698,7 @@ Shape, in `corev1` vocabulary: The "adapter-owned" set is derived by the webhook at admission time by diffing the engine container's args/env immediately before and after `InjectEngineConfig` runs. A flag/env is adapter-owned if the adapter added it OR modified an existing value. User pod-template entries the adapter does not touch are protected from CR-driven mutation — the CR can amend the engine integration, but not silently rewrite the engine pod owner's own template. -No `command` override (the entrypoint stays user-owned). No `resources` override on the engine container here — engine-pod resources are user-owned via the engine's own pod template, not this CR. Managed provider resources are configured under `spec.remoteStorage..resources`; top-level [`spec.resources`](#resources) remains a legacy-only compatibility field. +No `command` override (the entrypoint stays user-owned). No `resources` override on the engine container here — engine-pod resources are user-owned via the engine's own pod template, not this CR. Managed provider resources are configured under `spec.remoteStorage..resources`. The CRD field default is byte-identical to the prior behavior: a CacheBackend with no `engineOverrides` block renders the same injected patch as before. @@ -858,16 +709,15 @@ Each `KVCacheRuntimeAdapter` declares two methods: - `ReservedArgs() []string` — leading flag tokens the user MUST NOT override or suppress. - `ReservedEnv() []string` — env var names the user MUST NOT override or suppress. -The validating webhook iterates the adapter's reserved lists (resolved from -canonical `spec.runtime`, with `spec.integration.engine` retained as a legacy -fallback) and **hard-rejects** any `engineOverrides.{args,suppressArgs}` entry +The validating webhook selects the adapter from `spec.runtime`, then iterates +its reserved lists and **hard-rejects** any `engineOverrides.{args,suppressArgs}` entry that overlaps `ReservedArgs()` and any `engineOverrides.{env,suppressEnv}` entry that overlaps `ReservedEnv()`. The rejection names the offending flag/env and the adapter. Warning-only would let a user silently un-wire the integration and discover it via a crashed engine; the hard-reject keeps the breadcrumb at admission time. -The vLLM+LMCache adapter (`pkg/adapters/runtime/vllm_lmcache.go`) reserves the args/env the integration cannot function without: +The vLLM+LMCache adapter (`internal/adapters/builtin/runtime/vllm_lmcache.go`) reserves the args/env the integration cannot function without: - `ReservedArgs()`: `--kv-transfer-config` (the LMCache connector wiring). - `ReservedEnv()`: `VLLM_USE_V1` (selects the engine codepath the connector targets), `LMCACHE_REMOTE_URL` (the resolved cache endpoint), `INFERENCECACHE_FAIL_OPEN` (mirror of `spec.integration.failOpen` — overriding it would silently desync the pod from the CR contract), `PYTHONHASHSEED` (pins the deterministic `NONE_HASH` so LMCache reload matches under TP>1 — overriding or suppressing it silently 0-hits reload). @@ -875,13 +725,12 @@ The vLLM+LMCache adapter (`pkg/adapters/runtime/vllm_lmcache.go`) reserves the a The same reserved set applies when the canonical vLLM/LMCache engine cache has an External LMCacheServer binding or a Mooncake binding: the selected runtime adapter still runs the LMCache connector and varies only the structured -binding's protocol and endpoint. The legacy External and Mooncake runtime -adapters declare the same set for compatibility. Admission therefore rejects +binding's protocol and endpoint. Admission therefore rejects an override that would remove connector wiring regardless of provider ownership. See [Mooncake provider configuration](#mooncake-provider-configuration). -The SGLang+LMCache adapter (`pkg/adapters/runtime/sglang`) reserves a **different** set, because SGLang's engine-side wire is the LMCache MP wire, not the `lm://` one (see [SGLang engine support](#sglang-engine-support)): `ReservedArgs()` = `--enable-lmcache`, `--lmcache-config-file`; `ReservedEnv()` = `LMCACHE_USE_EXPERIMENTAL`, `INFERENCECACHE_FAIL_OPEN`. Suppressing `--lmcache-config-file` un-wires MP mode (the engine aborts at startup without it), hence its reservation. In MP mode the lm:// `LMCACHE_REMOTE_URL` is neither injected nor reserved, and `VLLM_USE_V1` / `PYTHONHASHSEED` are never injected for SGLang. Reservation is per-adapter precisely so each engine guards only the flags/env its own integration cannot function without. +The SGLang+LMCache adapter (`internal/adapters/builtin/runtime`) reserves a **different** set, because SGLang's engine-side wire is the LMCache MP wire, not the `lm://` one (see [SGLang engine support](#sglang-engine-support)): `ReservedArgs()` = `--enable-lmcache`, `--lmcache-config-file`; `ReservedEnv()` = `LMCACHE_USE_EXPERIMENTAL`, `INFERENCECACHE_FAIL_OPEN`. Suppressing `--lmcache-config-file` un-wires MP mode (the engine aborts at startup without it), hence its reservation. In MP mode the lm:// `LMCACHE_REMOTE_URL` is neither injected nor reserved, and `VLLM_USE_V1` / `PYTHONHASHSEED` are never injected for SGLang. Reservation is per-adapter precisely so each engine guards only the flags/env its own integration cannot function without. `LMCACHE_CHUNK_SIZE`, `LMCACHE_REMOTE_SERDE`, `LMCACHE_LOCAL_CPU`, `LMCACHE_MAX_LOCAL_CPU_SIZE` are deliberately NOT reserved — they are perf/mode tunables the operator may legitimately want to change. Canonical chunk size, serializer, and host-memory capacity use `spec.lmCache`; `engineOverrides.env` remains the engine-agnostic seam for explicit environment-level tuning. @@ -890,9 +739,9 @@ The SGLang+LMCache adapter (`pkg/adapters/runtime/sglang`) reserves a **differen Two shapes were on the table: - **A — typed K8s vocabulary** (`[]string` args, `[]corev1.EnvVar` env, plus suppression). Chosen. -- **B — backendConfig magic keys** (`cpuMode: "true"`, `gpuLimit: "0"`, `extraArgs: "..."`). Rejected. +- **B — free-form magic keys** (`cpuMode: "true"`, `gpuLimit: "0"`, `extraArgs: "..."`). Rejected. -A is more general: the Mooncake adapter, the SGLang adapter, and further engine/backend pairs plug in with no per-adapter `backendConfig` schema churn. It keeps the CRD disciplined (no permanent v1alpha1 legacy keys). B is faster to ship but bakes engine-specific knobs into the CRD, which is the trap an "engine-agnostic backend" surface is meant to avoid. +A is more general: Mooncake remote bindings, the SGLang adapter, and further engine/backend pairs plug in with no per-adapter free-form schema churn. It keeps the CRD disciplined. B is faster to ship but bakes engine-specific knobs into the CRD, which is the trap an "engine-agnostic backend" surface is meant to avoid. #### Residual risk @@ -909,7 +758,7 @@ A separate mutating admission webhook on `corev1/v1.Pod` (`name: mpod.inferencec | Aspect | Behavior | |---|---| | Selection | Lists `CacheBackend`s in the pod's namespace via the manager's **APIReader** (uncached live client; an informer-cache miss on a freshly-Ready backend would leave the pod permanently unwired since pod CREATE is a one-shot), then matches `pod.Labels` against each `Spec.EngineSelector.MatchLabels`. The first matching `CacheBackend` wins; one with a nil or empty `EngineSelector` is skipped (a "match-everything" selector would silently claim every pod in the namespace). | -| Injection | Resolves the runtime adapter via `runtime.Registry.Select(runtimeID, cache)`, resolves `spec.EffectiveRemoteStorage()` independently, and constructs a structured provider `Binding{Protocol, Endpoint}`. Managed ownership uses `status.endpoint` from the live Service; External ownership uses the trimmed, provider-validated `spec.remoteStorage.endpoint` (or legacy `spec.endpoint`) with no fallback to stale status; omitted canonical `remoteStorage` produces a nil host-only binding. The webhook calls `runtime.InjectEngineConfigWithBinding`, so the adapter selects the LMCache, RESP, or Mooncake engine wire from the binding protocol instead of inferring storage from `spec.type`. A missing endpoint fails open only when the selected adapter and binding require one. Events-only skips engine injection because it wires no KV connector and appends only the kvevent-subscriber sidecar. Adapters preserve existing user args/env and make repeat injection idempotent. | +| Injection | Resolves the runtime adapter via `runtime.Registry.Select(runtimeID, cache)`, resolves `spec.remoteStorage` independently, and constructs a structured provider `Binding{Protocol, Endpoint}`. Managed ownership uses `status.endpoint` from the live Service; External ownership uses the trimmed, provider-validated `spec.remoteStorage.endpoint` with no fallback to stale status; omitted `remoteStorage` produces a nil host-only binding. `SupportsBinding` is part of the required runtime adapter interface, and the webhook passes the binding directly to `adapter.InjectEngineConfig`, so the adapter selects the LMCache, RESP, or Mooncake engine wire from the binding protocol instead of inferring storage from `spec.type`. A non-nil binding with a missing endpoint fails open. Events-only skips engine injection because it wires no KV connector and appends only the kvevent-subscriber sidecar. Adapters preserve existing user args/env and make repeat injection idempotent. | | Annotations | Stamps TWO annotations on every successfully mutated pod: `inferencecache.io/injected-by: /` (operator-readable identity, shows in `kubectl describe pod`) AND `inferencecache.io/injected-by-uid: ` (the matched CR's metadata.uid). Successful injection also clears any stale `inferencecache.io/inject-skipped` marker. Reads `inferencecache.io/skip-inject: ` as an opt-out: the webhook returns Allowed, skips engine wiring, clears any stale injected-by/injected-by-uid pair, and stamps `inferencecache.io/inject-skipped: skip-inject-annotation` so explicit operator opt-out is distinguishable from selector drift. On all other fail-open returns after the pod is decoded (list/no match/missing endpoint/adapter errors), the webhook strips stale injected-by/injected-by-uid and inject-skipped annotations so a user cannot trick the events controller by pre-stamping a pod template. Decode failures fail open before a Pod exists to patch, so stale annotations cannot be cleared on that path. | | Events | The webhook itself does NOT record events (the apiserver assigns `metadata.uid` after mutating admission, so a webhook-recorded event would carry `involvedObject.uid=""` and be invisible to `kubectl describe pod`). Instead, the pod-watching `engine-pod-events` controller reads the persisted decision annotations after CREATE. For injected pods, it validates `inferencecache.io/injected-by-uid` against the live CR's `metadata.uid` and records a `Normal InjectedByCacheBackend` event on the now-persisted pod. For explicitly skipped pods carrying both a truthy `inferencecache.io/skip-inject` and `inferencecache.io/inject-skipped: skip-inject-annotation`, it records a `Normal SkippedByOperator` event on that pod. The skip marker is not authenticated, and `skipInjection` treats a pre-existing correct marker as already converged; `SkippedByOperator` therefore means the persisted pod carries the explicit opt-out plus skipped marker, not proof that the webhook authored the marker. The UID match REDUCES — but does NOT eliminate — the failurePolicy=Ignore forgery surface for injected pods: a casual copy-paste of an injected pod's annotations into a fresh template won't match the live CR's UID, but `metadata.uid` is not secret, so a pod creator with `get` RBAC on CacheBackends can read it and stamp the pair correctly. The injected Event signals "the webhook claims this pod was injected and the claim is consistent with the live CR," not "the webhook was cryptographically authenticated." The controller skips the injected event when the CR is missing, the UID annotation is absent, or the UID does not match — see the controller godoc for the full skip table. controller-runtime's EventBroadcaster aggregates duplicates on the apiserver side, so a re-enqueue across controller restarts upserts the existing event rather than spamming. | | Idempotency | The handler calls the adapter unconditionally on every admission and trusts the adapter to converge the full injected contract. For LMCache this is env plus the engine-specific required surface — `--kv-transfer-config` for vLLM; for SGLang `--enable-lmcache` + `--lmcache-config-file` **plus** the MP-worker native sidecar and the shared config / `/dev/shm` volumes + mounts. Its merge primitives (`upsertEnv` / `upsertArgPair` / `upsertFlag`, and for SGLang `adoptContainer` / `adoptVolume` / `upsertMountByName`) converge on the desired value rather than appending a duplicate. The SGLang `adopt*` pair additionally distinguishes the adapter's own prior injection (converge) from an operator's object squatting a reserved name (reject → fail-open admit) — see [Names the MP wire reserves](#sglang-engine-support). Native HiCache validates all reserved arguments against the original pod before mutation, preserves one matching or well-formed operator-supplied value, appends each missing canonical argument once, and rejects conflicts, malformed values, or duplicates without partially changing the pod. Re-admission of a fully-injected pod therefore produces an empty JSON-patch set. Trusting the adapter rather than a handler-side env-presence shortcut avoids the trap where a partially-injected pod is admitted permanently missing the rest of the contract. | diff --git a/docs/design/grpc-tls.md b/docs/design/grpc-tls.md index 78cf3e7e..624f578d 100644 --- a/docs/design/grpc-tls.md +++ b/docs/design/grpc-tls.md @@ -9,7 +9,7 @@ The **locked design decision** for the gRPC policy server is **one-sided Service TLS is **optional at the binary level**, controlled by flags (`--tls-cert-file` / `--tls-key-file`). The server *binary* fully supports TLS, but **`config/default` ships `:9090` plaintext, and TLS is an opt-in overlay** (`config/overlays/server-tls`). **Why opt-in, not on-by-default (yet).** Both gRPC clients of `:9090` are plaintext-only today: -- the in-cluster **`kvevent-subscriber` producer** (C1) dials `:9090` to call `ReportCacheState` with `insecure.NewCredentials()` (`cmd/kvevent-subscriber`), targeting the policy Service (`DefaultPolicyServerGRPCAddress` in `pkg/adapters/runtime/vllm_lmcache.go`); and +- the in-cluster **`kvevent-subscriber` producer** (C1) dials `:9090` to call `ReportCacheState` with `insecure.NewCredentials()` (`cmd/kvevent-subscriber`), targeting the policy Service (`DefaultPolicyServerGRPCAddress` in `pkg/adapters/runtime/lmcache_shared.go`); and - the **external gateway client** (E1) isn't built yet. Flipping `config/default` to require TLS would break cache-state **ingestion** (the subscriber's handshake fails → no `ReportCacheState`). So this ticket **locks the decision and lands the full server-side mechanism** (flags, reloading cert, cert-manager Issuer/Certificate, posture metric, opt-in overlay, tests), and **defers the default flip** until both clients are TLS-aware — at which point enabling it is just making `config/overlays/server-tls` the default (and the subscriber needs the server CA distributed into engine-pod namespaces; see *Client trust anchor* below). Operators who want TLS now apply the overlay. diff --git a/docs/design/kvevent-subscriber-wiring.md b/docs/design/kvevent-subscriber-wiring.md index 10d35bd5..be1f1b1b 100644 --- a/docs/design/kvevent-subscriber-wiring.md +++ b/docs/design/kvevent-subscriber-wiring.md @@ -31,7 +31,7 @@ KV events to nobody. Closing that gap is what this ADR is about. |---|---|---|---| | 1 | **Sidecar on the engine pod**, injected by the C6 mutating Pod webhook. | Identity unambiguous (sidecar shares the engine pod's network namespace; flags derived from the same CR the webhook already reads); one webhook, no new controller; lifecycle tied to the engine pod is *correct* (when the engine dies its KV events stop). | Adds one small container per engine pod. | | 2 | **DaemonSet** watching `CacheBackend` CRs; multiplexes subscriptions across all matching pods on the node. | One subscriber per node; survives engine pod restarts independently. | Identity discovery (which engine pods on this node? what's their CacheBackend? what's the engine port?) is real work, with nothing won at Phase-1 scale; pod-IP churn during rollouts; new controller surface. | -| 3 | **C5 owns it.** Add `EnsureObservation(pod)` to `KVCacheRuntimeAdapter`; each adapter decides how to subscribe. | Cleanest seam; SGLang / Mooncake adapters can override naturally. | The seam alone doesn't say *where* the subscription runs — option 1 or 2 still has to be picked underneath. | +| 3 | **C5 owns it.** Add `EnsureObservation(pod)` to `KVCacheRuntimeAdapter`; each adapter decides how to subscribe. | Cleanest seam; SGLang and future runtimes can override naturally. | The seam alone doesn't say *where* the subscription runs — option 1 or 2 still has to be picked underneath. | ## Decision @@ -46,9 +46,8 @@ Concretely: The vLLM/LMCache, vLLM/Mooncake, and SGLang/LMCache adapters return the `kvevent-subscriber` container spec (via the shared `RenderSubscriberSidecar` — the KV-event stream is the engine's own ZMQ publisher, independent of the L2 store; each adapter pins its engine's - `--hash-scheme` tag + ZMQ port); the reference adapter and the deprecated - legacy `type: External` adapter return `(nil, nil)`. Canonical external - ownership stays on the runtime/cache adapter and can attach observation. + `--hash-scheme` tag + ZMQ port); the reference adapter returns `(nil, nil)`. + External ownership stays on the runtime/cache adapter and can attach observation. * The Pod webhook (`internal/webhook/pod/podinjector.go`) calls `ObservationSidecar` right after `InjectEngineConfig`. A non-nil container is appended to `pod.Spec.Containers` (idempotent — skipped if a container by the well-known name is already present). Errors @@ -63,8 +62,7 @@ Concretely: * Sidecar identity flags are derived from the CR + pod: `--replica-id` ← `pod.Name` (via the downward API so `generateName` pods work), `--tenant-id` ← `pod.Namespace` (downward API likewise), `--model-id` ← canonical `spec.observation.modelID` - (with deprecated `spec.backendConfig.model` as a legacy read fallback; when - unset, the adapter returns no sidecar — the binary requires the flag, and the next + (when unset, the adapter returns no sidecar — the binary requires the flag, and the next admission picks it up once the operator sets the field), `--hash-scheme` ← the adapter's runtime convention (`"vllm"` or `"sglang"`), `--server` ← the policy-server in-cluster Service DNS (operator-configurable via a controller flag), @@ -83,15 +81,12 @@ Concretely: The SGLang adapter reuses the same shared subscriber, only its `--hash-scheme` tag differs (SGLang adopted vLLM's ZMQ KV-event wire); the seam is what would let a genuinely different future engine return a different sidecar (e.g. a different ZMQ port or a - completely different observation mechanism). The shipped Mooncake adapter, by contrast, - returns the *same* vLLM kvevent-subscriber the LMCache adapter does — Mooncake integrates - as an LMCache remote backend, so the engine is still vLLM and its KV events still come + completely different observation mechanism). A Mooncake remote binding uses the same + vLLM kvevent-subscriber because the engine is still vLLM and its KV events still come from vLLM's ZMQ publisher (scheme-tagged `vllm`); only the backend store differs. A future backend that fronts a non-vLLM engine, or exposes observation data some other way, could still return `nil` or a different container here. **DaemonSet remains an option for any future adapter** that wants it — it just isn't this PR. -* `External` backends explicitly return `nil` — we don't manage that backend's lifecycle, - per the ticket test plan. * Subscriber lifecycle tied to the engine pod is correctness, not a regression: when the engine dies its KV events stop; pairing the subscriber with the engine matches that. @@ -186,9 +181,8 @@ integration mode**, because the L2 tier is present only in one of them: holding the block. `BlockRemoved` genuinely means the prefix is gone and the hint MUST be pruned, so the helper **omits** the flag (subscriber default off, forwarding the eviction as `PREFIX_EVICTED`). EventsOnly is restricted to - `spec.type=LMCache` at admission (a managed Mooncake backend always provisions - its store, so `Mooncake` + `EventsOnly` is rejected), so a Mooncake backend - always takes the Offload branch and sets the flag. + `spec.type=LMCache` at admission, and any `remoteStorage` is rejected because + EventsOnly provisions no provider workload. Other adapters (e.g. plain vLLM, or future runtimes with no L2 tier) leave the flag off for the same reason as `EventsOnly` — their stored prefixes stay diff --git a/docs/design/lmcache-server-persistence.md b/docs/design/lmcache-server-persistence.md index d7082573..0747533e 100644 --- a/docs/design/lmcache-server-persistence.md +++ b/docs/design/lmcache-server-persistence.md @@ -50,8 +50,8 @@ ClusterIP, engines-anywhere model. honestly back the in-memory server. - The recommended durable / shared topology is the **Mooncake backend**. Its managed workload lifecycle lives in the provider adapter - (`pkg/adapters/backend/provider/mooncake.go`), while the vLLM runtime adapter - (`pkg/adapters/runtime/vllm_mooncake.go`) owns engine wiring. + (`internal/adapters/builtin/storage/mooncake.go`), while the vLLM runtime adapter + (`internal/adapters/builtin/runtime/vllm_lmcache.go`) owns engine wiring. - **Generalizable rule:** surface a `max*` / storage / quota field on a CRD only when the cache plane **authoritatively owns** the resource being limited. When it does not, omit the field or express the capability as a backend choice diff --git a/docs/design/repository-boundaries.md b/docs/design/repository-boundaries.md index da2d2e7f..ab9d2cc8 100644 --- a/docs/design/repository-boundaries.md +++ b/docs/design/repository-boundaries.md @@ -1,114 +1,671 @@ -# Repository boundaries +# Repository boundaries and refactor plan -Status: staged migration in progress. +Status: Phase 0 contract cleanup complete; staged structural migration in progress. -This document defines package ownership and dependency direction. Repository -moves should be behavior-preserving and small enough to review independently; -the target layout is a sequence of migrations, not a flag day. +This document is the single source of truth for repository ownership, +dependency direction, and the remaining structure refactor. It is intentionally +an implementation checklist rather than a flag-day redesign. Each commit should +be independently reviewable, preserve behavior unless explicitly marked as a +contract change, and leave the repository buildable and testable. -## Dependency rules +## Goals -1. `api/v1alpha1` owns the Kubernetes API. The CRDs remain one Go package. -2. `internal/controlplaneapi` owns private HTTP DTOs shared by the controller +The repository structure should make the following questions easy to answer: + +1. Which binaries does the project ship? +2. Which packages are supported external Go APIs? +3. Which packages are private implementations owned by a repository binary? +4. Where do Kubernetes, HTTP, and gRPC contracts live? +5. Where should a new runtime adapter, storage provider, controller, or server + feature be added? + +The default rule for new code is: + +> Put code under the `internal/` component that owns it. Promote it to `pkg/` +> only after a concrete external consumer exists and the project is prepared to +> maintain its Go API compatibility. + +## Top-level directory responsibilities + +| Directory | Responsibility | +|---|---| +| `cmd/` | Thin executable entry points: flags, dependency construction, startup, and shutdown | +| `api/` | Public Kubernetes API and CRD Go types | +| `proto/` | Hand-written protobuf sources and wire contract | +| `gen/` | Generated public protocol bindings | +| `pkg/` | Deliberately supported external Go contracts and reusable libraries | +| `internal/` | Repository-private implementations and private cross-binary contracts | +| `config/` | Kubernetes installation, RBAC, CRDs, samples, and overlays | +| `test/` | Cross-component test programs, stacks, fixtures, and end-to-end assets | +| `docs/` | Canonical user, operator, reference, and design documentation | +| `site/` | Documentation-site rendering and presentation | +| `hack/` | Repository-maintainer tools and verification commands | + +`cmd/` and `pkg/` are Go conventions. `internal/` is also enforced by the Go +compiler: code outside this module cannot import packages below the repository's +top-level `internal/` directory. + +## Dependency direction + +The desired high-level dependency direction is: + +```text +cmd/ + -> internal/ + -> pkg/ + api/ + gen/ + -> third-party dependencies +``` + +The rules are: + +1. `cmd/` packages are composition roots. They may select concrete built-in + implementations and inject them into private application packages. +2. `internal/` implementations may depend on public contracts. +3. Public `pkg/` contracts must not depend on controller, webhook, server, or + built-in implementation packages. +4. `api/v1alpha1` owns the Kubernetes API. The CRDs remain one Go package even + when their definitions are split across cohesive files. +5. `gen/inferencecache/v1alpha1` owns generated public gRPC bindings. The server + implements this API but does not own it. +6. `internal/controlplaneapi` owns private HTTP DTOs shared by the controller and server. Neither binary imports the other's implementation for wire types. -3. `internal/enginebinding` owns pod annotations and other metadata shared by - admission and controllers. Controllers do not import webhook packages. -4. `pkg/adapters` contains extension contracts that out-of-tree adapters may - implement. Shipping implementations and registration belong under - `internal/adapters`. -5. `internal/adapters/builtin` is the sole composition root for adapters - shipped by repository binaries. Production and nil fallbacks use the same - complete registries. -6. Implementation packages may depend on extension contracts. Extension - contracts must not depend on controller, webhook, server, or built-in - implementation packages. - -## Current neutral contracts - -The JSON contracts for `POST /policy` and `POST /probe` live in -`internal/controlplaneapi`. `pkg/server` exposes temporary type and constant -aliases so existing in-repository tests and callers continue to compile while -imports migrate. The JSON field names, policy version band, and probe result -semantics are unchanged. - -The engine-pod binding annotations and skip-value parser live in -`internal/enginebinding`. The pod webhook retains compatibility aliases, but -controllers consume the neutral owner directly. - -## Adapter composition - -`internal/adapters/builtin.New` constructs both registries used by the -controller binary: - -- the complete runtime set: vLLM LMCache, vLLM Mooncake, legacy External, - SGLang LMCache, and SGLang HiCache; -- the complete managed and external remote-storage provider set. - -`pkg/adapters/runtime.NewCoreRegistry` intentionally contains only adapters -implemented in that package. Its old `DefaultRegistry` name remains as a -deprecated compatibility wrapper and must not be used as a shipping default. - -## `pkg/` classification - -The following table is the migration inventory. "Supported" means an -intentional extension or reusable Go API. "Internalize" means the package is -owned by repository binaries and will move under `internal/` in a later, -behavior-preserving change. +7. `internal/enginebinding` owns generic engine-pod metadata shared by admission + and controllers. Controllers do not import webhook packages. +8. `pkg/adapters` contains the build-time extension contracts. Shipping + implementations and registration belong under `internal/adapters`. +9. `internal/adapters/builtin.New` owns the complete registry composition shipped + by the controller binary. Lower layers do not construct fallback registries. + +## Extension model + +The current adapter seam is a build-time Go extension point, not a dynamic +plugin system: + +- an external project can implement the contracts under `pkg/adapters` and + build a custom controller binary; +- the shipping controller registers the curated built-ins from + `internal/adapters/builtin`; +- the CRD currently uses enums for runtime, cache, and remote-storage provider + identifiers, so adding a new identifier also requires an intentional API and + CRD change; +- the repository does not load Go plugins or discover adapter implementations at + runtime. + +This build-time seam is the designated extension point, but its Go source +contract is pre-stable. The Phase 0 audit found no real out-of-tree adapter +consumer, so the structured-binding change intentionally did not retain a +second compatibility interface. A custom controller fork upgrading from an +earlier revision must implement `SupportsBinding(*backend.Binding)` and update +both injection methods to accept `*backend.Binding`; custom forks must pin the +repository revision they build against. Once the project supports its first +external adapter consumer, later source-breaking interface changes require an +explicit versioned contract or migration layer. + +This is the default extension model until a concrete consumer requires runtime +plugin loading. Do not introduce dynamic loading speculatively. + +## Current completed boundaries + +The following work is complete at the baseline for this plan: + +- [x] Remove the deprecated provider-rendering seams from the runtime adapter + package. +- [x] Remove the obsolete `CacheBackendType` values that no longer describe the + canonical engine-cache API. +- [x] Move shipping remote-storage providers to + `internal/adapters/builtin/storage`. +- [x] Move shipping vLLM and SGLang runtime implementations to + `internal/adapters/builtin/runtime`. +- [x] Use consistent combination-based runtime filenames: + `vllm_lmcache.go`, `sglang_lmcache.go`, and `sglang_hicache.go`. +- [x] Keep the LMCache kernel-check implementation with the built-in runtime + implementation in `internal/adapters/builtin/runtime/lmcachecheck.go`. +- [x] Centralize the shipping runtime and storage registries in + `internal/adapters/builtin.New`. +- [x] Make `pkg/adapters/runtime.NewRegistry` construct an empty public registry. +- [x] Add `internal/controlplaneapi` for the private `/policy` and `/probe` JSON + contracts. +- [x] Add `internal/enginebinding` for generic pod-binding annotations and + parsing. +- [x] Remove the unshipped CacheBackend legacy fields, compatibility readers, + conversion/defaulting branches, and canonical-versus-legacy behavior. +- [x] Make structured remote-storage `Binding` support and injection a required + part of the public runtime adapter contract. + +The baseline passes `go test ./...` and `git diff --check`. + +## Current package classification | Current package | Classification | Owner / rationale | Planned target | |---|---|---|---| -| `pkg/adapters/backend` | Supported | Remote-storage provider and binding extension contracts | Keep | -| `pkg/adapters/backend/provider` | Internalize | Shipping provider implementations | `internal/adapters/builtin/storage` | -| `pkg/adapters/runtime` | Split | Runtime extension contracts mixed with shipping vLLM implementations | Contracts stay; implementations move under `internal/adapters/builtin/runtime` | -| `pkg/adapters/runtime/external` | Internalize | Shipping legacy compatibility adapter | `internal/adapters/builtin/runtime` | -| `pkg/adapters/runtime/sglang` | Internalize | Shipping SGLang implementations | `internal/adapters/builtin/runtime` | -| `pkg/adapters/runtime/internal/enginewire` | Internalize | Shared implementation detail of built-in runtime adapters | `internal/enginebinding` or built-in runtime subtree | -| `pkg/adapters/engine` | Internalize | Subscriber-side ingest and metrics implementation | `internal/subscriber` | -| `pkg/adapters/engineclient` | Supported | Harness-facing engine egress client with no binary owner | Keep | -| `pkg/fingerprint` | Supported | Reusable content-fingerprint contract used across integrations | Keep | +| `pkg/adapters/backend` | Supported | Remote-storage provider and binding extension contracts | Keep, narrow to contract-only code | +| `internal/adapters/builtin/storage` | Internal | Shipping provider implementations | Keep | +| `pkg/adapters/runtime` | Supported | Runtime extension interfaces and registry | Keep, narrow to contract-only code | +| `internal/adapters/builtin/runtime` | Internal | Shipping vLLM/SGLang implementations and engine wire rendering | Keep | +| `pkg/adapters/engine` | Internalize | Subscriber-side event ingest, metrics, and reporting | `internal/subscriber` | +| `pkg/adapters/engineclient` | Internalize by default | Canary/harness client with no current external consumer | `internal/engineclient` | +| `pkg/fingerprint` | Supported | Language-neutral fingerprint contract used across integrations | Keep | | `pkg/tokenize` | Supported | Optional tokenizer boundary, including the tagged cgo implementation | Keep | | `pkg/index` | Internalize | Server-owned mutable cache-state implementation | `internal/index` | -| `pkg/server` and `pkg/server/auth` | Internalize | Server binary implementation | `internal/server` | +| `pkg/server` | Internalize | Server binary implementation | `internal/server` | +| `pkg/server/auth` | Internalize | Server-owned HTTP authentication | `internal/server/auth` | | `pkg/server/proto/...` | Migrate | Generated public gRPC API under a server-owned path | `gen/inferencecache/v1alpha1` | | `pkg/cli/doctor/...` | Internalize | `cmd/inferencecache` implementation | `internal/cli/doctor` | -| `pkg/render` | Internalize until an external consumer exists | Server-owned rendering placeholder | `internal/server/render` | +| `pkg/render` | Remove placeholder | Empty server-owned placeholder with no implementation | Delete; create `internal/server/render` when implemented | | `pkg/testing` | Internalize | In-repository envtest helpers | `internal/testutil` | | `pkg/version` | Internalize | Repository binary build metadata | `internal/version` | -Each move must update package documentation so a remaining `pkg/` package -states its owner or its supported external-consumer contract. - -## Generated protobuf migration - -The current generated import path remains -`github.com/cachebox-project/inference-cache/pkg/server/proto/inferencecache/v1alpha1`. -Moving generated code is a source-level contract change even when the protobuf -wire is identical, so it is deliberately separate from the HTTP-contract move. - -The migration sequence is: - -1. Change `go_package` to - `github.com/cachebox-project/inference-cache/gen/inferencecache/v1alpha1` - and regenerate into `gen/`. -2. Update all repository binaries, adapters, and tests in the same change. -3. Decide before merge whether the alpha module promises old-import - compatibility. If it does, retain a documented forwarding package for one - release; otherwise call out the import move in release notes. -4. Make generated-code drift checks treat `gen/` as the only generated Go - target, then remove the old generated directory. - -The protobuf package and service names remain `inferencecache.v1alpha1` and -`InferenceCache`; only the Go import path changes. - -## Remaining stages - -1. Move implementation-only packages under `internal/`, starting with server, - index, subscriber, doctor, and built-in adapters. -2. Split controller files by bounded context while retaining one package until - dependency edges are clear; split large server, index, webhook, and API files - along cohesive responsibilities. -3. Separate canonical, legacy, recipe, and invalid samples; make the site - consume canonical user documentation. -4. Move CI-executed reference-stack assets and fake engines into `test/`, then - split build logic while preserving public Make targets. +Every completed migration must update package documentation and repository docs +in the same commit. A remaining `pkg/` package must explicitly document its +external consumer or supported extension contract. + +## Target source layout + +```text +api/ +└── v1alpha1/ + +cmd/ +├── controller/ +├── inferencecache/ +├── kvevent-subscriber/ +└── server/ + +gen/ +└── inferencecache/ + └── v1alpha1/ + +internal/ +├── adapters/ +│ └── builtin/ +│ ├── runtime/ +│ │ ├── vllm_lmcache.go +│ │ ├── vllm_lmcache_wire.go +│ │ ├── sglang_lmcache.go +│ │ ├── sglang_lmcache_wire.go +│ │ ├── sglang_hicache.go +│ │ ├── subscriber.go +│ │ └── lmcachecheck.go +│ └── storage/ +│ ├── redis.go +│ ├── lmcache_server.go +│ └── mooncake.go +├── cli/ +│ └── doctor/ +│ ├── checks/ +│ └── output/ +├── controller/ +├── controlplaneapi/ +│ ├── policy.go +│ ├── probe.go +│ └── snapshot.go +├── enginebinding/ +├── engineclient/ +├── index/ +├── server/ +│ └── auth/ +├── subscriber/ +├── testutil/ +├── version/ +└── webhook/ + ├── pod/ + └── v1alpha1/ + +pkg/ +├── adapters/ +│ ├── backend/ +│ └── runtime/ +├── fingerprint/ +└── tokenize/ + +proto/ +└── inferencecache/ + └── v1alpha1/ + +test/ +├── fake-engine/ +└── reference-stack/ +``` + +`internal/adapters/builtin/runtime` intentionally remains one flat Go package +for now. The combination-based filenames make ownership clear, and the current +production implementation is not large enough to justify engine-specific +subpackages. Split it only when a real dependency boundary appears, not merely +because more files are added. + +## Phase 0: complete contract cleanup + +Phase 0 was intentionally completed before the structural file moves. These +were visible contract changes, not refactors, and inference-cache had not been +formally deployed, so no resource conversion or compatibility forwarding was +added. + +### 0.1 Remove unshipped CacheBackend compatibility + +- [x] Make `spec.runtime` required and keep the served CRD on the typed runtime, + cache, remote-storage, observation, and provider-resource hierarchy. +- [x] Remove `spec.integration.engine`, `spec.backendConfig`, top-level + `spec.resources`, and `spec.integration.firstEventTimeout` from the Go API and + generated CRD. +- [x] Remove `UsesCanonicalCacheHierarchy` and all read-time legacy fallbacks, + conversion/defaulting branches, and canonical-versus-legacy controller and + adapter paths. +- [x] Move the first-event timeout default to + `spec.observation.firstEventTimeout` and update samples, smoke checks, and + operator documentation. +- [x] Regenerate deepcopy and CRD output and run the full Go test suite. + +### 0.2 Make structured binding the runtime adapter contract + +- [x] Require every `KVCacheRuntimeAdapter` to implement + `SupportsBinding(*backend.Binding)`. +- [x] Pass `*backend.Binding` directly to `InjectEngineConfig` and + `InjectRouterConfig`; a nil binding has the single meaning “host-only.” +- [x] Remove the optional `RemoteBindingAdapter` and `EndpointRequirement` + capabilities and the endpoint-string fallback helpers. +- [x] Update the reference adapter, all shipping adapters, admission, + reconciliation, pod injection, tests, and extension documentation together. +- [x] Confirm no real out-of-tree adapter consumer exists and document the + pre-stable source contract plus the custom-fork migration requirement. +- [x] Keep provider lifecycle independent: backend providers render storage and + its protocol, while runtime adapters only accept and consume the resulting + binding. + +Phase 0 does not alter protobuf compatibility, legacy vLLM metric names, +Kubernetes Event API reading, or the LookupRoute wire paths. Those are separate +released or ecosystem-facing contracts and are outside this repository +structure plan. + +## Phase A: finish ownership boundaries + +Complete this phase before splitting large implementation files. This prevents +the same code from being repeatedly moved and rewritten. + +### A1. Narrow the public adapter surface + +Proposed commit: + +```text +refactor(adapters): narrow public adapter contracts +``` + +- [ ] Move shipping `Options`, subscriber image/server configuration, and + subscriber sidecar rendering from `pkg/adapters/runtime` into + `internal/adapters/builtin` or its runtime implementation package. +- [ ] Keep the runtime interfaces, registry, runtime identifiers, + supported-pair types, required structured-binding contract, and required + cross-component wire contracts public. +- [ ] Convert the concrete reference adapter into a Go example or test fixture + so it documents the extension contract without expanding the production API. +- [ ] Keep the LMCache kernel-check implementation in + `internal/adapters/builtin/runtime/lmcachecheck.go`. +- [ ] Preserve registry selection, pod mutation, and admission behavior. +- [ ] Update documentation that still references the pre-refactor adapter paths. + +This commit narrows source-level API exposure but must not redesign the adapter +contract. + +### A2. Move generated gRPC bindings + +Proposed commit: + +```text +refactor(proto): move generated grpc API under gen +``` + +- [ ] Change `go_package` to + `github.com/cachebox-project/inference-cache/gen/inferencecache/v1alpha1`. +- [ ] Regenerate the Go protobuf and gRPC bindings under `gen/`. +- [ ] Update all server, subscriber, test, and tool imports in the same commit. +- [ ] Update generation and generated-drift checks to treat `gen/` as the only + Go output target. +- [ ] Remove `pkg/server/proto`. +- [ ] Preserve the protobuf package, service names, field numbers, and wire + behavior. + +Because inference-cache has not been formally deployed, the default plan is not +to retain an old-import forwarding package. This is still a Go source import +change and must be called out in release notes if published externally before +the migration lands. + +### A3. Extract the snapshot HTTP contract + +Proposed commit: + +```text +refactor(controlplane): extract snapshot wire contract +``` + +- [ ] Add `internal/controlplaneapi/snapshot.go` for the `/snapshot` JSON DTOs. +- [ ] Keep mutable index domain types separate from the HTTP representation. +- [ ] Map index state to the HTTP DTO in the server boundary. +- [ ] Update the controller poller to import `internal/controlplaneapi`, not the + index implementation. +- [ ] Add JSON wire-shape tests before moving the index package. + +### A4. Internalize the mutable cache index + +Proposed commit: + +```text +refactor(index): internalize the cache index +``` + +- [ ] Move `pkg/index` to `internal/index`. +- [ ] Update the server, tests, and `hack/index-sizing` imports. +- [ ] Preserve ingest, lookup, ranking, quota, TTL, eviction, and soft-state + behavior. +- [ ] Do not split `index.go` in this commit. + +### A5. Internalize the server implementation + +Proposed commit: + +```text +refactor(server): internalize the server implementation +``` + +- [ ] Move `pkg/server` to `internal/server`. +- [ ] Move `pkg/server/auth` to `internal/server/auth` and retain it as a + cohesive security-focused subpackage. +- [ ] Update `cmd/server` and integration-test imports. +- [ ] Remove temporary `internal/controlplaneapi` type and constant aliases from + the server package after all callers use the neutral owner directly. +- [ ] Preserve HTTP routes, gRPC methods, metrics, TLS, authentication, and + fail-open behavior. +- [ ] Do not split server implementation files in this commit. + +### A6. Internalize the KV-event subscriber implementation + +Proposed commit: + +```text +refactor(subscriber): internalize kv event ingestion +``` + +- [ ] Move `pkg/adapters/engine` to `internal/subscriber`. +- [ ] Keep `cmd/kvevent-subscriber` as a thin composition and lifecycle layer. +- [ ] Preserve ZMQ decoding, positional fingerprinting, metrics scraping, + batching, reconnect, gRPC reporting, and fail-soft behavior. +- [ ] Keep tests and testdata beside the implementation. + +### A7. Internalize the doctor CLI implementation + +Proposed commit: + +```text +refactor(cli): internalize doctor implementation +``` + +- [ ] Move `pkg/cli/doctor` to `internal/cli/doctor`. +- [ ] Preserve the existing `checks` and `output` subpackages. +- [ ] Preserve CLI flags, finding codes, JSON field names, output formats, and + exit-code behavior. + +The CLI output is a user-facing contract even though the Go package is private. + +### A8. Internalize repository support packages + +Proposed commit: + +```text +refactor(repo): internalize repository support packages +``` + +- [ ] Move `pkg/testing` to `internal/testutil`. +- [ ] Move `pkg/version` to `internal/version`. +- [ ] Update Makefile `-ldflags` package paths. +- [ ] Delete the empty `pkg/render` placeholder. +- [ ] Create `internal/server/render` only when `RenderTemplate` receives a real + implementation. + +### A9. Reclassify the engine egress client + +Proposed commit: + +```text +refactor(engineclient): internalize the canary engine client +``` + +- [ ] Confirm that there is still no external SDK consumer. +- [ ] Move `pkg/adapters/engineclient` to `internal/engineclient` by default. +- [ ] Remove or explicitly isolate the unimplemented gRPC placeholder. +- [ ] Retain the OpenAI-compatible canary/harness behavior and tests. + +If a concrete external gateway consumer exists before this step, stop and +define the supported SDK contract instead of performing the move automatically. + +## Phase B: split large files without creating new package boundaries + +This phase is a readability refactor. Keep the existing Go packages and avoid +new interfaces unless a real dependency cycle requires one. + +### B1. Split the index implementation + +Proposed commit: + +```text +refactor(index): split implementation by responsibility +``` + +Target files: + +```text +internal/index/ +├── types.go +├── index.go +├── ingest.go +├── lookup.go +├── ranking.go +├── eviction.go +├── snapshot.go +└── accounting.go +``` + +- [ ] Move tests beside the responsibility they exercise. +- [ ] Keep all types and methods in package `index`. +- [ ] Do not change algorithms, locking, clock behavior, or metrics. + +### B2. Split the server gRPC implementation + +Proposed commit: + +```text +refactor(server): split grpc handlers by responsibility +``` + +Target files: + +```text +internal/server/ +├── server.go +├── lookup.go +├── ingest.go +├── proto_mapping.go +├── policy.go +├── probe.go +├── metrics.go +└── auth/ +``` + +- [ ] Separate RPC handlers from protobuf/domain mapping helpers. +- [ ] Keep route policy and lookup response construction cohesive. +- [ ] Preserve gRPC and HTTP wire behavior. + +### B3. Split the CacheBackend reconciler + +Proposed commit: + +```text +refactor(controller): split cachebackend reconciliation flow +``` + +Target files: + +```text +internal/controller/ +├── cachebackend_reconciler.go +├── cachebackend_dispatch.go +├── cachebackend_managed.go +├── cachebackend_serverless.go +├── cachebackend_workload.go +└── cachebackend_status.go +``` + +Existing cohesive files such as `cachebackend_probe.go`, +`cachebackend_kernelcheck.go`, and `cachebackend_server_restart.go` remain +separate. + +- [ ] Keep one `controller` package. +- [ ] Preserve reconcile ordering, ownership, status patching, events, and + requeue behavior. +- [ ] Split large tests along the same responsibilities. + +### B4. Split CacheBackend admission rules + +Proposed commit: + +```text +refactor(webhook): split cachebackend admission rules +``` + +Target files: + +```text +internal/webhook/v1alpha1/ +├── cachebackend_defaulter.go +├── cachebackend_validator.go +├── cachebackend_storage_validation.go +├── cachebackend_integration_validation.go +└── cachebackend_override_validation.go +``` + +- [ ] Keep one `v1alpha1` webhook package. +- [ ] Preserve validation ordering, field paths, error messages, and defaults. +- [ ] Split the large webhook test file by rule family. + +### B5. Split CacheBackend API definitions + +Proposed commit: + +```text +refactor(api): split cachebackend types by concern +``` + +Target files: + +```text +api/v1alpha1/ +├── cachebackend_types.go +├── cachebackend_cache_types.go +├── cachebackend_storage_types.go +├── cachebackend_integration_types.go +└── cachebackend_status_types.go +``` + +- [ ] Keep one `api/v1alpha1` Go package. +- [ ] Preserve all JSON names, kubebuilder markers, schema, defaults, printer + columns, and generated deepcopy behavior. +- [ ] Regenerate and verify the CRD after moving markers and types. + +## Phase C: organize samples, tests, docs, and build assets + +Complete the source ownership work first. These moves affect CI and user-facing +paths and should not obscure source-package reviews. + +### C1. Classify samples + +- [ ] Separate canonical minimal samples from recipes and invalid fixtures. +- [ ] Keep invalid admission fixtures clearly under a test-only directory. +- [ ] Update sample verification to discover the intended categories. +- [ ] Keep user documentation pointed at canonical examples. + +### C2. Move executable reference-stack assets under `test/` + +- [ ] Move CI-executed scripts, manifests, fake engines, and fixtures from + `docs/reference-stack` and `cmd/kvevent-fake-engine` under `test/`. +- [ ] Keep explanatory runbooks in `docs/` and link to the executable assets. +- [ ] Update GitHub Actions and Make targets without renaming the public Make + targets. + +### C3. Make documentation canonical + +- [ ] Decide which content under `docs/` is canonical. +- [ ] Make `site/` render or consume that source instead of maintaining + independent copies. +- [ ] Fix stale source-path references as part of every earlier move rather than + deferring all path updates to this step. + +### C4. Split build logic last + +- [ ] Split the large Makefile by concern only after source and test paths are + stable. +- [ ] Candidate includes: `make/tools.mk`, `make/build.mk`, `make/test.mk`, and + `make/release.mk`. +- [ ] Preserve public targets such as `build`, `test`, `ci`, `pre-pr`, image + targets, and verification targets. + +## Contract decisions outside structural phases + +Contract or behavior changes that are not already recorded in Phase 0 must be +reviewed separately and must not be hidden inside file moves. + +### Do not introduce runtime plugin loading without a concrete requirement + +The CRD enums and built-in composition intentionally make supported integrations +explicit. If a future requirement demands third-party adapters without a custom +controller build, it will require a broader design covering API extensibility, +configuration schema, discovery, trust, versioning, and failure isolation. That +is not part of this refactor. + +## Verification requirements + +Every commit must run the checks appropriate to its scope and report the actual +results. + +Minimum for a pure Go move or file split: + +```text +gofmt on changed Go files +go test ./... +git diff --check +``` + +Additional checks by change type: + +| Change | Required verification | +|---|---| +| CRD types or markers | Regenerate deepcopy/CRDs, verify generated diff, run API and webhook tests | +| Protobuf source or output path | Regenerate bindings, protobuf lint, generated-drift checks, full tests | +| Server/index/subscriber move | Package tests, integration tests, full tests | +| CLI move | Doctor output/exit-code tests and full tests | +| Test/reference-stack move | Affected Make target and GitHub workflow command paths | +| End of each phase | `go test -race ./...` or the repository `make ci` gate as practical | + +Add a lightweight repository-boundary verification before completing Phase A: + +- [ ] Maintain an explicit allow-list of supported `pkg/` packages. +- [ ] Reject production imports from `pkg/` into `internal/`. +- [ ] Reject imports from public adapter contracts into built-in adapters. +- [ ] Reject controller imports of server or mutable-index implementations. +- [ ] Require generated public Go protobuf code to live only under `gen/`. + +## Review discipline + +For every proposed commit: + +1. State whether it is a pure refactor, a source import-path change, or a runtime + contract/behavior change. +2. Keep unrelated cleanup out of the diff. +3. Preserve existing tests during moves; split tests only in the corresponding + Phase B readability commit. +4. Update documentation paths in the same commit as code moves. +5. Do not create forwarding packages or compatibility aliases unless a concrete + released consumer requires them. +6. Stop and reassess if a file move requires new runtime branching, schema + migration, or a new public dependency. + +This sequence keeps the repository continuously working while converging on a +small public API surface, explicit binary ownership, and a structure that can +grow without turning `pkg/` or `internal/` into undifferentiated collections. diff --git a/docs/design/sglang-lmcache-mp-mode.md b/docs/design/sglang-lmcache-mp-mode.md index d799d107..3c2d7446 100644 --- a/docs/design/sglang-lmcache-mp-mode.md +++ b/docs/design/sglang-lmcache-mp-mode.md @@ -1,6 +1,6 @@ # Design: LMCache MP mode — the converged worker model (SGLang now, vLLM migration) -Status: **implemented and GPU-validated** for SGLang (Phase 2, increments 1–2); increment 3 (operator surface + the remaining SPOF containment) is open — see [Phased delivery](#phased-delivery). Facts below are live-validated unless marked otherwise. · Supersedes the "mirror the vLLM+LMCache adapter" model in [cachebackend-api.md](cachebackend-api.md) SGLang section · Adapters: `pkg/adapters/runtime/sglang`, `pkg/adapters/runtime` (vLLM) +Status: **implemented and GPU-validated** for SGLang (Phase 2, increments 1–2); increment 3 (operator surface + the remaining SPOF containment) is open — see [Phased delivery](#phased-delivery). Facts below are live-validated unless marked otherwise. · Supersedes the "mirror the vLLM+LMCache adapter" model in [cachebackend-api.md](cachebackend-api.md) SGLang section · Built-in adapters: `internal/adapters/builtin/runtime`; public contract: `pkg/adapters/runtime` **LMCache upstream now recommends multiprocess (MP) mode for *both* vLLM and SGLang** (its quickstart: MP is *"recommended"* for vLLM via `LMCacheMPConnector`, @@ -412,7 +412,8 @@ data plane), different resolution because the data planes differ: - **Phase 1 (this doc).** Record the design, and — **comment-only, no behavior change** — resolve the stale `TODO(wire-test before production)` in - `enginewire.go` and align its godoc to the validated MP reality. The engine wire + `internal/adapters/builtin/runtime/sglang_lmcache_wire.go` and align its godoc + to the validated MP reality. The engine wire is unchanged: dropping the MP-ignored `LMCACHE_*` env is deferred to Phase 2, where `InjectSGLangLMCache` is rewritten wholesale (so it is edited once, not twice). The advisory admission warning stays (no working data plane yet), so no @@ -483,7 +484,7 @@ data plane), different resolution because the data planes differ: silently falls back to slow pickle serialization. The shared `emptyDir` must be `medium: Memory` and sized ≥ the L1. - **L2 durability/HA** — a single managed Redis is a simple default, not an HA - store. A planned `backendConfig` knob (not yet implemented) will let operators + store. A future typed remote-storage option will let operators who need durability select an `s3` or `mooncake_store` `--l2-adapter` instead, mirroring the LMCache-vs-Mooncake durability-is-a-backend-choice decision. - **Bleeding edge** — SGLang's LMCache integration is new (early 2026); the working diff --git a/docs/observability/alerts.md b/docs/observability/alerts.md index 52d36c08..cdd04f5b 100644 --- a/docs/observability/alerts.md +++ b/docs/observability/alerts.md @@ -594,7 +594,7 @@ Service-endpoint probe and Ready gate cannot catch: | `stage` label | What `failed` means | |---|---| | `ingest` | The probe wrote a synthetic prefix entry through the server's **in-process** `index.Ingest` path and the entry did not land. This pins the index ingest path; it does **NOT** exercise the gRPC `ReportCacheState` handler nor the `kvevent-subscriber` sidecar (subscriber wire bugs are invisible to Stage A by design — see the design doc and `pkg/server/probe.go` lead-in). A failure here means the index itself is dropping writes — a regression in `pkg/index` keying, scheme handling, or eviction. | -| `routing` | The probe wrote the entry, the index recorded it, but `LookupRoute` returned `NO_HINT` for the probe's hash. Likely an index-key-scheme mismatch (the probe's `hashScheme` is derived from canonical `spec.runtime`, with the deprecated engine field retained for legacy resources; an empty scheme fails open and produces `NO_HINT` on lookup) or a lookup-filter regression in `pkg/server`. | +| `routing` | The probe wrote the entry, the index recorded it, but `LookupRoute` returned `NO_HINT` for the probe's hash. Likely an index-key-scheme mismatch (the probe's `hashScheme` is derived from `spec.runtime`; an empty scheme fails open and produces `NO_HINT` on lookup) or a lookup-filter regression in `pkg/server`. | | `t2` | (When a `T2Prober` is wired into the server.) The tier-2 put/get cycle against the configured external backend (LMCache today) failed. No `T2Prober` is wired in this revision, so this stage reports `skipped` on every install — an alert here only fires once a follow-up registers a real `T2Prober`. | The alert uses `increase(...{result="failed"}[5m]) >= 2 for: 5m` — a @@ -627,8 +627,7 @@ By `stage` label: `kvevent-subscriber` pod logs + gRPC `:9090` reachability instead.) - `routing` — the index recorded the probe entry but lookup can't find it. The probe's `hashScheme` is derived from the backend's - canonical `spec.runtime` (or deprecated `spec.integration.engine` for legacy - resources); verify it is not being + `spec.runtime`; verify it is not being silently dropped on ingest (an empty scheme fails open and produces `NO_HINT` on lookup). Check the server-side lookup-filter logs for `reason_code=NO_HINT` on calls that should match. diff --git a/docs/reference-stack/README.md b/docs/reference-stack/README.md index dd54f465..2758b196 100644 --- a/docs/reference-stack/README.md +++ b/docs/reference-stack/README.md @@ -151,8 +151,7 @@ run it after changing the subscriber. [`scripts/canary_c2_reconcile.sh`](scripts/canary_c2_reconcile.sh) is a GPU-free, on-demand canary for the **C2 reconciler**: it brings up a kind cluster, runs the -controller, applies a legacy compatibility `CacheBackend` with -`backendConfig.profile: cpu`, and asserts +controller, applies a typed `CacheBackend` with a managed LMCacheServer, and asserts the controller stands up a healthy serving backend (Ready condition True, endpoint published) and owner-ref garbage collection when the CR is deleted. It exercises the reconciler against real pods — the gap the envtest unit tests can't cover. @@ -166,9 +165,9 @@ docs/reference-stack/scripts/canary_c2_reconcile.sh ``` Like the full-chain canary it is **on-demand**, not a blocking gate: it needs -Docker + kind, pulls the vLLM CPU image, and wants ~10+ GiB of Docker VM RAM. The -`cpu` profile runs a GPU-free vLLM engine (prefix caching + KV events, no LMCache -offload); real LMCache offload still needs a GPU (the default `gpu` profile). +Docker + kind and pulls the standalone LMCache server image. The default path +checks only the managed backend lifecycle, so it does not need an inference +engine or GPU. ## In-cluster auto-attach (production path) @@ -178,7 +177,7 @@ When the controller is installed in a cluster **and the operator passes digest in production), the pod-mutating webhook auto-attaches the `kvevent-subscriber` as a sidecar to every engine pod whose labels match a `CacheBackend.spec.engineSelector` and whose backend sets -`spec.observation.modelID` (or the deprecated `backendConfig.model` fallback). +`spec.observation.modelID`. The subscriber's identity flags (`--replica-id`, `--tenant-id`, `--model-id`, `--hash-scheme`) are derived from the CR + pod — no operator-supplied flags, no out-of-band `kubectl port-forward` + manual diff --git a/docs/reference-stack/VERSIONS.md b/docs/reference-stack/VERSIONS.md index 572c0004..26ca9b2a 100644 --- a/docs/reference-stack/VERSIONS.md +++ b/docs/reference-stack/VERSIONS.md @@ -48,7 +48,8 @@ pin an lmcache-server — SGLang never dials one. > `manifests/sglang-lmcache/deployment.yaml` renders the MP topology: a **Redis L2** + > the **MP-worker native sidecar** + the derived engine image (still a non-applyable > placeholder digest — substitute your own build). It is **derived from the -> GPU-validated adapter render** (`redis_l2.go` + the SGLang adapter) and structurally +> GPU-validated adapter render** (`internal/adapters/builtin/storage/redis.go` + +> the SGLang adapter) and structurally > checked (`kubectl apply --dry-run=client`); re-run it on a GPU before treating it as golden. > The pins below are authoritative for **both** the manifest and the controller-rendered > managed path. diff --git a/docs/reference-stack/manifests/sglang-lmcache/README.md b/docs/reference-stack/manifests/sglang-lmcache/README.md index 46524057..f6f24f43 100644 --- a/docs/reference-stack/manifests/sglang-lmcache/README.md +++ b/docs/reference-stack/manifests/sglang-lmcache/README.md @@ -3,7 +3,7 @@ The second-engine sibling of the top-level [vLLM + LMCache reference](../../README.md): a **SGLang** deployment that publishes **KV-cache events over ZMQ** and **offloads KV to a shared Redis L2** via LMCache **multiprocess (MP) mode**. It is the hand-built -reference the `(sglang, LMCache)` runtime adapter (`pkg/adapters/runtime/sglang`) +reference the `(sglang, LMCache)` runtime adapter (`internal/adapters/builtin/runtime`) mirrors: [`deployment.yaml`](deployment.yaml) stands up the same shape the adapter auto-injects — a Redis L2 store, the engine with `--enable-lmcache` + `--lmcache-config-file`, and a **node-local MP-worker native sidecar** that offloads @@ -12,8 +12,8 @@ are operator-owned scaffolding the adapter assumes is present, so the file as a is **not** byte-for-byte adapter output. > **Validation status.** This manifest is **derived from the GPU-validated adapter -> render** (`pkg/adapters/runtime/internal/enginewire/sglang_mp.go` + -> `pkg/adapters/backend/provider/redis_l2.go`; the controller-rendered managed path was +> render** (`internal/adapters/builtin/runtime/sglang_lmcache_wire.go` + +> `internal/adapters/builtin/storage/redis.go`; the controller-rendered managed path was > validated store→flush→retrieve end-to-end in the MP-mode increment) and is > **structurally checked** (`kubectl apply --dry-run=client`). It has **not** been > independently re-run end-to-end on a GPU in this exact hand shape — run it on a GPU diff --git a/docs/reference-stack/manifests/sglang-lmcache/deployment.yaml b/docs/reference-stack/manifests/sglang-lmcache/deployment.yaml index 4f1f03f7..e34ccc49 100644 --- a/docs/reference-stack/manifests/sglang-lmcache/deployment.yaml +++ b/docs/reference-stack/manifests/sglang-lmcache/deployment.yaml @@ -9,8 +9,10 @@ # (mp_host/mp_port) and reads its config from --lmcache-config-file; the old # lm:// LMCACHE_REMOTE_URL env is gone (MP mode ignores it). # This mirrors what the (sglang, LMCache) runtime adapter auto-injects on a matched -# engine pod (pkg/adapters/runtime/sglang + .../internal/enginewire/sglang_mp.go + -# redis_l2.go), which is GPU-validated end to end. The engine image / --model-path / +# engine pod (internal/adapters/builtin/runtime/sglang_lmcache.go + +# internal/adapters/builtin/runtime/sglang_lmcache_wire.go + +# internal/adapters/builtin/storage/redis.go), which is GPU-validated end to end. +# The engine image / --model-path / # resources / --kv-events-config remain operator-owned scaffolding the adapter # assumes is already present, so this is NOT byte-for-byte adapter output. # diff --git a/docs/reference-stack/scripts/canary_c2_reconcile.sh b/docs/reference-stack/scripts/canary_c2_reconcile.sh index 7cdb88b8..c0b2df3d 100755 --- a/docs/reference-stack/scripts/canary_c2_reconcile.sh +++ b/docs/reference-stack/scripts/canary_c2_reconcile.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# CPU canary for the C2 CacheBackend reconciler. Proves the controller stands up a +# Canary for the C2 CacheBackend reconciler. Proves the controller stands up a # healthy, serving backend from a CR on a GPU-free cluster (kind): # # kubectl apply CacheBackend(profile=cpu) --> controller --> Deployment + Service @@ -15,23 +15,18 @@ # the children via owner refs. # # This exercises the reconciler end to end against real pods — the gap envtest -# can't cover. It uses the CPU profile (no GPU, no LMCache offload); real LMCache -# offload needs a GPU (default profile). +# can't cover. The managed standalone server uses CPU storage and does not need +# an inference engine or GPU for this controller lifecycle check. # # On-demand canary (NOT a per-PR gate): needs Docker + kind + kubectl, pulls the -# multi-GB vLLM CPU image, and a Docker VM with ~10+ GiB RAM (CPU runtime baseline -# ~5 GiB + KV cache). See docs/reference-stack/VERSIONS.md. +# standalone LMCache server image. See docs/reference-stack/VERSIONS.md. # # Usage: docs/reference-stack/scripts/canary_c2_reconcile.sh -# Tunables via env: IMAGE, MODEL, KIND_CLUSTER, NAMESPACE, READY_TIMEOUT, SKIP_TRAFFIC. +# Tunables via env: CACHE_SERVER_IMAGE, MODEL, KIND_CLUSTER, NAMESPACE, +# READY_TIMEOUT, SKIP_TRAFFIC. set -euo pipefail -arch="$(uname -m)" -case "$arch" in - arm64 | aarch64) IMAGE_TAG="${IMAGE_TAG:-latest-arm64}" ;; - *) IMAGE_TAG="${IMAGE_TAG:-latest-x86_64}" ;; -esac -IMAGE="${IMAGE:-vllm/vllm-openai-cpu:$IMAGE_TAG}" +CACHE_SERVER_IMAGE="${CACHE_SERVER_IMAGE:-lmcache/standalone:v0.4.7}" MODEL="${MODEL:-Qwen/Qwen2.5-0.5B-Instruct}" KIND_CLUSTER="${KIND_CLUSTER:-ic-c2-canary}" NAMESPACE="${NAMESPACE:-c2-canary}" @@ -72,9 +67,9 @@ log "creating kind cluster $KIND_CLUSTER" "$KIND" create cluster --name "$KIND_CLUSTER" --wait 120s KUBECONFIG_ARGS=(--context "kind-$KIND_CLUSTER") -log "pulling CPU image and loading it into the node ($IMAGE)" -docker pull "$IMAGE" -"$KIND" load docker-image "$IMAGE" --name "$KIND_CLUSTER" +log "pulling LMCache server image and loading it into the node ($CACHE_SERVER_IMAGE)" +docker pull "$CACHE_SERVER_IMAGE" +"$KIND" load docker-image "$CACHE_SERVER_IMAGE" --name "$KIND_CLUSTER" # --- controller ------------------------------------------------------------- log "installing CRD" @@ -103,8 +98,8 @@ controller_pid=$! kubectl "${KUBECONFIG_ARGS[@]}" create namespace "$NAMESPACE" -# --- apply the CacheBackend (CPU profile) ----------------------------------- -log "applying CacheBackend $NAMESPACE/$CR_NAME (profile=cpu, image=$IMAGE)" +# --- apply the CacheBackend -------------------------------------------------- +log "applying CacheBackend $NAMESPACE/$CR_NAME (image=$CACHE_SERVER_IMAGE)" kubectl "${KUBECONFIG_ARGS[@]}" apply -f - <:50051` and the Service's first port = the RPC port. -# Proves the real installed controller selects the vLLM/Mooncake adapter and -# renders the mooncake_master workload via ResolveCacheServer; the real +# Proves the real installed controller selects the vLLM/LMCache adapter with +# a Mooncake binding and renders the mooncake_master provider workload; the real # engine-over-mooncakestore:// path stays for the Mooncake reference stack. # # Distinct from the C2/C6 canaries: those exercise real engine pods + cross-pod @@ -1645,19 +1646,12 @@ if [ "$matched" != "1" ]; then fi log "status.matchedEnginePods=1" -# --- canonical provider resource fallback --------------------------------- -# The paired sample uses canonical remoteStorage.lmCacheServer and omits its -# resources. Canonical defaulting stays renderer-local: admission must not -# repopulate deprecated spec.resources, while the provider renderer still puts -# a bounded 4Gi request / 8Gi limit on the lmcache-server container. This keeps -# the cgroup OOM guard without reviving the retired top-level API field. -log "asserting canonical provider resources stay out of the CR and default onto the rendered Deployment" -cb_legacy_resources="$(kubectl -n "$SAMPLE_NS" get cb qwen-demo-cache \ - -o jsonpath='{.spec.resources}' 2>/dev/null || true)" -if [ -n "$cb_legacy_resources" ]; then - kubectl -n "$SAMPLE_NS" get cb qwen-demo-cache -o yaml || true - fail "cb.spec.resources=$cb_legacy_resources, want absent for a canonical CacheBackend" -fi +# --- provider resource fallback -------------------------------------------- +# The paired sample uses remoteStorage.lmCacheServer and omits its resources. +# Defaulting stays renderer-local: the provider renderer puts a bounded 4Gi +# request / 8Gi limit on the lmcache-server container without persisting it in +# the CacheBackend. +log "asserting provider resources stay out of the CR and default onto the rendered Deployment" cb_provider_resources="$(kubectl -n "$SAMPLE_NS" get cb qwen-demo-cache \ -o jsonpath='{.spec.remoteStorage.lmCacheServer.resources}' 2>/dev/null || true)" if [ -n "$cb_provider_resources" ]; then @@ -1678,7 +1672,7 @@ if [ "$dep_req_mem" != "4Gi" ]; then kubectl -n "$SAMPLE_NS" get deploy qwen-demo-cache -o yaml || true fail "deploy.lmcache-server.resources.requests.memory=$dep_req_mem, want 4Gi (canonical provider fallback not applied)" fi -log "canonical provider resources defaulted on the workload only: requests.memory=4Gi limits.memory=8Gi" +log "provider resources defaulted on the workload only: requests.memory=4Gi limits.memory=8Gi" # --- KV-event readiness gate assertion (operator-facing) -------------------- # The managed backend has an engine pod attached (matchedEnginePods=1), but the @@ -1687,7 +1681,7 @@ log "canonical provider resources defaulted on the workload only: requests.memor # exact demo-day failure mode the gate exists to surface (engine present, # KV-event stream silent). We assert the gate's operator-visible surfaces end to # end on the real install: -# - spec.integration.firstEventTimeout defaulted to 5m (new CRD field + +# - spec.observation.firstEventTimeout defaulted to 5m (CRD field + # admission defaulting); # - once the managed cache-server reaches Available, the gate holds the # backend at Ready=False / reason AwaitingFirstKVEvent — the deterministic @@ -1697,13 +1691,13 @@ log "canonical provider resources defaulted on the workload only: requests.memor # the instant a KV event is observed, so its absence is the gate-specific # "nothing observed" signal. fet="$(kubectl -n "$SAMPLE_NS" get cb qwen-demo-cache \ - -o jsonpath='{.spec.integration.firstEventTimeout}' 2>/dev/null || true)" -# Accept both "5m" (CRD-schema default, applied when the integration block is + -o jsonpath='{.spec.observation.firstEventTimeout}' 2>/dev/null || true)" +# Accept both "5m" (CRD-schema default, applied when the observation block is # present) and "5m0s" (Go Duration.String(), the webhook-materialized form) — # both decode to the same 5m duration. if [ "$fet" != "5m" ] && [ "$fet" != "5m0s" ]; then kubectl -n "$SAMPLE_NS" get cb qwen-demo-cache -o yaml || true - fail "spec.integration.firstEventTimeout=$fet, want 5m (CRD default / webhook defaulter not applied)" + fail "spec.observation.firstEventTimeout=$fet, want 5m (CRD default / webhook defaulter not applied)" fi # The gate only evaluates once the managed cache-server Deployment is Available, @@ -1788,7 +1782,7 @@ log "T2Degraded absent until tier-2 is exercised (no-traffic steady state)" # injected engine to reach CrashLoopBackOff + 120s for the advisory condition to # publish). It MUST run AFTER the KV-event-gate AwaitingFirstKVEvent assertion # above, NOT between matchedEnginePods=1 and that gate. The gate's -# AwaitingFirstKVEvent window is bounded by spec.integration.firstEventTimeout +# AwaitingFirstKVEvent window is bounded by spec.observation.firstEventTimeout # (defaulted to 5m and asserted as 5m above), anchored at the cache-server # Deployment becoming Available; the busybox engine never emits KV events, so the # backend deterministically flips AwaitingFirstKVEvent -> NoKVEventsObserved once @@ -1904,8 +1898,12 @@ kind: CacheBackend metadata: name: unmatched-cache spec: - type: External - endpoint: unmatched-cache.example.com:8200 + runtime: VLLM + type: LMCache + remoteStorage: + provider: LMCacheServer + ownership: External + endpoint: unmatched-cache.example.com:8200 engineSelector: matchLabels: app: definitely-not-qwen @@ -2153,17 +2151,15 @@ while [ -z "$host_observed_generation" ] && [ "$(date +%s)" -lt "$deadline" ]; d done host_runtime="$(kubectl -n "$CANONICAL_SMOKE_NS" get cb "$CANONICAL_HOST_ONLY_CB" \ -o jsonpath='{.spec.runtime}' 2>/dev/null || true)" -host_compat_engine="$(kubectl -n "$CANONICAL_SMOKE_NS" get cb "$CANONICAL_HOST_ONLY_CB" \ - -o jsonpath='{.spec.integration.engine}' 2>/dev/null || true)" host_remote_provider="$(kubectl -n "$CANONICAL_SMOKE_NS" get cb "$CANONICAL_HOST_ONLY_CB" \ -o jsonpath='{.spec.remoteStorage.provider}' 2>/dev/null || true)" host_endpoint="$(kubectl -n "$CANONICAL_SMOKE_NS" get cb "$CANONICAL_HOST_ONLY_CB" \ -o jsonpath='{.status.endpoint}' 2>/dev/null || true)" if [ -z "$host_observed_generation" ] || [ "$host_runtime" != "SGLang" ] || \ - [ "$host_compat_engine" != "sglang" ] || [ -n "$host_remote_provider" ] || \ + [ -n "$host_remote_provider" ] || \ [ -n "$host_endpoint" ]; then kubectl -n "$CANONICAL_SMOKE_NS" get cb "$CANONICAL_HOST_ONLY_CB" -o yaml || true - fail "canonical host-only state is wrong: observedGeneration=$host_observed_generation runtime=$host_runtime integration.engine=$host_compat_engine provider=$host_remote_provider endpoint=$host_endpoint" + fail "canonical host-only state is wrong: observedGeneration=$host_observed_generation runtime=$host_runtime provider=$host_remote_provider endpoint=$host_endpoint" fi for resource in deployment service horizontalpodautoscaler; do if kubectl -n "$CANONICAL_SMOKE_NS" get "$resource" "$CANONICAL_HOST_ONLY_CB" >/dev/null 2>&1; then @@ -2403,12 +2399,15 @@ metadata: name: smoke-reject-no-endpoint namespace: $EXT_SMOKE_NS spec: - type: External - integration: { engine: vllm } + runtime: VLLM + type: LMCache + remoteStorage: + provider: LMCacheServer + ownership: External EOF )" -if ! grep -q "requires spec.endpoint" <<<"$reject_output"; then - fail "admission did not reject External with no endpoint as expected; got: $reject_output" +if ! grep -q "required when remoteStorage.ownership=External" <<<"$reject_output"; then + fail "admission did not reject external ownership with no endpoint as expected; got: $reject_output" fi reject_output="$(kubectl apply -f - <&1 || true @@ -2418,13 +2417,16 @@ metadata: name: smoke-reject-https namespace: $EXT_SMOKE_NS spec: - type: External - endpoint: https://cache.example.com:443/api - integration: { engine: vllm } + runtime: VLLM + type: LMCache + remoteStorage: + provider: LMCacheServer + ownership: External + endpoint: https://cache.example.com:443/api EOF )" if ! grep -q 'scheme "https" is not supported' <<<"$reject_output"; then - fail "admission did not reject External+https scheme as expected; got: $reject_output" + fail "admission did not reject external LMCacheServer+https scheme as expected; got: $reject_output" fi reject_output="$(kubectl apply -f - <&1 || true @@ -2434,13 +2436,16 @@ metadata: name: smoke-reject-no-host namespace: $EXT_SMOKE_NS spec: - type: External - endpoint: "lm://" - integration: { engine: vllm } + runtime: VLLM + type: LMCache + remoteStorage: + provider: LMCacheServer + ownership: External + endpoint: "lm://" EOF )" if ! grep -q "must be a non-empty host AND port" <<<"$reject_output"; then - fail "admission did not reject External+lm:// (no host) as expected; got: $reject_output" + fail "admission did not reject external LMCacheServer+lm:// (no host) as expected; got: $reject_output" fi reject_output="$(kubectl apply -f - <&1 || true @@ -2450,13 +2455,16 @@ metadata: name: smoke-reject-managed-endpoint namespace: $EXT_SMOKE_NS spec: + runtime: VLLM type: LMCache - endpoint: user-supplied.example:8080 - integration: { engine: vllm } + remoteStorage: + provider: LMCacheServer + ownership: Managed + endpoint: user-supplied.example:8080 EOF )" -if ! grep -q "spec.endpoint is only valid when spec.type=External" <<<"$reject_output"; then - fail "admission did not reject non-External + endpoint as expected; got: $reject_output" +if ! grep -q "managed providers publish their observed endpoint" <<<"$reject_output"; then + fail "admission did not reject managed remote storage + endpoint as expected; got: $reject_output" fi # Canonical runtime/cache adapters must explicitly accept their remote binding. @@ -2474,16 +2482,19 @@ spec: type: SGLangHiCache hiCache: ratio: "2" + engineSelector: + matchLabels: + app: sglang-hicache remoteStorage: provider: Redis ownership: Managed redis: {} EOF )" -if ! grep -q 'does not accept remote binding protocol "resp"' <<<"$reject_output"; then +if ! grep -q 'does not accept remote-storage protocol "resp"' <<<"$reject_output"; then fail "admission did not reject canonical SGLangHiCache + Redis as expected; got: $reject_output" fi -log "admission rejected invalid legacy endpoint shapes and canonical SGLangHiCache + Redis" +log "admission rejected invalid canonical endpoint shapes and SGLangHiCache + Redis" # --- CacheBackend admission: scale-to-zero + autoscaling + nil minReplicas --- # The installed validating webhook must reject the combination @@ -2567,8 +2578,8 @@ kubectl create namespace "$EVENTSONLY_SMOKE_NS" --dry-run=client -o yaml | kubec # $EVENTSONLY_SMOKE_CB_NAME; pin the name via a tmp copy so an overridden # tunable still resolves, and set the namespace with -n (the sample is # namespace-less, like the other samples). type=LMCache, integration.mode= -# EventsOnly, backendConfig.model set, no spec.endpoint (rejected on -# non-External) and no spec.autoscaling (rejected for events-only). The +# EventsOnly, observation.modelID set, no remoteStorage, and no spec.autoscaling +# (rejected for events-only). The # sample's engineSelector is irrelevant here: events-only provisions no # workload and the assertions below are all about the CR's own reconcile, so # no matched engine pod is required. @@ -2649,8 +2660,8 @@ done log "events-only CR publishes only Ready/Degraded/Progressing (FunctionalProbeOK/EngineKernelsHealthy/T2Degraded/EngineCompatibility absent)" # Negative-path admission: the misconfiguration the events-only validator -# guards. An EventsOnly + spec.type=External pair must be rejected at admission -# (events-only wires no connector; External provisions a server one would dial). +# guards. EventsOnly plus externally owned remote storage must be rejected at +# admission because events-only wires no connector that could dial it. eo_reject_output="$(kubectl apply -f - <&1 || true apiVersion: inferencecache.io/v1alpha1 kind: CacheBackend @@ -2658,17 +2669,20 @@ metadata: name: smoke-reject-events-only-external namespace: $EVENTSONLY_SMOKE_NS spec: - type: External - endpoint: external-cache.example:8200 + runtime: VLLM + type: LMCache + remoteStorage: + provider: LMCacheServer + ownership: External + endpoint: external-cache.example:8200 integration: - engine: vllm mode: EventsOnly EOF )" -if ! grep -q "is incompatible with spec.type" <<<"$eo_reject_output"; then - fail "admission did not reject EventsOnly+External as expected; got: $eo_reject_output" +if ! grep -q "provision no remote-storage provider" <<<"$eo_reject_output"; then + fail "admission did not reject EventsOnly+external remote storage as expected; got: $eo_reject_output" fi -log "admission rejected EventsOnly+External misconfiguration" +log "admission rejected EventsOnly+external remote-storage misconfiguration" # Clean up — keeps the cluster reusable for KEEP_CLUSTER=1 reruns. kubectl delete cb -n "$EVENTSONLY_SMOKE_NS" "$EVENTSONLY_SMOKE_CB_NAME" --ignore-not-found --wait=false >/dev/null || true @@ -3362,12 +3376,16 @@ metadata: name: $KC_INJECT_CB namespace: $KERNEL_CHECK_SMOKE_NS spec: + runtime: VLLM type: LMCache engineSelector: matchLabels: app: kc-inject-engine - backendConfig: - serverImage: $SAMPLE_CACHE_SERVER_IMAGE + remoteStorage: + provider: LMCacheServer + ownership: Managed + lmCacheServer: + image: $SAMPLE_CACHE_SERVER_IMAGE EOF # Wait for the controller to publish status.endpoint before admitting the engine @@ -3466,12 +3484,16 @@ metadata: annotations: inferencecache.io/lmcache-kernel-check: "report-only" spec: + runtime: VLLM type: LMCache engineSelector: matchLabels: app: kc-cond-engine - backendConfig: - serverImage: $SAMPLE_CACHE_SERVER_IMAGE + remoteStorage: + provider: LMCacheServer + ownership: Managed + lmCacheServer: + image: $SAMPLE_CACHE_SERVER_IMAGE EOF # Wait for status.endpoint before admitting the engine pod. The webhook @@ -3571,6 +3593,7 @@ metadata: annotations: inferencecache.io/lmcache-kernel-check: "strcit" spec: + runtime: VLLM type: LMCache engineSelector: matchLabels: @@ -3874,8 +3897,7 @@ case "$mc_apply_out" in log "Mooncake with the opt-in applies without the engine-hostNetwork warning" ;; esac -# Pin the fixture to the canonical hierarchy. This keeps the real-install smoke -# from silently falling back to the legacy spec.type=Mooncake adapter path. +# Pin the fixture to the canonical hierarchy. mc_runtime="$(kubectl -n "$MOONCAKE_SMOKE_NS" get cb "$MOONCAKE_CB_NAME" -o jsonpath='{.spec.runtime}')" mc_type="$(kubectl -n "$MOONCAKE_SMOKE_NS" get cb "$MOONCAKE_CB_NAME" -o jsonpath='{.spec.type}')" mc_provider="$(kubectl -n "$MOONCAKE_SMOKE_NS" get cb "$MOONCAKE_CB_NAME" -o jsonpath='{.spec.remoteStorage.provider}')" @@ -4015,4 +4037,4 @@ log "Mooncake engine pod: hostNetwork=true, dnsPolicy=ClusterFirstWithHostNet, w kubectl delete namespace "$MOONCAKE_SMOKE_NS" --ignore-not-found --wait=false >/dev/null 2>&1 || true -log "PASS — install bundle came up, CacheIndex + CacheTenant status writing, PromptTemplate + PDTopology schema-only surfaces, server HTTP surface, CachePolicy push adoption, gRPC fail-open (plaintext default), adapter (LoRA) index partitioning on LookupRoute, CacheBackend ↔ engine-pod binding signals + drift cadence, spec.resources defaults + thread-through, External backend end-to-end, Events-only + native SGLang HiCache engine-local lifecycles, /snapshot + /policy + /probe unauth rejection, audience binding on all three endpoints, the opt-in gRPC TLS overlay (incl. the existing LookupRoute call pattern over TLS), kernel-check injection shape + report-only FAIL condition path (EngineKernelsHealthy=False/KernelLoadFailed), the operator 'inferencecache doctor' CLI against the live install, the managed Mooncake backend provisioning contract (stand-in master reaches Available on hostNetwork behind a headless Service, Recreate strategy, mooncakestore:// RPC endpoint in status) plus the engineHostNetwork opt-in end-to-end (warning fires only without it; a matched engine pod is admitted onto hostNetwork with ClusterFirstWithHostNet and the mooncakestore:// connector, while a non-Mooncake engine pod stays on the pod network; real engine KV transfer is NOT exercised here), and every config/samples/ manifest applies cleanly — all work" +log "PASS — install bundle came up, CacheIndex + CacheTenant status writing, PromptTemplate + PDTopology schema-only surfaces, server HTTP surface, CachePolicy push adoption, gRPC fail-open (plaintext default), adapter (LoRA) index partitioning on LookupRoute, CacheBackend ↔ engine-pod binding signals + drift cadence, provider resource defaults + thread-through, External backend end-to-end, Events-only + native SGLang HiCache engine-local lifecycles, /snapshot + /policy + /probe unauth rejection, audience binding on all three endpoints, the opt-in gRPC TLS overlay (incl. the existing LookupRoute call pattern over TLS), kernel-check injection shape + report-only FAIL condition path (EngineKernelsHealthy=False/KernelLoadFailed), the operator 'inferencecache doctor' CLI against the live install, the managed Mooncake backend provisioning contract (stand-in master reaches Available on hostNetwork behind a headless Service, Recreate strategy, mooncakestore:// RPC endpoint in status) plus the engineHostNetwork opt-in end-to-end (warning fires only without it; a matched engine pod is admitted onto hostNetwork with ClusterFirstWithHostNet and the mooncakestore:// connector, while a non-Mooncake engine pod stays on the pod network; real engine KV transfer is NOT exercised here), and every config/samples/ manifest applies cleanly — all work" diff --git a/hack/verify-samples/admission_test.go b/hack/verify-samples/admission_test.go index 3ee36fee..f10dabdc 100644 --- a/hack/verify-samples/admission_test.go +++ b/hack/verify-samples/admission_test.go @@ -18,6 +18,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/webhook" cachev1alpha1 "github.com/cachebox-project/inference-cache/api/v1alpha1" + builtinadapters "github.com/cachebox-project/inference-cache/internal/adapters/builtin" cachewebhookv1alpha1 "github.com/cachebox-project/inference-cache/internal/webhook/v1alpha1" ) @@ -95,7 +96,8 @@ func TestVerifySamplesAdmissionEndToEnd(t *testing.T) { if err != nil { t.Fatalf("ctrl.NewManager: %v", err) } - if err := cachewebhookv1alpha1.SetupCacheBackendWebhookWithManager(mgr, nil); err != nil { + registries := builtinadapters.New() + if err := cachewebhookv1alpha1.SetupCacheBackendWebhookWithManager(mgr, registries.Runtime); err != nil { t.Fatalf("register CacheBackend webhook: %v", err) } if err := cachewebhookv1alpha1.SetupCachePolicyWebhookWithManager(mgr); err != nil { @@ -132,10 +134,8 @@ func TestVerifySamplesAdmissionEndToEnd(t *testing.T) { good := &cachev1alpha1.CacheBackend{ ObjectMeta: metav1.ObjectMeta{Name: "good", Namespace: "default"}, Spec: cachev1alpha1.CacheBackendSpec{ - Type: cachev1alpha1.CacheBackendTypeLMCache, - Integration: &cachev1alpha1.CacheBackendIntegrationSpec{ - Engine: "vllm", - }, + Runtime: cachev1alpha1.CacheBackendRuntimeVLLM, + Type: cachev1alpha1.CacheBackendTypeLMCache, }, } if err := cl.Create(ctx, good, client.DryRunAll); err != nil { @@ -143,17 +143,15 @@ func TestVerifySamplesAdmissionEndToEnd(t *testing.T) { } }) - // Known-BAD: an engine the adapter registry has never heard of. + // Known-BAD: a runtime/cache pair the adapter registry does not support. // Admission MUST reject with a message that names the offending // (engine, type) pair so a real operator gets an actionable error. t.Run("bad_engine_is_rejected", func(t *testing.T) { bad := &cachev1alpha1.CacheBackend{ ObjectMeta: metav1.ObjectMeta{Name: "bad", Namespace: "default"}, Spec: cachev1alpha1.CacheBackendSpec{ - Type: cachev1alpha1.CacheBackendTypeLMCache, - Integration: &cachev1alpha1.CacheBackendIntegrationSpec{ - Engine: "bogus", - }, + Runtime: cachev1alpha1.CacheBackendRuntimeVLLM, + Type: cachev1alpha1.CacheBackendTypeSGLangHiCache, }, } err := cl.Create(ctx, bad, client.DryRunAll) diff --git a/hack/verify-samples/main.go b/hack/verify-samples/main.go index 6044f5ce..d158df27 100644 --- a/hack/verify-samples/main.go +++ b/hack/verify-samples/main.go @@ -48,6 +48,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/webhook" cachev1alpha1 "github.com/cachebox-project/inference-cache/api/v1alpha1" + builtinadapters "github.com/cachebox-project/inference-cache/internal/adapters/builtin" cachewebhookv1alpha1 "github.com/cachebox-project/inference-cache/internal/webhook/v1alpha1" ) @@ -145,12 +146,13 @@ func run() error { } // Register the CacheBackend defaulting + validating webhooks with the - // shipping adapter registry (nil → defaultShippingRegistry, the same - // wiring the controller uses in production). The Pod injector is + // shipping adapter registry, matching the controller's production wiring. + // The Pod injector is // intentionally NOT registered: its MutatingWebhookConfiguration uses // failurePolicy=Ignore, so Pod creates (none in this suite anyway) // would just bypass it; CacheBackend is what we need to exercise. - if err := cachewebhookv1alpha1.SetupCacheBackendWebhookWithManager(mgr, nil); err != nil { + registries := builtinadapters.New() + if err := cachewebhookv1alpha1.SetupCacheBackendWebhookWithManager(mgr, registries.Runtime); err != nil { return fmt.Errorf("register CacheBackend webhook: %w", err) } // The CachePolicy + CacheTenant webhooks are now part of the shipped diff --git a/internal/adapters/builtin/boundaries_test.go b/internal/adapters/builtin/boundaries_test.go index 3e852632..c224251e 100644 --- a/internal/adapters/builtin/boundaries_test.go +++ b/internal/adapters/builtin/boundaries_test.go @@ -14,44 +14,54 @@ import ( const modulePath = "github.com/cachebox-project/inference-cache" -func TestControllerProductionImportsRespectBoundaries(t *testing.T) { +func TestProductionImportsRespectAdapterBoundaries(t *testing.T) { t.Parallel() root := repositoryRoot(t) - controllerRoot := filepath.Join(root, "internal", "controller") - var files []string - err := filepath.WalkDir(controllerRoot, func(path string, entry fs.DirEntry, walkErr error) error { - if walkErr != nil { - return walkErr - } - if entry.IsDir() || filepath.Ext(path) != ".go" || strings.HasSuffix(path, "_test.go") { - return nil - } - files = append(files, path) - return nil - }) - if err != nil { - t.Fatalf("walk controller files: %v", err) + scopes := []struct { + root string + banned []string + }{ + { + root: filepath.Join(root, "internal", "controller"), + banned: []string{ + modulePath + "/pkg/server", + modulePath + "/internal/webhook", + modulePath + "/internal/adapters/builtin", + }, + }, + { + root: filepath.Join(root, "internal", "webhook"), + banned: []string{modulePath + "/internal/adapters/builtin"}, + }, } - banned := map[string]struct{}{ - modulePath + "/pkg/server": {}, - modulePath + "/internal/webhook": {}, - } - for _, path := range files { - file, err := parser.ParseFile(token.NewFileSet(), path, nil, parser.ImportsOnly) - if err != nil { - t.Fatalf("parse %s: %v", path, err) - } - for _, imported := range file.Imports { - pathValue, err := strconv.Unquote(imported.Path.Value) + for _, scope := range scopes { + err := filepath.WalkDir(scope.root, func(path string, entry fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if entry.IsDir() || filepath.Ext(path) != ".go" || strings.HasSuffix(path, "_test.go") { + return nil + } + file, err := parser.ParseFile(token.NewFileSet(), path, nil, parser.ImportsOnly) if err != nil { - t.Fatalf("unquote import in %s: %v", path, err) + t.Fatalf("parse %s: %v", path, err) } - for prefix := range banned { - if pathValue == prefix || strings.HasPrefix(pathValue, prefix+"/") { - t.Errorf("%s imports implementation package %q", filepath.Base(path), pathValue) + for _, imported := range file.Imports { + pathValue, err := strconv.Unquote(imported.Path.Value) + if err != nil { + t.Fatalf("unquote import in %s: %v", path, err) + } + for _, prefix := range scope.banned { + if pathValue == prefix || strings.HasPrefix(pathValue, prefix+"/") { + t.Errorf("%s imports implementation package %q", filepath.Base(path), pathValue) + } } } + return nil + }) + if err != nil { + t.Fatalf("walk production files: %v", err) } } } diff --git a/internal/adapters/builtin/registry.go b/internal/adapters/builtin/registry.go index c66c884c..4a871174 100644 --- a/internal/adapters/builtin/registry.go +++ b/internal/adapters/builtin/registry.go @@ -1,11 +1,10 @@ package builtin import ( + builtinruntime "github.com/cachebox-project/inference-cache/internal/adapters/builtin/runtime" + builtinstorage "github.com/cachebox-project/inference-cache/internal/adapters/builtin/storage" backendadapter "github.com/cachebox-project/inference-cache/pkg/adapters/backend" - backendprovider "github.com/cachebox-project/inference-cache/pkg/adapters/backend/provider" adapterruntime "github.com/cachebox-project/inference-cache/pkg/adapters/runtime" - externaladapter "github.com/cachebox-project/inference-cache/pkg/adapters/runtime/external" - sglangadapter "github.com/cachebox-project/inference-cache/pkg/adapters/runtime/sglang" ) // Registries contains the complete runtime and remote-storage provider sets @@ -18,13 +17,13 @@ type Registries struct { // New constructs the complete built-in registries. Runtime options are applied // consistently to every adapter that injects the subscriber sidecar. func New(opts ...adapterruntime.Option) Registries { - runtimeRegistry := adapterruntime.NewCoreRegistry(opts...) - runtimeRegistry.Register(externaladapter.NewAdapter()) - runtimeRegistry.Register(sglangadapter.NewAdapter(opts...)) - runtimeRegistry.Register(sglangadapter.NewHiCacheAdapter(opts...)) + runtimeRegistry := adapterruntime.NewRegistry() + runtimeRegistry.Register(builtinruntime.NewVLLMLMCacheAdapter(opts...)) + runtimeRegistry.Register(builtinruntime.NewSGLangLMCacheAdapter(opts...)) + runtimeRegistry.Register(builtinruntime.NewSGLangHiCacheAdapter(opts...)) return Registries{ Runtime: runtimeRegistry, - Storage: backendprovider.DefaultRegistry(), + Storage: builtinstorage.DefaultRegistry(), } } diff --git a/internal/adapters/builtin/registry_test.go b/internal/adapters/builtin/registry_test.go index 67bd16c4..688a319c 100644 --- a/internal/adapters/builtin/registry_test.go +++ b/internal/adapters/builtin/registry_test.go @@ -18,8 +18,6 @@ func TestNewIncludesEveryShippingRuntimeAdapter(t *testing.T) { integration *cachev1alpha1.CacheBackendIntegrationSpec }{ {name: "vllm lmcache", runtime: adapterruntime.RuntimeVLLM, backend: cachev1alpha1.CacheBackendTypeLMCache}, - {name: "vllm mooncake legacy", runtime: adapterruntime.RuntimeVLLM, backend: cachev1alpha1.CacheBackendTypeMooncake}, - {name: "vllm external legacy", runtime: adapterruntime.RuntimeVLLM, backend: cachev1alpha1.CacheBackendTypeExternal}, {name: "sglang lmcache", runtime: adapterruntime.RuntimeSGLang, backend: cachev1alpha1.CacheBackendTypeLMCache}, {name: "sglang hicache", runtime: adapterruntime.RuntimeSGLang, backend: cachev1alpha1.CacheBackendTypeSGLangHiCache}, } { diff --git a/internal/adapters/builtin/runtime/contract_aliases_test.go b/internal/adapters/builtin/runtime/contract_aliases_test.go new file mode 100644 index 00000000..7a6a91c9 --- /dev/null +++ b/internal/adapters/builtin/runtime/contract_aliases_test.go @@ -0,0 +1,39 @@ +package runtime + +import adapterruntime "github.com/cachebox-project/inference-cache/pkg/adapters/runtime" + +type ( + KVCacheRuntimeAdapter = adapterruntime.KVCacheRuntimeAdapter + InitContainerProvider = adapterruntime.InitContainerProvider + Options = adapterruntime.Options + Option = adapterruntime.Option + RuntimeID = adapterruntime.RuntimeID + SupportedPair = adapterruntime.SupportedPair + SubscriberSidecarParams = adapterruntime.SubscriberSidecarParams +) + +const ( + RuntimeVLLM = adapterruntime.RuntimeVLLM + RuntimeSGLang = adapterruntime.RuntimeSGLang + RuntimeReference = adapterruntime.RuntimeReference + LMCacheKernelCheckContainerName = adapterruntime.LMCacheKernelCheckContainerName + AnnotationLMCacheKernelCheck = adapterruntime.AnnotationLMCacheKernelCheck + KernelCheckModeAuto = adapterruntime.KernelCheckModeAuto + KernelCheckModeReportOnly = adapterruntime.KernelCheckModeReportOnly + KernelCheckModeStrict = adapterruntime.KernelCheckModeStrict + KernelCheckModeOff = adapterruntime.KernelCheckModeOff + KernelCheckMsgOK = adapterruntime.KernelCheckMsgOK + KernelCheckMsgFailPrefix = adapterruntime.KernelCheckMsgFailPrefix + EnvKernelCheckStrict = adapterruntime.EnvKernelCheckStrict + DefaultSubscriberImage = adapterruntime.DefaultSubscriberImage + DefaultPolicyServerGRPCAddress = adapterruntime.DefaultPolicyServerGRPCAddress + SubscriberContainerName = adapterruntime.SubscriberContainerName +) + +var ( + RenderSubscriberSidecar = adapterruntime.RenderSubscriberSidecar + WithSubscriberImage = adapterruntime.WithSubscriberImage + WithPolicyServerGRPCAddress = adapterruntime.WithPolicyServerGRPCAddress + NewRegistry = adapterruntime.NewRegistry + NewReferenceAdapter = adapterruntime.NewReferenceAdapter +) diff --git a/internal/adapters/builtin/runtime/doc.go b/internal/adapters/builtin/runtime/doc.go new file mode 100644 index 00000000..0d06e750 --- /dev/null +++ b/internal/adapters/builtin/runtime/doc.go @@ -0,0 +1,18 @@ +// Package runtime contains the runtime-adapter implementations shipped by the +// controller binary. Public extension contracts remain in pkg/adapters/runtime; +// this internal package owns the concrete vLLM+LMCache, SGLang+LMCache, and +// SGLang+HiCache integrations and their engine wire rendering. +// +// SGLang adopted vLLM's KV-event wire wholesale: --kv-events-config drives a +// ZmqEventPublisher emitting the same msgspec array-like BlockStored / +// BlockRemoved / AllBlocksCleared tuples, so the shipped kvevent-subscriber +// binary decodes SGLang's stream unchanged — the only difference is the +// --hash-scheme=sglang tag that keeps SGLang prefixes in their own index +// domain (no cross-engine false hits against vLLM entries with identical +// prefix bytes). The engine-side LMCache *launch* surface differs from vLLM +// (--enable-lmcache + LMCACHE_USE_EXPERIMENTAL rather than +// --kv-transfer-config). Managed cache-server rendering belongs to +// internal/adapters/builtin/storage; subscriber-sidecar rendering remains shared +// in pkg/adapters/runtime/kvevent_subscriber.go, with common defaults in +// pkg/adapters/runtime/lmcache_shared.go. +package runtime diff --git a/internal/adapters/builtin/runtime/lmcachecheck.go b/internal/adapters/builtin/runtime/lmcachecheck.go new file mode 100644 index 00000000..16e1574b --- /dev/null +++ b/internal/adapters/builtin/runtime/lmcachecheck.go @@ -0,0 +1,191 @@ +package runtime + +import ( + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" + + cachev1alpha1 "github.com/cachebox-project/inference-cache/api/v1alpha1" + adapterruntime "github.com/cachebox-project/inference-cache/pkg/adapters/runtime" +) + +// gpuResourceName is the extended resource an engine container requests when +// it wants a GPU. Auto mode skips CPU-only engines. +const gpuResourceName = corev1.ResourceName("nvidia.com/gpu") + +// kernelCheckScript is the Python the init container runs against the engine +// image. It locates the package dir WITHOUT executing lmcache.__init__ (which +// swallows the c_ops failure into a WARNING and overrides +// sys.modules["lmcache.c_ops"] with a fallback shim, so a naive +// `import lmcache.c_ops` ALWAYS succeeds — a silent no-op). Instead it +// dlopens the native c_ops*.so from disk via ctypes.CDLL, which re-does the +// real dynamic load and raises on a missing/mismatched libcudart (empirically: +// "OSError: libcudart.so.13: cannot open shared object file"). torch MUST be +// imported first — the extension DT_NEEDs libtorch's libc10.so. +const kernelCheckScript = ` +import sys, os, glob, importlib.util, ctypes +STRICT = os.environ.get("KERNEL_CHECK_STRICT") == "1" +MSG = "/dev/termination-log" +def emit(s): + try: + with open(MSG, "w") as f: f.write(s[:3500]) + except Exception: + pass +def fail(s): + emit("FAIL: " + s) + sys.exit(1 if STRICT else 0) +try: + spec = importlib.util.find_spec("lmcache") + locs = list(spec.submodule_search_locations) if spec else [] + if not locs: + fail("lmcache not importable") + sos = sorted(glob.glob(os.path.join(locs[0], "c_ops*.so"))) + if not sos: + fail("no native c_ops extension present (pure-python/CPU build)") + import torch # required: c_ops.so DT_NEEDED libtorch (libc10.so) + # dlopen the native extension to force the dynamic loader to resolve every + # DT_NEEDED lib (libtorch, libcudart, ...). This is where a CUDA-kernel + # mismatch surfaces (e.g. a cu13 wheel on a cu12 image → "libcudart.so.13: + # cannot open shared object file"). ctypes.CDLL is used rather than + # importlib.exec_module on purpose: exec_module derives the C init symbol + # (PyInit_) from the spec name and would FAIL to find it for any + # name other than the extension's own, false-failing a HEALTHY engine. + # CDLL needs no init symbol — it tests exactly the dlopen/DT_NEEDED + # resolution where the kernel/CUDA mismatch lives. + ctypes.CDLL(sos[0]) + emit("OK") +except SystemExit: + raise +except BaseException as e: + fail("%s: %r" % (type(e).__name__, e)) +` + +// resolveKernelCheckMode returns the effective mode for a CacheBackend. +// Unrecognized values fall back to auto; admission rejects them before they +// reach here (IsValidKernelCheckMode), so in practice only the known values +// arrive — the fallback is a defense-in-depth default, not the typo guard. +func resolveKernelCheckMode(cache *cachev1alpha1.CacheBackend) string { + if cache == nil { + return adapterruntime.KernelCheckModeAuto + } + switch cache.Annotations[adapterruntime.AnnotationLMCacheKernelCheck] { + case adapterruntime.KernelCheckModeReportOnly: + return adapterruntime.KernelCheckModeReportOnly + case adapterruntime.KernelCheckModeStrict: + return adapterruntime.KernelCheckModeStrict + case adapterruntime.KernelCheckModeOff: + return adapterruntime.KernelCheckModeOff + default: + return adapterruntime.KernelCheckModeAuto + } +} + +// engineContainerForKernelCheck resolves the engine container in pod whose +// image the init container reuses. Mirrors the adapter's documented +// convention: prefer the container named EngineContainerName; else, a +// single-container pod IS the engine; else (multi-container, no match) return +// nil so the caller skips. MUST be resolved before the webhook appends the +// observation sidecar (which would defeat the single-container fallback). +func engineContainerForKernelCheck(pod *corev1.Pod) *corev1.Container { + if pod == nil { + return nil + } + for i := range pod.Spec.Containers { + if pod.Spec.Containers[i].Name == EngineContainerName { + return &pod.Spec.Containers[i] + } + } + if len(pod.Spec.Containers) == 1 { + return &pod.Spec.Containers[0] + } + return nil +} + +// requestsGPU reports whether c requests an nvidia.com/gpu (limit or request +// with a positive quantity). +func requestsGPU(c *corev1.Container) bool { + if c == nil { + return false + } + for _, rl := range []corev1.ResourceList{c.Resources.Limits, c.Resources.Requests} { + if q, ok := rl[gpuResourceName]; ok && q.Sign() > 0 { + return true + } + } + return false +} + +// kernelCheckResources is the resource envelope for the init container: small +// CPU/memory requests, no limits, no nvidia.com/gpu. There is no resource shape +// that is fail-open under EVERY namespace policy (a ResourceQuota/LimitRange may +// REQUIRE per-container requests, while a LimitRange max may REJECT large +// ones); this is the most broadly-compatible compromise: +// - No nvidia.com/gpu: the missing-libcudart dlopen failure is caught at load +// time without a device. +// - Small requests (not none): a namespace with a `requests.*` ResourceQuota +// or a min-only LimitRange rejects a container that specifies no request, +// which would block the engine pod — so the check declares modest ones. The +// engine container (a GPU vLLM image needing GiB of RAM) requests far more, +// so these are below any per-container max it already satisfies AND are +// subsumed by it in the pod's effective request (init requests are max'd +// with, not summed onto, app requests) — no scheduling/quota footprint +// increase. +// - No limits: an explicit limit could exceed a LimitRange per-container max +// the engine still satisfies; omitting it lets any LimitRange default apply +// within bounds and leaves `import torch` bounded only by the pod/node. +func kernelCheckResources() corev1.ResourceRequirements { + return corev1.ResourceRequirements{ + Requests: corev1.ResourceList{ + corev1.ResourceCPU: resource.MustParse("50m"), + corev1.ResourceMemory: resource.MustParse("256Mi"), + }, + } +} + +// KernelCheckInitContainer renders the LMCache kernel-check init container for +// a vLLM+LMCache engine pod, or nil when the configured gate does not apply. +// Auto mode checks GPU pods in report-only mode; report-only and strict force +// injection; off disables it. +func (vllmLMCacheAdapter) KernelCheckInitContainer(cache *cachev1alpha1.CacheBackend, pod *corev1.Pod) (*corev1.Container, error) { + if cache == nil || pod == nil { + return nil, nil + } + mode := resolveKernelCheckMode(cache) + if mode == adapterruntime.KernelCheckModeOff { + return nil, nil + } + engine := engineContainerForKernelCheck(pod) + if engine == nil || engine.Image == "" { + return nil, nil + } + if mode == adapterruntime.KernelCheckModeAuto && !requestsGPU(engine) { + return nil, nil + } + + strictValue := "0" + if mode == adapterruntime.KernelCheckModeStrict { + strictValue = "1" + } + env := make([]corev1.EnvVar, 0, len(engine.Env)+1) + for _, entry := range engine.Env { + if entry.Name != adapterruntime.EnvKernelCheckStrict { + env = append(env, entry) + } + } + env = append(env, corev1.EnvVar{Name: adapterruntime.EnvKernelCheckStrict, Value: strictValue}) + + return &corev1.Container{ + Name: adapterruntime.LMCacheKernelCheckContainerName, + Image: engine.Image, + ImagePullPolicy: engine.ImagePullPolicy, + SecurityContext: engine.SecurityContext.DeepCopy(), + WorkingDir: engine.WorkingDir, + Command: []string{"python3", "-c", kernelCheckScript}, + Env: env, + EnvFrom: append([]corev1.EnvFromSource(nil), engine.EnvFrom...), + VolumeMounts: append([]corev1.VolumeMount(nil), engine.VolumeMounts...), + VolumeDevices: append([]corev1.VolumeDevice(nil), engine.VolumeDevices...), + Resources: kernelCheckResources(), + TerminationMessagePath: "/dev/termination-log", + TerminationMessagePolicy: corev1.TerminationMessageReadFile, + }, nil +} diff --git a/pkg/adapters/runtime/kernelcheck_script_test.go b/internal/adapters/builtin/runtime/lmcachecheck_script_test.go similarity index 100% rename from pkg/adapters/runtime/kernelcheck_script_test.go rename to internal/adapters/builtin/runtime/lmcachecheck_script_test.go diff --git a/pkg/adapters/runtime/kernelcheck_test.go b/internal/adapters/builtin/runtime/lmcachecheck_test.go similarity index 100% rename from pkg/adapters/runtime/kernelcheck_test.go rename to internal/adapters/builtin/runtime/lmcachecheck_test.go diff --git a/pkg/adapters/runtime/sglang/hicache.go b/internal/adapters/builtin/runtime/sglang_hicache.go similarity index 84% rename from pkg/adapters/runtime/sglang/hicache.go rename to internal/adapters/builtin/runtime/sglang_hicache.go index cedebcce..a273fa53 100644 --- a/pkg/adapters/runtime/sglang/hicache.go +++ b/internal/adapters/builtin/runtime/sglang_hicache.go @@ -1,4 +1,4 @@ -package sglang +package runtime import ( "fmt" @@ -11,7 +11,6 @@ import ( cachev1alpha1 "github.com/cachebox-project/inference-cache/api/v1alpha1" backendadapter "github.com/cachebox-project/inference-cache/pkg/adapters/backend" runtimeadapter "github.com/cachebox-project/inference-cache/pkg/adapters/runtime" - "github.com/cachebox-project/inference-cache/pkg/adapters/runtime/internal/enginewire" ) const ( @@ -23,58 +22,45 @@ const ( SGLangHiCacheMemoryLayoutArg = "--hicache-mem-layout" ) -type hiCacheAdapter struct { +type sglangHiCacheAdapter struct { subscriberImage string policyServerGRPCAddress string } -// NewHiCacheAdapter returns the endpoint-free adapter for SGLang's native +// NewSGLangHiCacheAdapter returns the endpoint-free adapter for SGLang's native // host-memory hierarchical cache. -func NewHiCacheAdapter(opts ...runtimeadapter.Option) runtimeadapter.KVCacheRuntimeAdapter { +func NewSGLangHiCacheAdapter(opts ...runtimeadapter.Option) runtimeadapter.KVCacheRuntimeAdapter { var cfg runtimeadapter.Options for _, option := range opts { option(&cfg) } - return hiCacheAdapter{ + return sglangHiCacheAdapter{ subscriberImage: cfg.SubscriberImage, policyServerGRPCAddress: cfg.PolicyServerGRPCAddress, } } -func (hiCacheAdapter) Supports(runtime runtimeadapter.RuntimeID, cache *cachev1alpha1.CacheBackend) bool { +func (sglangHiCacheAdapter) Supports(runtime runtimeadapter.RuntimeID, cache *cachev1alpha1.CacheBackend) bool { return cache != nil && runtime == runtimeadapter.RuntimeSGLang && cache.Spec.Type == cachev1alpha1.CacheBackendTypeSGLangHiCache } -func (hiCacheAdapter) SupportedPairs() []runtimeadapter.SupportedPair { +func (sglangHiCacheAdapter) SupportedPairs() []runtimeadapter.SupportedPair { return []runtimeadapter.SupportedPair{{ Runtime: runtimeadapter.RuntimeSGLang, Backend: cachev1alpha1.CacheBackendTypeSGLangHiCache, }} } -func (hiCacheAdapter) RequiresEndpoint() bool { return false } - -func (hiCacheAdapter) SupportsRemoteBinding(binding *backendadapter.Binding) bool { +func (sglangHiCacheAdapter) SupportsBinding(binding *backendadapter.Binding) bool { return binding == nil } -func (a hiCacheAdapter) InjectEngineConfigWithBinding(pod *corev1.PodSpec, binding *backendadapter.Binding, cache *cachev1alpha1.CacheBackend) error { +func (sglangHiCacheAdapter) InjectEngineConfig(pod *corev1.PodSpec, binding *backendadapter.Binding, cache *cachev1alpha1.CacheBackend) error { if binding != nil { return fmt.Errorf("SGLang HiCache adapter does not support remote binding protocol %q", binding.Protocol) } - return a.InjectEngineConfig(pod, "", cache) -} - -func (hiCacheAdapter) ResolveCacheServer(cache *cachev1alpha1.CacheBackend) (*corev1.PodSpec, *corev1.Service, error) { - if err := ValidateHiCacheBackend(cache); err != nil { - return nil, nil, err - } - return nil, nil, nil -} - -func (hiCacheAdapter) InjectEngineConfig(pod *corev1.PodSpec, _ string, cache *cachev1alpha1.CacheBackend) error { cfg, err := resolveHiCacheConfig(cache) if err != nil { return err @@ -85,7 +71,7 @@ func (hiCacheAdapter) InjectEngineConfig(pod *corev1.PodSpec, _ string, cache *c if len(pod.Containers) == 0 { return fmt.Errorf("inject SGLang HiCache config: pod has no containers") } - engineIndex, err := enginewire.EngineContainerIndexNamed(pod, enginewire.SGLangEngineContainerName) + engineIndex, err := EngineContainerIndexNamed(pod, SGLangEngineContainerName) if err != nil { return err } @@ -94,7 +80,7 @@ func (hiCacheAdapter) InjectEngineConfig(pod *corev1.PodSpec, _ string, cache *c // copy. The pod webhook fail-opens on an error, so injection must be // all-or-nothing. args := pod.Containers[engineIndex].Args - if hasArg(args, enginewire.SGLangEnableLMCacheArg) || hasArg(args, enginewire.SGLangConfigFileArg) { + if hasArg(args, SGLangEnableLMCacheArg) || hasArg(args, SGLangConfigFileArg) { return fmt.Errorf("inject SGLang HiCache config: native HiCache conflicts with SGLang LMCache arguments") } if err := validateEnableArg(args); err != nil { @@ -189,22 +175,22 @@ func (hiCacheAdapter) InjectEngineConfig(pod *corev1.PodSpec, _ string, cache *c return nil } -func (hiCacheAdapter) InjectRouterConfig(*corev1.PodSpec, string, *cachev1alpha1.CacheBackend) error { +func (sglangHiCacheAdapter) InjectRouterConfig(*corev1.PodSpec, *backendadapter.Binding, *cachev1alpha1.CacheBackend) error { return nil } -func (a hiCacheAdapter) ObservationSidecar(cache *cachev1alpha1.CacheBackend, pod *corev1.Pod) (*corev1.Container, error) { +func (a sglangHiCacheAdapter) ObservationSidecar(cache *cachev1alpha1.CacheBackend, pod *corev1.Pod) (*corev1.Container, error) { return runtimeadapter.RenderSubscriberSidecar(runtimeadapter.SubscriberSidecarParams{ Image: a.subscriberImage, ServerAddr: a.policyServerGRPCAddress, Cache: cache, Pod: pod, - HashScheme: subscriberHashScheme, - EngineZMQPortStr: defaultEngineZMQPortStr, + HashScheme: sglangSubscriberHashScheme, + EngineZMQPortStr: sglangDefaultEngineZMQPortStr, }) } -func (hiCacheAdapter) ReservedArgs() []string { +func (sglangHiCacheAdapter) ReservedArgs() []string { return hiCacheReservedArgs() } @@ -219,10 +205,10 @@ func hiCacheReservedArgs() []string { } } -func (hiCacheAdapter) ReservedEnv() []string { return nil } +func (sglangHiCacheAdapter) ReservedEnv() []string { return nil } -func (hiCacheAdapter) EngineContainerName() string { - return enginewire.SGLangEngineContainerName +func (sglangHiCacheAdapter) EngineContainerName() string { + return SGLangEngineContainerName } type resolvedHiCacheConfig struct { @@ -250,7 +236,7 @@ func resolveHiCacheConfig(cache *cachev1alpha1.CacheBackend) (resolvedHiCacheCon cachev1alpha1.CacheBackendTypeSGLangHiCache) } if runtimeadapter.ResolveRuntimeID(cache) != runtimeadapter.RuntimeSGLang { - return resolvedHiCacheConfig{}, fmt.Errorf("resolve SGLang HiCache config: integration.engine must be sglang") + return resolvedHiCacheConfig{}, fmt.Errorf("resolve SGLang HiCache config: spec.runtime must be SGLang") } if cachev1alpha1.IntegrationMode(cache.Spec.Integration) != cachev1alpha1.CacheBackendIntegrationModeOffload { return resolvedHiCacheConfig{}, fmt.Errorf("resolve SGLang HiCache config: integration.mode must be Offload") @@ -267,17 +253,9 @@ func resolveHiCacheConfig(cache *cachev1alpha1.CacheBackend) (resolvedHiCacheCon if cache.Spec.Autoscaling != nil { return resolvedHiCacheConfig{}, fmt.Errorf("resolve SGLang HiCache config: autoscaling is unsupported for an engine-local backend") } - if strings.TrimSpace(cache.Spec.Endpoint) != "" { - return resolvedHiCacheConfig{}, fmt.Errorf("resolve SGLang HiCache config: spec.endpoint is unsupported for an engine-local backend") - } if cache.Spec.EngineSelector == nil || len(cache.Spec.EngineSelector.MatchLabels) == 0 { return resolvedHiCacheConfig{}, fmt.Errorf("resolve SGLang HiCache config: spec.engineSelector.matchLabels is required") } - for key := range cache.Spec.BackendConfig { - if key != "model" { - return resolvedHiCacheConfig{}, fmt.Errorf("resolve SGLang HiCache config: backendConfig key %q is unsupported; only model is allowed", key) - } - } if cache.Spec.Integration != nil && cache.Spec.Integration.EngineOverrides != nil { overrides := cache.Spec.Integration.EngineOverrides for _, arg := range overrides.Args { @@ -467,7 +445,4 @@ func equivalentNumber(actual, desired string) bool { actualValue == desiredValue } -var ( - _ runtimeadapter.KVCacheRuntimeAdapter = hiCacheAdapter{} - _ runtimeadapter.EndpointRequirement = hiCacheAdapter{} -) +var _ runtimeadapter.KVCacheRuntimeAdapter = sglangHiCacheAdapter{} diff --git a/pkg/adapters/runtime/sglang/hicache_test.go b/internal/adapters/builtin/runtime/sglang_hicache_test.go similarity index 80% rename from pkg/adapters/runtime/sglang/hicache_test.go rename to internal/adapters/builtin/runtime/sglang_hicache_test.go index 01232eb8..735ec027 100644 --- a/pkg/adapters/runtime/sglang/hicache_test.go +++ b/internal/adapters/builtin/runtime/sglang_hicache_test.go @@ -1,4 +1,4 @@ -package sglang +package runtime import ( "reflect" @@ -11,18 +11,17 @@ import ( cachev1alpha1 "github.com/cachebox-project/inference-cache/api/v1alpha1" backendadapter "github.com/cachebox-project/inference-cache/pkg/adapters/backend" runtimeadapter "github.com/cachebox-project/inference-cache/pkg/adapters/runtime" - "github.com/cachebox-project/inference-cache/pkg/adapters/runtime/internal/enginewire" ) func newHiCacheBackend(spec *cachev1alpha1.SGLangHiCacheSpec) *cachev1alpha1.CacheBackend { return &cachev1alpha1.CacheBackend{ ObjectMeta: metav1.ObjectMeta{Name: "hicache", Namespace: "ns1"}, Spec: cachev1alpha1.CacheBackendSpec{ - Type: cachev1alpha1.CacheBackendTypeSGLangHiCache, + Runtime: cachev1alpha1.CacheBackendRuntimeSGLang, + Type: cachev1alpha1.CacheBackendTypeSGLangHiCache, Integration: &cachev1alpha1.CacheBackendIntegrationSpec{ - Engine: "sglang", - Mode: cachev1alpha1.CacheBackendIntegrationModeOffload, - Role: cachev1alpha1.CacheBackendIntegrationRoleReadWrite, + Mode: cachev1alpha1.CacheBackendIntegrationModeOffload, + Role: cachev1alpha1.CacheBackendIntegrationRoleReadWrite, }, EngineSelector: &cachev1alpha1.CacheBackendEngineSelector{ MatchLabels: map[string]string{"app": "sglang"}, @@ -33,7 +32,7 @@ func newHiCacheBackend(spec *cachev1alpha1.SGLangHiCacheSpec) *cachev1alpha1.Cac } func TestHiCacheAdapterContract(t *testing.T) { - adapter := NewHiCacheAdapter() + adapter := NewSGLangHiCacheAdapter() cache := newHiCacheBackend(&cachev1alpha1.SGLangHiCacheSpec{Ratio: "2"}) if !adapter.Supports(runtimeadapter.RuntimeSGLang, cache) { @@ -47,23 +46,10 @@ func TestHiCacheAdapterContract(t *testing.T) { t.Fatal("SGLangHiCache adapter unexpectedly supports LMCache") } - requirement, ok := adapter.(runtimeadapter.EndpointRequirement) - if !ok || requirement.RequiresEndpoint() { - t.Fatalf("EndpointRequirement = (%v, %v), want implemented and false", ok, requirement) - } - if pod, svc, err := runtimeadapter.ResolveLegacyCacheServer(adapter, newHiCacheBackend( - &cachev1alpha1.SGLangHiCacheSpec{Ratio: "2"}, - )); err != nil || pod != nil || svc != nil { - t.Fatalf("ResolveCacheServer = (%v, %v, %v), want (nil, nil, nil)", pod, svc, err) - } - bindingAware, ok := adapter.(runtimeadapter.RemoteBindingAdapter) - if !ok { - t.Fatal("SGLangHiCache adapter does not implement RemoteBindingAdapter") - } - if !bindingAware.SupportsRemoteBinding(nil) { + if !adapter.SupportsBinding(nil) { t.Fatal("SGLangHiCache adapter must accept a nil host-only binding") } - if bindingAware.SupportsRemoteBinding(&backendadapter.Binding{Protocol: backendadapter.ProtocolRESP}) { + if adapter.SupportsBinding(&backendadapter.Binding{Protocol: backendadapter.ProtocolRESP}) { t.Fatal("SGLangHiCache adapter unexpectedly accepts remote storage") } } @@ -79,7 +65,7 @@ func TestHiCacheInjectsOnlyRequestedFlags(t *testing.T) { pod := &corev1.PodSpec{ Containers: []corev1.Container{ { - Name: enginewire.SGLangEngineContainerName, + Name: SGLangEngineContainerName, Image: "sglang:test", Args: []string{"--model-path", "model"}, Env: []corev1.EnvVar{{Name: "KEEP", Value: "true"}}, @@ -90,7 +76,7 @@ func TestHiCacheInjectsOnlyRequestedFlags(t *testing.T) { } beforeNonArgs := pod.DeepCopy() - if err := NewHiCacheAdapter().InjectEngineConfig(pod, "", cache); err != nil { + if err := NewSGLangHiCacheAdapter().InjectEngineConfig(pod, nil, cache); err != nil { t.Fatalf("InjectEngineConfig: %v", err) } args := pod.Containers[0].Args @@ -120,7 +106,7 @@ func TestHiCacheInjectsOnlyRequestedFlags(t *testing.T) { func TestHiCacheOptionalFieldsStayOmitted(t *testing.T) { cache := newHiCacheBackend(&cachev1alpha1.SGLangHiCacheSpec{Ratio: "1.5"}) pod := &corev1.PodSpec{Containers: []corev1.Container{{Name: "only"}}} - if err := NewHiCacheAdapter().InjectEngineConfig(pod, "", cache); err != nil { + if err := NewSGLangHiCacheAdapter().InjectEngineConfig(pod, nil, cache); err != nil { t.Fatalf("InjectEngineConfig: %v", err) } if got, ok := testArgValue(pod.Containers[0].Args, SGLangHiCacheRatioArg); !ok || got != "1.5" { @@ -150,10 +136,10 @@ func TestHiCacheMatchingArgsArePreserved(t *testing.T) { SGLangHiCacheMemoryLayoutArg, "page_first", } pod := &corev1.PodSpec{Containers: []corev1.Container{{ - Name: enginewire.SGLangEngineContainerName, + Name: SGLangEngineContainerName, Args: append([]string(nil), originalArgs...), }}} - if err := NewHiCacheAdapter().InjectEngineConfig(pod, "", cache); err != nil { + if err := NewSGLangHiCacheAdapter().InjectEngineConfig(pod, nil, cache); err != nil { t.Fatalf("InjectEngineConfig: %v", err) } if !reflect.DeepEqual(pod.Containers[0].Args, originalArgs) { @@ -178,21 +164,21 @@ func TestHiCacheConflictsFailAtomically(t *testing.T) { {"different optional value", []string{SGLangHiCacheWritePolicyArg, "write_back"}}, {"enable carries value", []string{SGLangEnableHiCacheArg + "=true"}}, {"duplicate enable", []string{SGLangEnableHiCacheArg, SGLangEnableHiCacheArg}}, - {"LMCache enabled", []string{enginewire.SGLangEnableLMCacheArg}}, - {"LMCache config", []string{enginewire.SGLangConfigFileArg, "/tmp/lmcache.yaml"}}, + {"LMCache enabled", []string{SGLangEnableLMCacheArg}}, + {"LMCache config", []string{SGLangConfigFileArg, "/tmp/lmcache.yaml"}}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { pod := &corev1.PodSpec{ Containers: []corev1.Container{{ - Name: enginewire.SGLangEngineContainerName, + Name: SGLangEngineContainerName, Args: append([]string(nil), tc.args...), Env: []corev1.EnvVar{{Name: "KEEP", Value: "yes"}}, }}, Volumes: []corev1.Volume{{Name: "keep"}}, } before := pod.DeepCopy() - if err := NewHiCacheAdapter().InjectEngineConfig(pod, "", base); err == nil { + if err := NewSGLangHiCacheAdapter().InjectEngineConfig(pod, nil, base); err == nil { t.Fatal("InjectEngineConfig returned no error") } if !reflect.DeepEqual(pod, before) { @@ -218,12 +204,12 @@ func TestHiCacheOmittedOptionalArgsFailAtomicallyWhenMalformedOrDuplicated(t *te for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { pod := &corev1.PodSpec{Containers: []corev1.Container{{ - Name: enginewire.SGLangEngineContainerName, + Name: SGLangEngineContainerName, Args: append([]string(nil), tc.args...), Env: []corev1.EnvVar{{Name: "KEEP", Value: "yes"}}, }}} before := pod.DeepCopy() - if err := NewHiCacheAdapter().InjectEngineConfig(pod, "", cache); err == nil { + if err := NewSGLangHiCacheAdapter().InjectEngineConfig(pod, nil, cache); err == nil { t.Fatal("InjectEngineConfig returned no error") } if !reflect.DeepEqual(pod, before) { @@ -249,7 +235,7 @@ func TestHiCacheRejectsInvalidBackendAtAdapterBoundary(t *testing.T) { cache.Spec.HiCache.SizeGB = &zero }}, {"invalid ratio", func(cache *cachev1alpha1.CacheBackend) { cache.Spec.HiCache.Ratio = "NaN" }}, - {"wrong engine", func(cache *cachev1alpha1.CacheBackend) { cache.Spec.Integration.Engine = "vllm" }}, + {"wrong engine", func(cache *cachev1alpha1.CacheBackend) { cache.Spec.Runtime = cachev1alpha1.CacheBackendRuntimeVLLM }}, {"events only", func(cache *cachev1alpha1.CacheBackend) { cache.Spec.Integration.Mode = cachev1alpha1.CacheBackendIntegrationModeEventsOnly }}, @@ -262,15 +248,9 @@ func TestHiCacheRejectsInvalidBackendAtAdapterBoundary(t *testing.T) { {"autoscaling", func(cache *cachev1alpha1.CacheBackend) { cache.Spec.Autoscaling = &cachev1alpha1.CacheBackendAutoscalingSpec{MaxReplicas: 2} }}, - {"endpoint", func(cache *cachev1alpha1.CacheBackend) { - cache.Spec.Endpoint = "cache.example.com:8200" - }}, {"missing selector", func(cache *cachev1alpha1.CacheBackend) { cache.Spec.EngineSelector = nil }}, - {"unknown backendConfig", func(cache *cachev1alpha1.CacheBackend) { - cache.Spec.BackendConfig = map[string]string{"l1SizeGB": "8"} - }}, {"reserved arg override", func(cache *cachev1alpha1.CacheBackend) { cache.Spec.Integration.EngineOverrides = &cachev1alpha1.EngineInjectionOverrides{ Args: []string{SGLangHiCacheRatioArg + "=3"}, @@ -290,17 +270,12 @@ func TestHiCacheRejectsInvalidBackendAtAdapterBoundary(t *testing.T) { cache := newHiCacheBackend(&cachev1alpha1.SGLangHiCacheSpec{Ratio: "2"}) tc.mutate(cache) pod := &corev1.PodSpec{Containers: []corev1.Container{{Name: "sglang"}}} - if err := NewHiCacheAdapter().InjectEngineConfig(pod, "", cache); err == nil { + if err := NewSGLangHiCacheAdapter().InjectEngineConfig(pod, nil, cache); err == nil { t.Fatal("InjectEngineConfig returned no error") } if len(pod.Containers[0].Args) != 0 { t.Fatalf("invalid config partially injected args: %v", pod.Containers[0].Args) } - if renderedPod, renderedService, err := runtimeadapter.ResolveLegacyCacheServer(NewHiCacheAdapter(), cache); err == nil || - renderedPod != nil || renderedService != nil { - t.Fatalf("ResolveCacheServer = (%v, %v, %v), want invalid config rejected", - renderedPod, renderedService, err) - } }) } } @@ -311,14 +286,14 @@ func TestHiCacheMultiContainerRequiresSGLangName(t *testing.T) { {Name: "engine"}, {Name: "metrics"}, }} - if err := NewHiCacheAdapter().InjectEngineConfig(pod, "", cache); err == nil || + if err := NewSGLangHiCacheAdapter().InjectEngineConfig(pod, nil, cache); err == nil || !strings.Contains(err.Error(), `none is named "sglang"`) { t.Fatalf("InjectEngineConfig error = %v, want missing sglang container", err) } } func TestHiCacheReservedArgs(t *testing.T) { - got := NewHiCacheAdapter().ReservedArgs() + got := NewSGLangHiCacheAdapter().ReservedArgs() want := []string{ SGLangEnableHiCacheArg, SGLangHiCacheSizeArg, @@ -330,15 +305,15 @@ func TestHiCacheReservedArgs(t *testing.T) { if !reflect.DeepEqual(got, want) { t.Fatalf("ReservedArgs = %v, want %v", got, want) } - if got := NewHiCacheAdapter().ReservedEnv(); len(got) != 0 { + if got := NewSGLangHiCacheAdapter().ReservedEnv(); len(got) != 0 { t.Fatalf("ReservedEnv = %v, want empty", got) } } func TestHiCacheObservationSidecarReusesSGLangRenderer(t *testing.T) { cache := newHiCacheBackend(&cachev1alpha1.SGLangHiCacheSpec{Ratio: "2"}) - cache.Spec.BackendConfig = map[string]string{"model": "model-a"} - adapter := NewHiCacheAdapter( + cache.Spec.Observation = &cachev1alpha1.CacheBackendObservationSpec{ModelID: "model-a"} + adapter := NewSGLangHiCacheAdapter( runtimeadapter.WithSubscriberImage("subscriber:test"), runtimeadapter.WithPolicyServerGRPCAddress("policy:50051"), ) diff --git a/pkg/adapters/runtime/sglang/sglang.go b/internal/adapters/builtin/runtime/sglang_lmcache.go similarity index 68% rename from pkg/adapters/runtime/sglang/sglang.go rename to internal/adapters/builtin/runtime/sglang_lmcache.go index cbfabba7..b6ad3ad3 100644 --- a/pkg/adapters/runtime/sglang/sglang.go +++ b/internal/adapters/builtin/runtime/sglang_lmcache.go @@ -1,4 +1,4 @@ -package sglang +package runtime import ( "fmt" @@ -7,9 +7,7 @@ import ( cachev1alpha1 "github.com/cachebox-project/inference-cache/api/v1alpha1" backendadapter "github.com/cachebox-project/inference-cache/pkg/adapters/backend" - provideradapter "github.com/cachebox-project/inference-cache/pkg/adapters/backend/provider" runtimeadapter "github.com/cachebox-project/inference-cache/pkg/adapters/runtime" - "github.com/cachebox-project/inference-cache/pkg/adapters/runtime/internal/enginewire" ) const ( @@ -24,31 +22,31 @@ const ( // subscriber — the same scheme-independent algorithm for both engines, NOT // the engine's native block hash), so the disjointness guarantee rides // entirely on this tag, not on vLLM's and SGLang's native hashes differing. - subscriberHashScheme = "sglang" + sglangSubscriberHashScheme = "sglang" // defaultEngineZMQPortStr is the port SGLang's KV-event ZMQ PUB endpoint // binds by default (SGLang's KVEventsConfig defaults to tcp://*:5557, the // same port vLLM uses). The operator enables the publisher with // --kv-events-config on the engine; the subscriber sidecar dials it over // 127.0.0.1 since it shares the engine pod's network namespace. - defaultEngineZMQPortStr = "5557" + sglangDefaultEngineZMQPortStr = "5557" ) -// adapter wires SGLang engine pods to LMCache for the (SGLang, LMCache) +// sglangLMCacheAdapter wires SGLang engine pods to LMCache for the (SGLang, LMCache) // pair. SGLang drives LMCache in MULTIPROCESS (MP) mode: // -// - InjectEngineConfigWithBinding renders a node-local MP-worker +// - InjectEngineConfig renders a node-local MP-worker // native sidecar + a config-file (mp_host/mp_port) the engine reads via // --lmcache-config-file. A nil binding is host-only; an optional RESP // binding offloads to independently selected Redis storage. // - It turns LMCache on with // --enable-lmcache + LMCACHE_USE_EXPERIMENTAL (not vLLM's --kv-transfer-config) // and does NOT inject the lm:// LMCACHE_REMOTE_URL env, which MP mode ignores. -// See enginewire.InjectSGLangLMCache. +// See InjectSGLangLMCache. // // GPU-validated end-to-end; full design: docs/design/sglang-lmcache-mp-mode.md. The // kvevent-subscriber sidecar rendering is still shared engine-agnostically. -type adapter struct { +type sglangLMCacheAdapter struct { // subscriberImage is the image the kvevent-subscriber sidecar runs. // Empty (the default) disables sidecar auto-attach — ObservationSidecar // returns nil — so an unconfigured controller install doesn't push engine @@ -60,22 +58,16 @@ type adapter struct { policyServerGRPCAddress string } -// NewAdapter returns the runtime adapter for the (sglang, LMCache) pair. The +// NewSGLangLMCacheAdapter returns the runtime adapter for the (sglang, LMCache) pair. The // optional [runtimeadapter.Option] helpers let the controller pin the // subscriber sidecar's image + policy-server target — the same options -// the built-in composition passes to [runtimeadapter.NewCoreRegistry], so -// the SGLang subscriber sidecar auto-attaches with identical operator wiring. -// -// The internal/adapters/builtin composition wires it into the shared -// [runtimeadapter.Registry] alongside the core and External adapters. This -// package imports its parent pkg/adapters/runtime, so it cannot be registered -// by [runtimeadapter.NewCoreRegistry] without an import cycle. -func NewAdapter(opts ...runtimeadapter.Option) runtimeadapter.KVCacheRuntimeAdapter { +// the built-in composition applies uniformly to every shipping adapter. +func NewSGLangLMCacheAdapter(opts ...runtimeadapter.Option) runtimeadapter.KVCacheRuntimeAdapter { var cfg runtimeadapter.Options for _, o := range opts { o(&cfg) } - return adapter{ + return sglangLMCacheAdapter{ subscriberImage: cfg.SubscriberImage, policyServerGRPCAddress: cfg.PolicyServerGRPCAddress, } @@ -83,49 +75,33 @@ func NewAdapter(opts ...runtimeadapter.Option) runtimeadapter.KVCacheRuntimeAdap // Supports matches SGLang engines against an LMCache CacheBackend. Every other // (runtime, backend) combination is left for another adapter — vLLM+LMCache, -// the External passthrough, or a future SGLang+Mooncake adapter — and an +// an externally owned remote binding, or a future SGLang+Mooncake binding — and an // unsupported pair surfaces as ErrNoAdapter at admission. -func (adapter) Supports(runtime runtimeadapter.RuntimeID, cache *cachev1alpha1.CacheBackend) bool { +func (sglangLMCacheAdapter) Supports(runtime runtimeadapter.RuntimeID, cache *cachev1alpha1.CacheBackend) bool { if cache == nil { return false } return runtime == runtimeadapter.RuntimeSGLang && - cache.Spec.EffectiveCacheType() == cachev1alpha1.CacheBackendTypeLMCache && - (cache.Spec.UsesCanonicalCacheHierarchy() || cache.Spec.Type == cachev1alpha1.CacheBackendTypeLMCache) + cache.Spec.EffectiveCacheType() == cachev1alpha1.CacheBackendTypeLMCache } // SupportedPairs lets the registry surface this adapter's canonical pair in the // "no adapter supports the (engine, backend) pair" admission error so an // operator who mistypes the engine or backend sees sglang/LMCache as a // candidate. -func (adapter) SupportedPairs() []runtimeadapter.SupportedPair { +func (sglangLMCacheAdapter) SupportedPairs() []runtimeadapter.SupportedPair { return []runtimeadapter.SupportedPair{ {Runtime: runtimeadapter.RuntimeSGLang, Backend: cachev1alpha1.CacheBackendTypeLMCache}, } } -// ResolveCacheServer is the pre-separation compatibility renderer. Production -// provider lifecycle resolves through pkg/adapters/backend/provider instead. -func (adapter) ResolveCacheServer(cache *cachev1alpha1.CacheBackend) (*corev1.PodSpec, *corev1.Service, error) { - return provideradapter.ResolveRedisL2Server(cache) -} - -// InjectEngineConfig renders SGLang's LMCache MP-mode launch surface, merging with -// the pod template's existing args/env: it adds the node-local MP-worker native -// sidecar + shared /dev/shm/config volumes, and turns the connector on via -// --enable-lmcache + --lmcache-config-file + LMCACHE_USE_EXPERIMENTAL (no -// VLLM_USE_V1 / PYTHONHASHSEED, and no lm:// LMCACHE_REMOTE_URL — MP mode ignores -// it). endpoint is the managed Redis L2 address the worker offloads to. See -// enginewire.InjectSGLangLMCache for the full wire. -func (adapter) InjectEngineConfig(pod *corev1.PodSpec, endpoint string, cache *cachev1alpha1.CacheBackend) error { - return enginewire.InjectSGLangLMCache(pod, endpoint, cache) -} - -func (adapter) SupportsRemoteBinding(binding *backendadapter.Binding) bool { +func (sglangLMCacheAdapter) SupportsBinding(binding *backendadapter.Binding) bool { return binding == nil || binding.Protocol == backendadapter.ProtocolRESP } -func (adapter) InjectEngineConfigWithBinding(pod *corev1.PodSpec, binding *backendadapter.Binding, cache *cachev1alpha1.CacheBackend) error { +// InjectEngineConfig renders SGLang's LMCache MP-mode launch surface from a +// host-only nil binding or a RESP binding for Redis L2 storage. +func (sglangLMCacheAdapter) InjectEngineConfig(pod *corev1.PodSpec, binding *backendadapter.Binding, cache *cachev1alpha1.CacheBackend) error { endpoint := "" if binding != nil { if binding.Protocol != backendadapter.ProtocolRESP { @@ -133,7 +109,7 @@ func (adapter) InjectEngineConfigWithBinding(pod *corev1.PodSpec, binding *backe } endpoint = binding.Endpoint } - return enginewire.InjectSGLangLMCache(pod, endpoint, cache) + return InjectSGLangLMCache(pod, endpoint, cache) } // InjectRouterConfig is a no-op for LMCache: the topology has no router @@ -142,9 +118,9 @@ func (adapter) InjectEngineConfigWithBinding(pod *corev1.PodSpec, binding *backe // branching on backend type — per // [runtimeadapter.KVCacheRuntimeAdapter.InjectRouterConfig]: "backends without // a router component should return nil without touching pod." -func (adapter) InjectRouterConfig(pod *corev1.PodSpec, endpoint string, cache *cachev1alpha1.CacheBackend) error { +func (sglangLMCacheAdapter) InjectRouterConfig(pod *corev1.PodSpec, binding *backendadapter.Binding, cache *cachev1alpha1.CacheBackend) error { _ = pod - _ = endpoint + _ = binding _ = cache return nil } @@ -159,14 +135,14 @@ func (adapter) InjectRouterConfig(pod *corev1.PodSpec, endpoint string, cache *c // evicted blocks) and forwarded in EventsOnly (no L2). The shipped subscriber // binary decodes SGLang's KV-event stream unchanged because SGLang emits the // same msgspec BlockStored/BlockRemoved/AllBlocksCleared wire vLLM does. -func (a adapter) ObservationSidecar(cache *cachev1alpha1.CacheBackend, pod *corev1.Pod) (*corev1.Container, error) { +func (a sglangLMCacheAdapter) ObservationSidecar(cache *cachev1alpha1.CacheBackend, pod *corev1.Pod) (*corev1.Container, error) { return runtimeadapter.RenderSubscriberSidecar(runtimeadapter.SubscriberSidecarParams{ Image: a.subscriberImage, ServerAddr: a.policyServerGRPCAddress, Cache: cache, Pod: pod, - HashScheme: subscriberHashScheme, - EngineZMQPortStr: defaultEngineZMQPortStr, + HashScheme: sglangSubscriberHashScheme, + EngineZMQPortStr: sglangDefaultEngineZMQPortStr, }) } @@ -183,8 +159,8 @@ func (a adapter) ObservationSidecar(cache *cachev1alpha1.CacheBackend, pod *core // // (Distinct from the vLLM adapter, which reserves --kv-transfer-config — the // two engines turn LMCache on through different launch surfaces.) -func (adapter) ReservedArgs() []string { - return []string{enginewire.SGLangEnableLMCacheArg, enginewire.SGLangConfigFileArg} +func (sglangLMCacheAdapter) ReservedArgs() []string { + return []string{SGLangEnableLMCacheArg, SGLangConfigFileArg} } // ReservedEnv returns the env var names this adapter injects and blocks @@ -200,10 +176,10 @@ func (adapter) ReservedArgs() []string { // Unlike the vLLM adapter, VLLM_USE_V1 and PYTHONHASHSEED are NOT reserved — they // are not injected for SGLang at all (no vLLM v1 codepath; SGLang's sha256-based // prefix hashing does not depend on PYTHONHASHSEED). -func (adapter) ReservedEnv() []string { +func (sglangLMCacheAdapter) ReservedEnv() []string { return []string{ - enginewire.EnvLMCacheUseExperimental, - enginewire.EnvInferenceCacheFailOpen, + EnvLMCacheUseExperimental, + EnvInferenceCacheFailOpen, } } @@ -211,7 +187,7 @@ func (adapter) ReservedEnv() []string { // the adapter mutates. The pod webhook uses this to scope engineOverrides edits // to the same container InjectEngineConfig writes to — overrides land on the // engine, not on user-attached sidecars. -func (adapter) EngineContainerName() string { return enginewire.SGLangEngineContainerName } +func (sglangLMCacheAdapter) EngineContainerName() string { return SGLangEngineContainerName } // Compile-time assertion: the adapter implements the full C5 interface. -var _ runtimeadapter.KVCacheRuntimeAdapter = adapter{} +var _ runtimeadapter.KVCacheRuntimeAdapter = sglangLMCacheAdapter{} diff --git a/pkg/adapters/runtime/sglang/sglang_test.go b/internal/adapters/builtin/runtime/sglang_lmcache_test.go similarity index 81% rename from pkg/adapters/runtime/sglang/sglang_test.go rename to internal/adapters/builtin/runtime/sglang_lmcache_test.go index 38f3c527..d083bc3a 100644 --- a/pkg/adapters/runtime/sglang/sglang_test.go +++ b/internal/adapters/builtin/runtime/sglang_lmcache_test.go @@ -1,9 +1,10 @@ -package sglang +package runtime import ( "flag" "io" "reflect" + "strconv" "strings" "testing" @@ -12,23 +13,52 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" cachev1alpha1 "github.com/cachebox-project/inference-cache/api/v1alpha1" + provideradapter "github.com/cachebox-project/inference-cache/internal/adapters/builtin/storage" backendadapter "github.com/cachebox-project/inference-cache/pkg/adapters/backend" runtimeadapter "github.com/cachebox-project/inference-cache/pkg/adapters/runtime" - "github.com/cachebox-project/inference-cache/pkg/adapters/runtime/internal/enginewire" ) func newSGLangBackend(cfg map[string]string) *cachev1alpha1.CacheBackend { cb := &cachev1alpha1.CacheBackend{ ObjectMeta: metav1.ObjectMeta{Name: "cache", Namespace: "ns1"}, Spec: cachev1alpha1.CacheBackendSpec{ - Type: cachev1alpha1.CacheBackendTypeLMCache, - Integration: &cachev1alpha1.CacheBackendIntegrationSpec{Engine: "sglang"}, - BackendConfig: cfg, + Runtime: cachev1alpha1.CacheBackendRuntimeSGLang, + Type: cachev1alpha1.CacheBackendTypeLMCache, + LMCache: &cachev1alpha1.LMCacheEngineSpec{}, + Integration: &cachev1alpha1.CacheBackendIntegrationSpec{}, + RemoteStorage: &cachev1alpha1.CacheBackendRemoteStorageSpec{ + Provider: cachev1alpha1.CacheBackendRemoteStorageProviderRedis, + Ownership: cachev1alpha1.CacheBackendRemoteStorageOwnershipManaged, + Redis: &cachev1alpha1.RedisRemoteStorageSpec{Image: cfg["redisImage"]}, + }, }, } + if value := cfg["chunkSize"]; value != "" { + parsed, _ := strconv.ParseInt(value, 10, 32) + chunkSize := int32(parsed) + cb.Spec.LMCache.ChunkSizeTokens = &chunkSize + } + cb.Spec.LMCache.WorkerImage = cfg["workerImage"] + if value := cfg["mpPort"]; value != "" { + parsed, _ := strconv.ParseInt(value, 10, 32) + port := int32(parsed) + cb.Spec.LMCache.WorkerPort = &port + } + if value := cfg["l1SizeGB"]; value != "" { + if capacity, err := resource.ParseQuantity(value + "Gi"); err == nil { + cb.Spec.LMCache.HostMemory = &cachev1alpha1.CacheBackendHostMemorySpec{Capacity: &capacity} + } + } + if value := cfg["model"]; value != "" { + cb.Spec.Observation = &cachev1alpha1.CacheBackendObservationSpec{ModelID: value} + } return cb } +func respBinding(endpoint string) *backendadapter.Binding { + return &backendadapter.Binding{Protocol: backendadapter.ProtocolRESP, Endpoint: endpoint} +} + func findInitContainer(cs []corev1.Container, name string) *corev1.Container { for i := range cs { if cs[i].Name == name { @@ -56,8 +86,12 @@ func hasMount(ms []corev1.VolumeMount, name string) bool { return false } +func resolveRedisServer(_ runtimeadapter.KVCacheRuntimeAdapter, cb *cachev1alpha1.CacheBackend) (*corev1.PodSpec, *corev1.Service, error) { + return provideradapter.ResolveRedisL2Server(cb) +} + func TestSGLangSupports(t *testing.T) { - a := NewAdapter() + a := NewSGLangLMCacheAdapter() cases := []struct { name string runtime runtimeadapter.RuntimeID @@ -66,8 +100,7 @@ func TestSGLangSupports(t *testing.T) { }{ {"sglang+lmcache", runtimeadapter.RuntimeSGLang, newSGLangBackend(nil), true}, {"vllm+lmcache", runtimeadapter.RuntimeVLLM, newSGLangBackend(nil), false}, - {"sglang+external", runtimeadapter.RuntimeSGLang, &cachev1alpha1.CacheBackend{Spec: cachev1alpha1.CacheBackendSpec{Type: cachev1alpha1.CacheBackendTypeExternal}}, false}, - {"sglang+mooncake", runtimeadapter.RuntimeSGLang, &cachev1alpha1.CacheBackend{Spec: cachev1alpha1.CacheBackendSpec{Type: cachev1alpha1.CacheBackendTypeMooncake}}, false}, + {"sglang+unsupported", runtimeadapter.RuntimeSGLang, &cachev1alpha1.CacheBackend{Spec: cachev1alpha1.CacheBackendSpec{Type: cachev1alpha1.CacheBackendType("unsupported")}}, false}, {"nil cache", runtimeadapter.RuntimeSGLang, nil, false}, } for _, tc := range cases { @@ -80,7 +113,7 @@ func TestSGLangSupports(t *testing.T) { } func TestSGLangSupportedPairs(t *testing.T) { - a := NewAdapter().(interface { + a := NewSGLangLMCacheAdapter().(interface { SupportedPairs() []runtimeadapter.SupportedPair }) got := a.SupportedPairs() @@ -91,8 +124,8 @@ func TestSGLangSupportedPairs(t *testing.T) { } func TestSGLangResolveCacheServer(t *testing.T) { - a := NewAdapter() - pod, svc, err := runtimeadapter.ResolveLegacyCacheServer(a, newSGLangBackend(nil)) + a := NewSGLangLMCacheAdapter() + pod, svc, err := resolveRedisServer(a, newSGLangBackend(nil)) if err != nil { t.Fatalf("ResolveCacheServer: %v", err) } @@ -112,9 +145,9 @@ func TestSGLangResolveCacheServer(t *testing.T) { } func TestSGLangResolveCacheServerImageOverride(t *testing.T) { - a := NewAdapter() + a := NewSGLangLMCacheAdapter() cb := newSGLangBackend(map[string]string{"redisImage": "registry.example.com/redis:pinned"}) - pod, _, err := runtimeadapter.ResolveLegacyCacheServer(a, cb) + pod, _, err := resolveRedisServer(a, cb) if err != nil { t.Fatalf("ResolveCacheServer: %v", err) } @@ -124,7 +157,7 @@ func TestSGLangResolveCacheServerImageOverride(t *testing.T) { } func TestSGLangResolveCacheServerNilCache(t *testing.T) { - if _, _, err := runtimeadapter.ResolveLegacyCacheServer(NewAdapter(), nil); err == nil { + if _, _, err := resolveRedisServer(NewSGLangLMCacheAdapter(), nil); err == nil { t.Fatalf("ResolveCacheServer(nil) returned no error") } } @@ -136,11 +169,6 @@ func TestSGLangCanonicalHostOnlyBindingDoesNotSelectRedis(t *testing.T) { Spec: cachev1alpha1.CacheBackendSpec{ Runtime: cachev1alpha1.CacheBackendRuntimeSGLang, Type: cachev1alpha1.CacheBackendTypeLMCache, - BackendConfig: map[string]string{ - "chunkSize": "999", - "l1SizeGB": "99", - "workerImage": "legacy.example/worker:wrong", - }, LMCache: &cachev1alpha1.LMCacheEngineSpec{ ChunkSizeTokens: &chunkSize, HostMemory: &cachev1alpha1.CacheBackendHostMemorySpec{ @@ -150,12 +178,12 @@ func TestSGLangCanonicalHostOnlyBindingDoesNotSelectRedis(t *testing.T) { }, } pod := &corev1.PodSpec{Containers: []corev1.Container{{Name: "sglang", Image: "sglang:test"}}} - adapter := NewAdapter().(runtimeadapter.RemoteBindingAdapter) - if !adapter.SupportsRemoteBinding(nil) { + adapter := NewSGLangLMCacheAdapter() + if !adapter.SupportsBinding(nil) { t.Fatal("SGLang LMCache adapter rejected host-only binding") } - if err := adapter.InjectEngineConfigWithBinding(pod, (*backendadapter.Binding)(nil), cache); err != nil { - t.Fatalf("InjectEngineConfigWithBinding: %v", err) + if err := adapter.InjectEngineConfig(pod, nil, cache); err != nil { + t.Fatalf("InjectEngineConfig: %v", err) } worker := findInitContainer(pod.InitContainers, "lmcache-mp-worker") if worker == nil { @@ -168,18 +196,15 @@ func TestSGLangCanonicalHostOnlyBindingDoesNotSelectRedis(t *testing.T) { if !strings.Contains(script, "--chunk-size 128") || !strings.Contains(script, "--l1-size-gb 6") { t.Fatalf("worker command did not consume typed LMCache config: %q", script) } - if worker.Image == "legacy.example/worker:wrong" { - t.Fatal("canonical engine config inherited legacy backendConfig.workerImage") - } } func TestSGLangInjectEngineConfig(t *testing.T) { - a := NewAdapter() + a := NewSGLangLMCacheAdapter() cb := newSGLangBackend(nil) pod := &corev1.PodSpec{ Containers: []corev1.Container{ { - Name: enginewire.SGLangEngineContainerName, + Name: SGLangEngineContainerName, Image: "sglang:test", Args: []string{"--page-size", "64"}, Env: []corev1.EnvVar{{Name: "HF_TOKEN", Value: "secret-token"}}, @@ -191,35 +216,35 @@ func TestSGLangInjectEngineConfig(t *testing.T) { }, } - if err := a.InjectEngineConfig(pod, "cache.ns1.svc.cluster.local:6379", cb); err != nil { + if err := a.InjectEngineConfig(pod, respBinding("cache.ns1.svc.cluster.local:6379"), cb); err != nil { t.Fatalf("InjectEngineConfig: %v", err) } engine := pod.Containers[0] // MP-mode engine wire: connector on + config-file, and the lm:// env is GONE. - if !containsArg(engine.Args, enginewire.SGLangEnableLMCacheArg) { - t.Fatalf("engine args missing %s: %v", enginewire.SGLangEnableLMCacheArg, engine.Args) + if !containsArg(engine.Args, SGLangEnableLMCacheArg) { + t.Fatalf("engine args missing %s: %v", SGLangEnableLMCacheArg, engine.Args) } - if !containsArg(engine.Args, enginewire.SGLangConfigFileArg) { - t.Fatalf("engine args missing %s: %v", enginewire.SGLangConfigFileArg, engine.Args) + if !containsArg(engine.Args, SGLangConfigFileArg) { + t.Fatalf("engine args missing %s: %v", SGLangConfigFileArg, engine.Args) } - if v, ok := lookupEnv(engine.Env, enginewire.EnvLMCacheUseExperimental); !ok || v != "True" { - t.Fatalf("%s = (%q, %v), want True", enginewire.EnvLMCacheUseExperimental, v, ok) + if v, ok := lookupEnv(engine.Env, EnvLMCacheUseExperimental); !ok || v != "True" { + t.Fatalf("%s = (%q, %v), want True", EnvLMCacheUseExperimental, v, ok) } - if v, ok := lookupEnv(engine.Env, enginewire.EnvInferenceCacheFailOpen); !ok || v == "" { - t.Fatalf("%s missing", enginewire.EnvInferenceCacheFailOpen) + if v, ok := lookupEnv(engine.Env, EnvInferenceCacheFailOpen); !ok || v == "" { + t.Fatalf("%s missing", EnvInferenceCacheFailOpen) } // The old lm:// env is NOT injected — SGLang MP mode ignores it. - if _, ok := lookupEnv(engine.Env, enginewire.EnvLMCacheRemoteURL); ok { - t.Fatalf("%s injected — SGLang MP mode must not use the lm:// env", enginewire.EnvLMCacheRemoteURL) + if _, ok := lookupEnv(engine.Env, EnvLMCacheRemoteURL); ok { + t.Fatalf("%s injected — SGLang MP mode must not use the lm:// env", EnvLMCacheRemoteURL) } // vLLM-only env/args stay absent. - if _, ok := lookupEnv(engine.Env, enginewire.EnvVLLMUseV1); ok { - t.Fatalf("%s (vLLM-only) injected for SGLang", enginewire.EnvVLLMUseV1) + if _, ok := lookupEnv(engine.Env, EnvVLLMUseV1); ok { + t.Fatalf("%s (vLLM-only) injected for SGLang", EnvVLLMUseV1) } - if _, ok := lookupEnv(engine.Env, enginewire.EnvPythonHashSeed); ok { - t.Fatalf("%s (vLLM-only) injected for SGLang", enginewire.EnvPythonHashSeed) + if _, ok := lookupEnv(engine.Env, EnvPythonHashSeed); ok { + t.Fatalf("%s (vLLM-only) injected for SGLang", EnvPythonHashSeed) } if containsArg(engine.Args, "--kv-transfer-config") { t.Fatalf("--kv-transfer-config (vLLM-only) injected for SGLang: %v", engine.Args) @@ -274,54 +299,54 @@ func TestSGLangInjectEngineConfig(t *testing.T) { } func TestSGLangInjectEngineConfigSingleContainerPodAcceptsAnyName(t *testing.T) { - a := NewAdapter() + a := NewSGLangLMCacheAdapter() cb := newSGLangBackend(nil) pod := &corev1.PodSpec{Containers: []corev1.Container{{Name: "engine", Image: "img"}}} - if err := a.InjectEngineConfig(pod, "cache.ns1.svc:6379", cb); err != nil { + if err := a.InjectEngineConfig(pod, respBinding("cache.ns1.svc:6379"), cb); err != nil { t.Fatalf("InjectEngineConfig: %v", err) } - if !containsArg(pod.Containers[0].Args, enginewire.SGLangConfigFileArg) { - t.Fatalf("single-container pod missing %s; should have been treated as the engine", enginewire.SGLangConfigFileArg) + if !containsArg(pod.Containers[0].Args, SGLangConfigFileArg) { + t.Fatalf("single-container pod missing %s; should have been treated as the engine", SGLangConfigFileArg) } } func TestSGLangInjectEngineConfigMultiContainerWithoutSGLangNameErrors(t *testing.T) { - a := NewAdapter() + a := NewSGLangLMCacheAdapter() cb := newSGLangBackend(nil) pod := &corev1.PodSpec{Containers: []corev1.Container{ {Name: "engine"}, {Name: "sidecar"}, }} - err := a.InjectEngineConfig(pod, "cache.ns1.svc:65432", cb) + err := a.InjectEngineConfig(pod, respBinding("cache.ns1.svc:65432"), cb) if err == nil { t.Fatalf("expected an error for multi-container pod without an sglang-named container") } for _, c := range pod.Containers { - if _, ok := lookupEnv(c.Env, enginewire.EnvLMCacheRemoteURL); ok { + if _, ok := lookupEnv(c.Env, EnvLMCacheRemoteURL); ok { t.Fatalf("container %q got env injected before the error: %v", c.Name, c.Env) } } } func TestSGLangInjectEngineConfigIdempotent(t *testing.T) { - a := NewAdapter() + a := NewSGLangLMCacheAdapter() cb := newSGLangBackend(nil) - pod := &corev1.PodSpec{Containers: []corev1.Container{{Name: enginewire.SGLangEngineContainerName, Image: "img"}}} - if err := a.InjectEngineConfig(pod, "first.svc:6379", cb); err != nil { + pod := &corev1.PodSpec{Containers: []corev1.Container{{Name: SGLangEngineContainerName, Image: "img"}}} + if err := a.InjectEngineConfig(pod, respBinding("first.svc:6379"), cb); err != nil { t.Fatalf("first InjectEngineConfig: %v", err) } - if err := a.InjectEngineConfig(pod, "second.svc:6379", cb); err != nil { + if err := a.InjectEngineConfig(pod, respBinding("second.svc:6379"), cb); err != nil { t.Fatalf("second InjectEngineConfig: %v", err) } // --enable-lmcache appears exactly once (no duplicate on re-inject). flags := 0 for _, arg := range pod.Containers[0].Args { - if arg == enginewire.SGLangEnableLMCacheArg { + if arg == SGLangEnableLMCacheArg { flags++ } } if flags != 1 { - t.Fatalf("%s count = %d, want 1", enginewire.SGLangEnableLMCacheArg, flags) + t.Fatalf("%s count = %d, want 1", SGLangEnableLMCacheArg, flags) } // Exactly one worker sidecar and two volumes (config + dshm) — re-inject // upserts by name rather than appending duplicates. @@ -345,15 +370,15 @@ func TestSGLangInjectEngineConfigIdempotent(t *testing.T) { } func TestSGLangInjectEngineConfigConfigOverrides(t *testing.T) { - a := NewAdapter() + a := NewSGLangLMCacheAdapter() cb := newSGLangBackend(map[string]string{ "chunkSize": "512", "l1SizeGB": "8", "workerImage": "registry.example/lmcache-worker:pinned", "mpPort": "6000", }) - pod := &corev1.PodSpec{Containers: []corev1.Container{{Name: enginewire.SGLangEngineContainerName, Image: "img"}}} - if err := a.InjectEngineConfig(pod, "x.svc:6379", cb); err != nil { + pod := &corev1.PodSpec{Containers: []corev1.Container{{Name: SGLangEngineContainerName, Image: "img"}}} + if err := a.InjectEngineConfig(pod, respBinding("x.svc:6379"), cb); err != nil { t.Fatalf("InjectEngineConfig: %v", err) } worker := findInitContainer(pod.InitContainers, "lmcache-mp-worker") @@ -369,8 +394,8 @@ func TestSGLangInjectEngineConfigConfigOverrides(t *testing.T) { t.Fatalf("worker args missing %q: %s", want, joined) } } - if !containsArg(pod.Containers[0].Args, enginewire.SGLangConfigFileArg) { - t.Fatalf("engine missing %s", enginewire.SGLangConfigFileArg) + if !containsArg(pod.Containers[0].Args, SGLangConfigFileArg) { + t.Fatalf("engine missing %s", SGLangConfigFileArg) } } @@ -379,10 +404,10 @@ func TestSGLangInjectEngineConfigReusesExistingDevShm(t *testing.T) { // SECOND mount at the same mountPath makes the Pod invalid (the API server // rejects duplicate mountPaths), so injection must REUSE the engine's volume for // the worker rather than adding its own. - a := NewAdapter() + a := NewSGLangLMCacheAdapter() pod := &corev1.PodSpec{ Containers: []corev1.Container{{ - Name: enginewire.SGLangEngineContainerName, + Name: SGLangEngineContainerName, Image: "sglang:test", VolumeMounts: []corev1.VolumeMount{{Name: "dshm", MountPath: "/dev/shm"}}, }}, @@ -391,7 +416,7 @@ func TestSGLangInjectEngineConfigReusesExistingDevShm(t *testing.T) { VolumeSource: corev1.VolumeSource{EmptyDir: &corev1.EmptyDirVolumeSource{Medium: corev1.StorageMediumMemory}}, }}, } - if err := a.InjectEngineConfig(pod, "r.svc:6379", newSGLangBackend(nil)); err != nil { + if err := a.InjectEngineConfig(pod, respBinding("r.svc:6379"), newSGLangBackend(nil)); err != nil { t.Fatalf("InjectEngineConfig: %v", err) } n := 0 @@ -427,11 +452,11 @@ func TestSGLangInjectEngineConfigRejectsConfigPathCollision(t *testing.T) { // (a ConfigMap mount is read-only), so injection must reject with a clear reason // — the webhook turns that into a fail-open admit. pod := &corev1.PodSpec{Containers: []corev1.Container{{ - Name: enginewire.SGLangEngineContainerName, + Name: SGLangEngineContainerName, Image: "sglang:test", VolumeMounts: []corev1.VolumeMount{{Name: "operator-cfg", MountPath: "/etc/lmcache"}}, }}} - err := NewAdapter().InjectEngineConfig(pod, "r.svc:6379", newSGLangBackend(nil)) + err := NewSGLangLMCacheAdapter().InjectEngineConfig(pod, respBinding("r.svc:6379"), newSGLangBackend(nil)) if err == nil { t.Fatalf("want an error when the engine already mounts the adapter-owned config path") } @@ -447,7 +472,7 @@ func TestSGLangInjectEngineConfigRejectsForeignReservedNames(t *testing.T) { // the pod webhook fails open and the pod admits un-wired rather than corrupted. // Silently skipping is not an option for the worker: the engine gets // --lmcache-config-file regardless and would block on a config nothing writes. - engine := corev1.Container{Name: enginewire.SGLangEngineContainerName, Image: "sglang:test"} + engine := corev1.Container{Name: SGLangEngineContainerName, Image: "sglang:test"} cases := []struct { name string pod *corev1.PodSpec @@ -487,7 +512,7 @@ func TestSGLangInjectEngineConfigRejectsForeignReservedNames(t *testing.T) { for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { before := tc.pod.DeepCopy() - err := NewAdapter().InjectEngineConfig(tc.pod, "r.svc:6379", newSGLangBackend(nil)) + err := NewSGLangLMCacheAdapter().InjectEngineConfig(tc.pod, respBinding("r.svc:6379"), newSGLangBackend(nil)) if err == nil { t.Fatalf("want an error when %s", tc.name) } @@ -512,12 +537,12 @@ func TestSGLangInjectEngineConfigReinjectionConvergesOnCurrentRender(t *testing. // status.endpoint here). Value-equality against a fresh render would misread this // as foreign; the second injection must instead converge the worker on the new // endpoint rather than reject it, duplicate it, or leave the stale one. - a := NewAdapter() - pod := &corev1.PodSpec{Containers: []corev1.Container{{Name: enginewire.SGLangEngineContainerName, Image: "img"}}} - if err := a.InjectEngineConfig(pod, "first.svc:6379", newSGLangBackend(nil)); err != nil { + a := NewSGLangLMCacheAdapter() + pod := &corev1.PodSpec{Containers: []corev1.Container{{Name: SGLangEngineContainerName, Image: "img"}}} + if err := a.InjectEngineConfig(pod, respBinding("first.svc:6379"), newSGLangBackend(nil)); err != nil { t.Fatalf("first InjectEngineConfig: %v", err) } - if err := a.InjectEngineConfig(pod, "second.svc:6379", newSGLangBackend(nil)); err != nil { + if err := a.InjectEngineConfig(pod, respBinding("second.svc:6379"), newSGLangBackend(nil)); err != nil { t.Fatalf("second InjectEngineConfig: %v", err) } w := findInitContainer(pod.InitContainers, "lmcache-mp-worker") @@ -539,7 +564,7 @@ func TestSGLangInjectEngineConfigRejectsUnwritableDevShm(t *testing.T) { // fail deep inside LMCache at runtime — reject at admission instead. engine := func(m corev1.VolumeMount) corev1.Container { return corev1.Container{ - Name: enginewire.SGLangEngineContainerName, Image: "sglang:test", + Name: SGLangEngineContainerName, Image: "sglang:test", VolumeMounts: []corev1.VolumeMount{m}, } } @@ -573,7 +598,7 @@ func TestSGLangInjectEngineConfigRejectsUnwritableDevShm(t *testing.T) { } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - err := NewAdapter().InjectEngineConfig(tc.pod, "r.svc:6379", newSGLangBackend(nil)) + err := NewSGLangLMCacheAdapter().InjectEngineConfig(tc.pod, respBinding("r.svc:6379"), newSGLangBackend(nil)) if err == nil { t.Fatalf("want an error when the engine's /dev/shm is not writable scratch (%s)", tc.name) } @@ -591,7 +616,7 @@ func TestSGLangInjectEngineConfigReusesWritableNonEmptyDirDevShm(t *testing.T) { // on the flag's value, not on the source being exotic. pod := &corev1.PodSpec{ Containers: []corev1.Container{{ - Name: enginewire.SGLangEngineContainerName, Image: "sglang:test", + Name: SGLangEngineContainerName, Image: "sglang:test", VolumeMounts: []corev1.VolumeMount{{Name: "nfs-shm", MountPath: "/dev/shm"}}, }}, Volumes: []corev1.Volume{{ @@ -599,7 +624,7 @@ func TestSGLangInjectEngineConfigReusesWritableNonEmptyDirDevShm(t *testing.T) { VolumeSource: corev1.VolumeSource{NFS: &corev1.NFSVolumeSource{Server: "s", Path: "/p", ReadOnly: false}}, }}, } - if err := NewAdapter().InjectEngineConfig(pod, "r.svc:6379", newSGLangBackend(nil)); err != nil { + if err := NewSGLangLMCacheAdapter().InjectEngineConfig(pod, respBinding("r.svc:6379"), newSGLangBackend(nil)); err != nil { t.Fatalf("InjectEngineConfig rejected a writable /dev/shm: %v", err) } w := findInitContainer(pod.InitContainers, "lmcache-mp-worker") @@ -625,8 +650,8 @@ func TestSGLangInjectEngineConfigWorkerSeesTheGPU(t *testing.T) { // documented for operators in docs/design/cachebackend-api.md. This test exists so // the env is not dropped as dead weight — the failure it prevents is a wedged // engine, not a cache miss. - pod := &corev1.PodSpec{Containers: []corev1.Container{{Name: enginewire.SGLangEngineContainerName, Image: "sglang:test"}}} - if err := NewAdapter().InjectEngineConfig(pod, "r.svc:6379", newSGLangBackend(nil)); err != nil { + pod := &corev1.PodSpec{Containers: []corev1.Container{{Name: SGLangEngineContainerName, Image: "sglang:test"}}} + if err := NewSGLangLMCacheAdapter().InjectEngineConfig(pod, respBinding("r.svc:6379"), newSGLangBackend(nil)); err != nil { t.Fatalf("InjectEngineConfig: %v", err) } w := findInitContainer(pod.InitContainers, "lmcache-mp-worker") @@ -649,8 +674,8 @@ func TestSGLangInjectEngineConfigWorkerRestrictedSecurityContext(t *testing.T) { // engine pod into a REJECTED one in a restricted namespace (the inverse of // fail-open). And it must add NO capabilities (an added cap is itself a Restricted // violation; IPC_LOCK is not needed — GPU access is via device files, not caps). - pod := &corev1.PodSpec{Containers: []corev1.Container{{Name: enginewire.SGLangEngineContainerName, Image: "sglang:test"}}} - if err := NewAdapter().InjectEngineConfig(pod, "r.svc:6379", newSGLangBackend(nil)); err != nil { + pod := &corev1.PodSpec{Containers: []corev1.Container{{Name: SGLangEngineContainerName, Image: "sglang:test"}}} + if err := NewSGLangLMCacheAdapter().InjectEngineConfig(pod, respBinding("r.svc:6379"), newSGLangBackend(nil)); err != nil { t.Fatalf("InjectEngineConfig: %v", err) } w := findInitContainer(pod.InitContainers, "lmcache-mp-worker") @@ -691,13 +716,13 @@ func TestSGLangInjectEngineConfigWorkerMirrorsEngineUserIdentity(t *testing.T) { uid := int64(1000) gid := int64(2000) pod := &corev1.PodSpec{Containers: []corev1.Container{{ - Name: enginewire.SGLangEngineContainerName, + Name: SGLangEngineContainerName, Image: "sglang:test", SecurityContext: &corev1.SecurityContext{ RunAsNonRoot: &nonRoot, RunAsUser: &uid, RunAsGroup: &gid, }, }}} - if err := NewAdapter().InjectEngineConfig(pod, "r.svc:6379", newSGLangBackend(nil)); err != nil { + if err := NewSGLangLMCacheAdapter().InjectEngineConfig(pod, respBinding("r.svc:6379"), newSGLangBackend(nil)); err != nil { t.Fatalf("InjectEngineConfig: %v", err) } w := findInitContainer(pod.InitContainers, "lmcache-mp-worker") @@ -713,8 +738,8 @@ func TestSGLangInjectEngineConfigWorkerMirrorsEngineUserIdentity(t *testing.T) { } // And it does NOT force a read-only rootfs or a fixed UID when the engine sets // none — that would risk breaking the vendor image's writes / CUDA-IPC. - pod2 := &corev1.PodSpec{Containers: []corev1.Container{{Name: enginewire.SGLangEngineContainerName, Image: "sglang:test"}}} - _ = NewAdapter().InjectEngineConfig(pod2, "r.svc:6379", newSGLangBackend(nil)) + pod2 := &corev1.PodSpec{Containers: []corev1.Container{{Name: SGLangEngineContainerName, Image: "sglang:test"}}} + _ = NewSGLangLMCacheAdapter().InjectEngineConfig(pod2, respBinding("r.svc:6379"), newSGLangBackend(nil)) w2 := findInitContainer(pod2.InitContainers, "lmcache-mp-worker") if w2.SecurityContext.RunAsUser != nil { t.Errorf("runAsUser forced to %v when engine set none — must inherit from the pod, not override the image", w2.SecurityContext.RunAsUser) @@ -731,7 +756,7 @@ func TestSGLangInjectEngineConfigMirrorsDevShmSubPath(t *testing.T) { // subPath so both resolve to the same place. pod := &corev1.PodSpec{ Containers: []corev1.Container{{ - Name: enginewire.SGLangEngineContainerName, Image: "sglang:test", + Name: SGLangEngineContainerName, Image: "sglang:test", VolumeMounts: []corev1.VolumeMount{{Name: "scratch", MountPath: "/dev/shm", SubPath: "shm"}}, }}, Volumes: []corev1.Volume{{ @@ -739,7 +764,7 @@ func TestSGLangInjectEngineConfigMirrorsDevShmSubPath(t *testing.T) { VolumeSource: corev1.VolumeSource{EmptyDir: &corev1.EmptyDirVolumeSource{Medium: corev1.StorageMediumMemory}}, }}, } - if err := NewAdapter().InjectEngineConfig(pod, "r.svc:6379", newSGLangBackend(nil)); err != nil { + if err := NewSGLangLMCacheAdapter().InjectEngineConfig(pod, respBinding("r.svc:6379"), newSGLangBackend(nil)); err != nil { t.Fatalf("InjectEngineConfig: %v", err) } w := findInitContainer(pod.InitContainers, "lmcache-mp-worker") @@ -765,7 +790,7 @@ func TestSGLangInjectEngineConfigRejectsUnshareableDevShm(t *testing.T) { // own env, and source-level read-only that the mount-level readOnly check misses. engine := func(m corev1.VolumeMount) corev1.Container { return corev1.Container{ - Name: enginewire.SGLangEngineContainerName, Image: "sglang:test", + Name: SGLangEngineContainerName, Image: "sglang:test", VolumeMounts: []corev1.VolumeMount{m}, } } @@ -862,7 +887,7 @@ func TestSGLangInjectEngineConfigRejectsUnshareableDevShm(t *testing.T) { } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - err := NewAdapter().InjectEngineConfig(tc.pod, "r.svc:6379", newSGLangBackend(nil)) + err := NewSGLangLMCacheAdapter().InjectEngineConfig(tc.pod, respBinding("r.svc:6379"), newSGLangBackend(nil)) if err == nil { t.Fatalf("want an error when the engine's /dev/shm is unshareable (%s)", tc.name) } @@ -878,10 +903,10 @@ func TestSGLangInjectEngineConfigWorkerHasMemoryBudget(t *testing.T) { // must carry a matching memory request+limit (l1SizeGB + 1Gi) — otherwise the L1 // is invisible to the scheduler and can overcommit the node. pod := &corev1.PodSpec{Containers: []corev1.Container{{ - Name: enginewire.SGLangEngineContainerName, Image: "sglang:test", + Name: SGLangEngineContainerName, Image: "sglang:test", }}} cb := newSGLangBackend(map[string]string{"l1SizeGB": "8"}) - if err := NewAdapter().InjectEngineConfig(pod, "r.svc:6379", cb); err != nil { + if err := NewSGLangLMCacheAdapter().InjectEngineConfig(pod, respBinding("r.svc:6379"), cb); err != nil { t.Fatalf("InjectEngineConfig: %v", err) } w := findInitContainer(pod.InitContainers, "lmcache-mp-worker") @@ -928,8 +953,8 @@ func TestSGLangInjectEngineConfigSanitizesNumericConfig(t *testing.T) { for _, tc := range cases { t.Run(tc.key+"="+tc.bad, func(t *testing.T) { cb := newSGLangBackend(map[string]string{tc.key: tc.bad}) - pod := &corev1.PodSpec{Containers: []corev1.Container{{Name: enginewire.SGLangEngineContainerName, Image: "img"}}} - if err := NewAdapter().InjectEngineConfig(pod, "r.svc:6379", cb); err != nil { + pod := &corev1.PodSpec{Containers: []corev1.Container{{Name: SGLangEngineContainerName, Image: "img"}}} + if err := NewSGLangLMCacheAdapter().InjectEngineConfig(pod, respBinding("r.svc:6379"), cb); err != nil { t.Fatalf("InjectEngineConfig: %v", err) } joined := strings.Join(findInitContainer(pod.InitContainers, "lmcache-mp-worker").Args, " ") @@ -944,7 +969,7 @@ func TestSGLangInjectEngineConfigSanitizesNumericConfig(t *testing.T) { } func TestSGLangInjectEngineConfigFailOpen(t *testing.T) { - a := NewAdapter() + a := NewSGLangLMCacheAdapter() trueVal, falseVal := true, false cases := []struct { name string @@ -959,36 +984,36 @@ func TestSGLangInjectEngineConfigFailOpen(t *testing.T) { t.Run(tc.name, func(t *testing.T) { cb := newSGLangBackend(nil) cb.Spec.Integration.FailOpen = tc.failOpen - pod := &corev1.PodSpec{Containers: []corev1.Container{{Name: enginewire.SGLangEngineContainerName}}} - if err := a.InjectEngineConfig(pod, "x.svc:65432", cb); err != nil { + pod := &corev1.PodSpec{Containers: []corev1.Container{{Name: SGLangEngineContainerName}}} + if err := a.InjectEngineConfig(pod, respBinding("x.svc:65432"), cb); err != nil { t.Fatalf("InjectEngineConfig: %v", err) } - if v, _ := lookupEnv(pod.Containers[0].Env, enginewire.EnvInferenceCacheFailOpen); v != tc.want { - t.Fatalf("%s = %q, want %q", enginewire.EnvInferenceCacheFailOpen, v, tc.want) + if v, _ := lookupEnv(pod.Containers[0].Env, EnvInferenceCacheFailOpen); v != tc.want { + t.Fatalf("%s = %q, want %q", EnvInferenceCacheFailOpen, v, tc.want) } }) } } func TestSGLangInjectEngineConfigBadInput(t *testing.T) { - a := NewAdapter() + a := NewSGLangLMCacheAdapter() cb := newSGLangBackend(nil) - good := &corev1.PodSpec{Containers: []corev1.Container{{Name: enginewire.SGLangEngineContainerName}}} + good := &corev1.PodSpec{Containers: []corev1.Container{{Name: SGLangEngineContainerName}}} cases := []struct { name string fn func() error }{ - {"nil pod", func() error { return a.InjectEngineConfig(nil, "x.svc:65432", cb) }}, - {"nil cache", func() error { return a.InjectEngineConfig(good, "x.svc:65432", nil) }}, - {"empty endpoint", func() error { return a.InjectEngineConfig(good, "", cb) }}, - {"no containers", func() error { return a.InjectEngineConfig(&corev1.PodSpec{}, "x.svc:65432", cb) }}, + {"nil pod", func() error { return a.InjectEngineConfig(nil, respBinding("x.svc:65432"), cb) }}, + {"nil cache", func() error { return a.InjectEngineConfig(good, respBinding("x.svc:65432"), nil) }}, + {"empty endpoint", func() error { return a.InjectEngineConfig(good, respBinding(""), cb) }}, + {"no containers", func() error { return a.InjectEngineConfig(&corev1.PodSpec{}, respBinding("x.svc:65432"), cb) }}, // The resp --l2-adapter takes an INTEGER port, emitted unquoted into JSON. A // non-numeric or out-of-range port would render invalid JSON, the worker would // fail to parse it and never bind its ZMQ port, and the engine would sit behind // the startup probe forever — reject at admission and let the webhook fail open. - {"non-numeric port", func() error { return a.InjectEngineConfig(good, "r.svc:redis", cb) }}, - {"port out of range", func() error { return a.InjectEngineConfig(good, "r.svc:70000", cb) }}, - {"zero port", func() error { return a.InjectEngineConfig(good, "r.svc:0", cb) }}, + {"non-numeric port", func() error { return a.InjectEngineConfig(good, respBinding("r.svc:redis"), cb) }}, + {"port out of range", func() error { return a.InjectEngineConfig(good, respBinding("r.svc:70000"), cb) }}, + {"zero port", func() error { return a.InjectEngineConfig(good, respBinding("r.svc:0"), cb) }}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { @@ -1000,10 +1025,10 @@ func TestSGLangInjectEngineConfigBadInput(t *testing.T) { } func TestSGLangInjectRouterConfigIsNoop(t *testing.T) { - a := NewAdapter() + a := NewSGLangLMCacheAdapter() cb := newSGLangBackend(nil) pod := &corev1.PodSpec{Containers: []corev1.Container{{Name: "router", Env: []corev1.EnvVar{{Name: "EXISTING", Value: "x"}}}}} - if err := a.InjectRouterConfig(pod, "x.svc:65432", cb); err != nil { + if err := a.InjectRouterConfig(pod, respBinding("x.svc:65432"), cb); err != nil { t.Fatalf("InjectRouterConfig: %v", err) } if len(pod.Containers[0].Env) != 1 || pod.Containers[0].Env[0].Name != "EXISTING" { @@ -1011,13 +1036,13 @@ func TestSGLangInjectRouterConfigIsNoop(t *testing.T) { } // Truly a no-op even on bad input (router-less backend must never force // callers to special-case it). - if err := a.InjectRouterConfig(nil, "x", cb); err != nil { + if err := a.InjectRouterConfig(nil, respBinding("x"), cb); err != nil { t.Fatalf("InjectRouterConfig(nil pod) = %v, want nil", err) } } func TestSGLangObservationSidecarShape(t *testing.T) { - a := NewAdapter(runtimeadapter.WithSubscriberImage(runtimeadapter.DefaultSubscriberImage)) + a := NewSGLangLMCacheAdapter(runtimeadapter.WithSubscriberImage(runtimeadapter.DefaultSubscriberImage)) cb := newSGLangBackend(map[string]string{"model": "Qwen/Qwen2.5-0.5B-Instruct"}) pod := &corev1.Pod{ObjectMeta: metav1.ObjectMeta{Name: "sglang-a", Namespace: "engines"}} @@ -1063,7 +1088,7 @@ func TestSGLangObservationSidecarArgsParseAgainstSubscriberFlagSet(t *testing.T) // startup. Parse the rendered args through a FlagSet mirroring the binary's // event-path flag surface and assert they parse cleanly. Keep in sync with // cmd/kvevent-subscriber/main.go. - a := NewAdapter(runtimeadapter.WithSubscriberImage(runtimeadapter.DefaultSubscriberImage)) + a := NewSGLangLMCacheAdapter(runtimeadapter.WithSubscriberImage(runtimeadapter.DefaultSubscriberImage)) cb := newSGLangBackend(map[string]string{"model": "Qwen/Qwen2.5-0.5B-Instruct"}) pod := &corev1.Pod{ObjectMeta: metav1.ObjectMeta{Name: "sglang-a", Namespace: "engines"}} c, err := a.ObservationSidecar(cb, pod) @@ -1094,7 +1119,7 @@ func TestSGLangObservationSidecarArgsParseAgainstSubscriberFlagSet(t *testing.T) } func TestSGLangObservationSidecarHonoursOptions(t *testing.T) { - a := NewAdapter( + a := NewSGLangLMCacheAdapter( runtimeadapter.WithSubscriberImage("registry.example.com/subscriber:pinned"), runtimeadapter.WithPolicyServerGRPCAddress("ic-server.custom-ns.svc.cluster.local:9090"), ) @@ -1113,7 +1138,7 @@ func TestSGLangObservationSidecarHonoursOptions(t *testing.T) { } func TestSGLangObservationSidecarSkipsWithoutModel(t *testing.T) { - a := NewAdapter(runtimeadapter.WithSubscriberImage(runtimeadapter.DefaultSubscriberImage)) + a := NewSGLangLMCacheAdapter(runtimeadapter.WithSubscriberImage(runtimeadapter.DefaultSubscriberImage)) cb := newSGLangBackend(nil) pod := &corev1.Pod{ObjectMeta: metav1.ObjectMeta{Name: "sglang-a"}} c, err := a.ObservationSidecar(cb, pod) @@ -1121,12 +1146,12 @@ func TestSGLangObservationSidecarSkipsWithoutModel(t *testing.T) { t.Fatalf("ObservationSidecar: %v", err) } if c != nil { - t.Fatalf("expected nil sidecar when backendConfig.model is unset, got %+v", c) + t.Fatalf("expected nil sidecar when observation.modelID is unset, got %+v", c) } } func TestSGLangObservationSidecarSkipsWithoutImage(t *testing.T) { - a := NewAdapter() // no image configured → auto-attach opt-out + a := NewSGLangLMCacheAdapter() // no image configured → auto-attach opt-out cb := newSGLangBackend(map[string]string{"model": "MyOrg/MyModel"}) pod := &corev1.Pod{ObjectMeta: metav1.ObjectMeta{Name: "sglang-a"}} c, err := a.ObservationSidecar(cb, pod) @@ -1139,7 +1164,7 @@ func TestSGLangObservationSidecarSkipsWithoutImage(t *testing.T) { } func TestSGLangObservationSidecarBadInput(t *testing.T) { - a := NewAdapter(runtimeadapter.WithSubscriberImage(runtimeadapter.DefaultSubscriberImage)) + a := NewSGLangLMCacheAdapter(runtimeadapter.WithSubscriberImage(runtimeadapter.DefaultSubscriberImage)) cb := newSGLangBackend(map[string]string{"model": "m"}) cases := []struct { name string @@ -1159,8 +1184,8 @@ func TestSGLangObservationSidecarBadInput(t *testing.T) { } func TestSGLangReservedArgs(t *testing.T) { - got := NewAdapter().ReservedArgs() - want := []string{enginewire.SGLangEnableLMCacheArg, enginewire.SGLangConfigFileArg} + got := NewSGLangLMCacheAdapter().ReservedArgs() + want := []string{SGLangEnableLMCacheArg, SGLangConfigFileArg} if len(got) != len(want) { t.Fatalf("ReservedArgs = %v, want %v", got, want) } @@ -1172,10 +1197,10 @@ func TestSGLangReservedArgs(t *testing.T) { } func TestSGLangReservedEnv(t *testing.T) { - got := NewAdapter().ReservedEnv() + got := NewSGLangLMCacheAdapter().ReservedEnv() want := []string{ - enginewire.EnvLMCacheUseExperimental, - enginewire.EnvInferenceCacheFailOpen, + EnvLMCacheUseExperimental, + EnvInferenceCacheFailOpen, } if len(got) != len(want) { t.Fatalf("ReservedEnv = %v, want %v", got, want) @@ -1189,11 +1214,11 @@ func TestSGLangReservedEnv(t *testing.T) { // injected), the LMCACHE_* tunables stay overridable, and LMCACHE_REMOTE_URL // (the old lm:// wire) is gone in MP mode so it must not be reserved either. forbidden := map[string]bool{ - enginewire.EnvVLLMUseV1: true, - enginewire.EnvPythonHashSeed: true, - enginewire.EnvLMCacheChunkSize: true, - enginewire.EnvLMCacheRemoteSerde: true, - enginewire.EnvLMCacheRemoteURL: true, + EnvVLLMUseV1: true, + EnvPythonHashSeed: true, + EnvLMCacheChunkSize: true, + EnvLMCacheRemoteSerde: true, + EnvLMCacheRemoteURL: true, } for _, name := range got { if forbidden[name] { @@ -1203,8 +1228,8 @@ func TestSGLangReservedEnv(t *testing.T) { } func TestSGLangEngineContainerName(t *testing.T) { - if got := NewAdapter().EngineContainerName(); got != enginewire.SGLangEngineContainerName { - t.Fatalf("EngineContainerName = %q, want %q", got, enginewire.SGLangEngineContainerName) + if got := NewSGLangLMCacheAdapter().EngineContainerName(); got != SGLangEngineContainerName { + t.Fatalf("EngineContainerName = %q, want %q", got, SGLangEngineContainerName) } } diff --git a/pkg/adapters/runtime/internal/enginewire/sglang_mp.go b/internal/adapters/builtin/runtime/sglang_lmcache_wire.go similarity index 98% rename from pkg/adapters/runtime/internal/enginewire/sglang_mp.go rename to internal/adapters/builtin/runtime/sglang_lmcache_wire.go index 3b12764c..b38da24e 100644 --- a/pkg/adapters/runtime/internal/enginewire/sglang_mp.go +++ b/internal/adapters/builtin/runtime/sglang_lmcache_wire.go @@ -1,4 +1,4 @@ -package enginewire +package runtime import ( "fmt" @@ -64,7 +64,8 @@ const ( sglangMaxTCPPort = 65535 // a valid TCP port sglangMaxL1SizeGB = 1024 // 1 TiB — bounded so ParseQuantity always sizes /dev/shm - // BackendConfig override keys. + // Typed LMCache configuration keys used by the renderer. + cfgKeyChunkSize = "chunkSize" cfgKeyWorkerImage = "workerImage" cfgKeyL1SizeGB = "l1SizeGB" cfgKeyMPPort = "mpPort" @@ -100,8 +101,7 @@ func InjectSGLangLMCache(pod *corev1.PodSpec, endpoint string, cache *cachev1alp if err := validateInjectPodCacheInputs(pod, cache, "engine"); err != nil { return err } - if endpoint == "" && - (!cache.Spec.UsesCanonicalCacheHierarchy() || cache.Spec.EffectiveRemoteStorage() != nil) { + if endpoint == "" && cache.Spec.EffectiveRemoteStorage() != nil { return fmt.Errorf("inject engine config: endpoint is empty") } i, err := EngineContainerIndexNamed(pod, SGLangEngineContainerName) @@ -235,7 +235,7 @@ func mountAtPath(ms []corev1.VolumeMount, path string) *corev1.VolumeMount { // and the server listens before the engine starts. The worker image defaults to // the engine image (guaranteeing the same lmcache version — the two speak the MP // wire) and is overridable via lmCache.workerImage (or legacy -// backendConfig.workerImage). +// spec.lmCache.workerImage). func sglangMPWorkerContainer(engineImage string, engineSC *corev1.SecurityContext, cfg map[string]string, chunkSize, mpPort, l1SizeGB, l2Adapter string, shmMount corev1.VolumeMount) corev1.Container { image := ConfigOr(cfg, cfgKeyWorkerImage, engineImage) configPath := sglangConfigMountPath + "/" + sglangConfigFileName @@ -457,12 +457,7 @@ func sglangIntInRangeOr(cfg map[string]string, key, fallback string, max int) st } func effectiveSGLangLMCacheConfig(cache *cachev1alpha1.CacheBackend) map[string]string { - cfg := make(map[string]string, len(cache.Spec.BackendConfig)+4) - if !cache.Spec.UsesCanonicalCacheHierarchy() { - for key, value := range cache.Spec.BackendConfig { - cfg[key] = value - } - } + cfg := make(map[string]string, 4) if cache.Spec.LMCache == nil { return cfg } diff --git a/pkg/adapters/runtime/vllm_lmcache.go b/internal/adapters/builtin/runtime/vllm_lmcache.go similarity index 57% rename from pkg/adapters/runtime/vllm_lmcache.go rename to internal/adapters/builtin/runtime/vllm_lmcache.go index 30be46b9..8b7c4dbb 100644 --- a/pkg/adapters/runtime/vllm_lmcache.go +++ b/internal/adapters/builtin/runtime/vllm_lmcache.go @@ -2,38 +2,12 @@ package runtime import ( "fmt" - "net" - "strconv" - "strings" corev1 "k8s.io/api/core/v1" cachev1alpha1 "github.com/cachebox-project/inference-cache/api/v1alpha1" backendadapter "github.com/cachebox-project/inference-cache/pkg/adapters/backend" - provideradapter "github.com/cachebox-project/inference-cache/pkg/adapters/backend/provider" - "github.com/cachebox-project/inference-cache/pkg/adapters/runtime/internal/enginewire" -) - -// Engine env var names. Re-exported from the internal enginewire package so -// downstream callers (admission validators, integration tests, future -// adapter authors) can assert on the wire contract without importing an -// internal/ path. The constants live in enginewire so adapters that speak -// the same wire (vLLM+LMCache and the External passthrough today) share a -// single source of truth. -const ( - EnvLMCacheRemoteURL = enginewire.EnvLMCacheRemoteURL - EnvLMCacheRemoteSerde = enginewire.EnvLMCacheRemoteSerde - EnvLMCacheChunkSize = enginewire.EnvLMCacheChunkSize - EnvLMCacheLocalCPU = enginewire.EnvLMCacheLocalCPU - EnvLMCacheMaxLocalCPU = enginewire.EnvLMCacheMaxLocalCPU - EnvVLLMUseV1 = enginewire.EnvVLLMUseV1 - EnvInferenceCacheFailOpen = enginewire.EnvInferenceCacheFailOpen - EnvPythonHashSeed = enginewire.EnvPythonHashSeed - // EngineContainerName is the conventional name for the vLLM container in - // an engine pod the adapter mutates. When no container with this name is - // present, a single-container pod is treated as the engine; a multi- - // container pod is rejected. - EngineContainerName = enginewire.EngineContainerName + adapterruntime "github.com/cachebox-project/inference-cache/pkg/adapters/runtime" ) // vLLM-specific kvevent-subscriber wiring. The subscriber image and @@ -44,13 +18,13 @@ const ( // endpoint=tcp://*:5557). Parameterising via the adapter (not hardcoding in // the webhook) lets SGLang or another engine adapter pick a different port // without touching the webhook. - defaultEngineZMQPortStr = "5557" + vllmDefaultEngineZMQPortStr = "5557" // subscriberHashScheme is the canonical hash-scheme tag the vLLM subscriber // carries. Hard-coded for this adapter (vLLM's block-hash scheme is distinct // from SGLang's, and the cache plane keys on the scheme to keep them from // collapsing). - subscriberHashScheme = "vllm" + vllmSubscriberHashScheme = "vllm" ) // vllmLMCacheAdapter wires vLLM engine pods to an LMCache engine cache and an @@ -60,8 +34,8 @@ const ( // ObservationSidecar returns the kvevent-subscriber container the webhook // appends so the engine pod auto-attaches to the policy server. // -// This adapter wires vLLM+LMCache. The vLLM+Mooncake sibling reuses the same -// LMCache connector wire via a mooncakestore:// remote. SGLang+LMCache shares +// This adapter wires vLLM+LMCache, including Mooncake remote bindings via the +// mooncakestore:// protocol. SGLang+LMCache shares // the observation sidecar but uses its own MP engine wire and a Redis provider // binding rather than the standalone lmcache-server. type vllmLMCacheAdapter struct { @@ -77,12 +51,12 @@ type vllmLMCacheAdapter struct { } // NewVLLMLMCacheAdapter returns the adapter that wires vLLM engine pods to an -// LMCache CacheBackend. The optional [Option] helpers let the controller pin +// LMCache CacheBackend. The optional [adapterruntime.Option] helpers let the controller pin // the subscriber sidecar's image + policy-server target; the no-arg form // reproduces the package defaults and keeps tests + the nil-Registry // fallback paths working. -func NewVLLMLMCacheAdapter(opts ...Option) KVCacheRuntimeAdapter { - var cfg Options +func NewVLLMLMCacheAdapter(opts ...adapterruntime.Option) adapterruntime.KVCacheRuntimeAdapter { + var cfg adapterruntime.Options for _, o := range opts { o(&cfg) } @@ -95,20 +69,19 @@ func NewVLLMLMCacheAdapter(opts ...Option) KVCacheRuntimeAdapter { // Supports matches vLLM runtimes against an LMCache CacheBackend. Any other // (runtime, backend) combination is left for another adapter — a future // admission validator surfaces unsupported pairs as ErrNoAdapter. -func (vllmLMCacheAdapter) Supports(runtime RuntimeID, cache *cachev1alpha1.CacheBackend) bool { +func (vllmLMCacheAdapter) Supports(runtime adapterruntime.RuntimeID, cache *cachev1alpha1.CacheBackend) bool { if cache == nil { return false } - return runtime == RuntimeVLLM && - cache.Spec.EffectiveCacheType() == cachev1alpha1.CacheBackendTypeLMCache && - (cache.Spec.UsesCanonicalCacheHierarchy() || cache.Spec.Type == cachev1alpha1.CacheBackendTypeLMCache) + return runtime == adapterruntime.RuntimeVLLM && + cache.Spec.EffectiveCacheType() == cachev1alpha1.CacheBackendTypeLMCache } // SupportedPairs lets the registry expose this adapter's canonical pair to // admission error messages so a user who asked for an unsupported pair can // see what they could have asked for instead. -func (vllmLMCacheAdapter) SupportedPairs() []SupportedPair { - return []SupportedPair{{Runtime: RuntimeVLLM, Backend: cachev1alpha1.CacheBackendTypeLMCache}} +func (vllmLMCacheAdapter) SupportedPairs() []adapterruntime.SupportedPair { + return []adapterruntime.SupportedPair{{Runtime: adapterruntime.RuntimeVLLM, Backend: cachev1alpha1.CacheBackendTypeLMCache}} } // ReservedArgs returns the leading flag tokens this adapter injects and that @@ -120,7 +93,7 @@ func (vllmLMCacheAdapter) SupportedPairs() []SupportedPair { // reads at startup; suppressing it means no LMCache wiring at all. // // Other tunables the operator may legitimately want to change (e.g. perf -// knobs surfaced as backendConfig keys) are deliberately NOT reserved. +// connector-tuning knobs are deliberately NOT reserved. func (vllmLMCacheAdapter) ReservedArgs() []string { return []string{defaultEngineKVTransferConfigArg} } @@ -159,52 +132,30 @@ func (vllmLMCacheAdapter) ReservedEnv() []string { } } -// ResolveCacheServer is the pre-separation compatibility renderer. Production -// provider lifecycle resolves through pkg/adapters/backend/provider. -func (vllmLMCacheAdapter) ResolveCacheServer(cache *cachev1alpha1.CacheBackend) (*corev1.PodSpec, *corev1.Service, error) { - return provideradapter.ResolveLMCacheServer(cache) -} - // InjectEngineConfig adds the LMCache connector arg and LMCACHE_* env to the -// vLLM container in pod, delegating to the shared engine-wire helper. The -// External backend adapter calls the same helper with an operator-supplied -// endpoint, keeping the rendered engine wiring byte-identical regardless of -// who owns the cache lifecycle. +// vLLM container in pod from the structured remote-storage binding. // // spec.integration.role maps onto LMCache's kv_role in the connector // config: ReadOnly → kv_consumer, WriteOnly → kv_producer, ReadWrite // (and unset / unknown) → kv_both. -func (vllmLMCacheAdapter) InjectEngineConfig(pod *corev1.PodSpec, endpoint string, cache *cachev1alpha1.CacheBackend) error { - // Events-only (tier-1 routing) wires NO KV connector: the engine container - // is left unmodified so a hybrid-attention model's KV-cache manager is not - // disabled by a connector it cannot load. The engine's own (operator- - // configured) kv-events publisher is all the observation sidecar needs, and - // nothing dials a cache server, so no endpoint is required either. The - // subscriber is still appended by the webhook via ObservationSidecar. - if cache != nil && cache.Spec.IsEventsOnly() { - return nil - } - return enginewire.InjectVLLMLMCache(pod, endpoint, cache) -} - -func (vllmLMCacheAdapter) SupportsRemoteBinding(binding *backendadapter.Binding) bool { +func (vllmLMCacheAdapter) SupportsBinding(binding *backendadapter.Binding) bool { return binding == nil || binding.Protocol == backendadapter.ProtocolLMCache || binding.Protocol == backendadapter.ProtocolMooncakeStore } -func (vllmLMCacheAdapter) InjectEngineConfigWithBinding(pod *corev1.PodSpec, binding *backendadapter.Binding, cache *cachev1alpha1.CacheBackend) error { +func (vllmLMCacheAdapter) InjectEngineConfig(pod *corev1.PodSpec, binding *backendadapter.Binding, cache *cachev1alpha1.CacheBackend) error { if cache != nil && cache.Spec.IsEventsOnly() { return nil } if binding == nil { - return enginewire.InjectVLLMLMCacheHostOnly(pod, cache) + return InjectVLLMLMCacheHostOnly(pod, cache) } switch binding.Protocol { case backendadapter.ProtocolLMCache: - return enginewire.InjectVLLMLMCache(pod, binding.Endpoint, cache) + return InjectVLLMLMCache(pod, binding.Endpoint, cache) case backendadapter.ProtocolMooncakeStore: - if err := enginewire.InjectVLLMMooncake(pod, binding.Endpoint, cache); err != nil { + if err := InjectVLLMMooncake(pod, binding.Endpoint, cache); err != nil { return err } injectMooncakeEngineHostNetwork(pod, cache) @@ -214,24 +165,37 @@ func (vllmLMCacheAdapter) InjectEngineConfigWithBinding(pod *corev1.PodSpec, bin } } +func injectMooncakeEngineHostNetwork(pod *corev1.PodSpec, cache *cachev1alpha1.CacheBackend) { + if EngineHostNetworkRequested(cache) { + pod.HostNetwork = true + pod.DNSPolicy = corev1.DNSClusterFirstWithHostNet + } +} + +// EngineHostNetworkRequested reports whether the operator opted engine pods +// using a Mooncake remote binding into host networking. +func EngineHostNetworkRequested(cache *cachev1alpha1.CacheBackend) bool { + return adapterruntime.EngineHostNetworkRequested(cache) +} + // InjectRouterConfig is a no-op for LMCache: the LMCache topology has no // router component the controller needs to wire. Returning nil keeps the // interface contract satisfied so a Registry caller can blindly invoke both // Inject* paths on a per-pod basis without branching on backend type — per -// [KVCacheRuntimeAdapter.InjectRouterConfig]: "backends without a router +// [adapterruntime.KVCacheRuntimeAdapter.InjectRouterConfig]: "backends without a router // component should return nil without touching pod." Input validation is // intentionally skipped so a router-less backend never forces callers to // special-case it. -func (vllmLMCacheAdapter) InjectRouterConfig(pod *corev1.PodSpec, endpoint string, cache *cachev1alpha1.CacheBackend) error { +func (vllmLMCacheAdapter) InjectRouterConfig(pod *corev1.PodSpec, binding *backendadapter.Binding, cache *cachev1alpha1.CacheBackend) error { _ = pod - _ = endpoint + _ = binding _ = cache return nil } // ObservationSidecar returns the kvevent-subscriber container the Pod webhook // appends to a vLLM engine pod so its KV-cache events flow to the policy -// server. It delegates to the shared [RenderSubscriberSidecar], pinning the +// server. It delegates to the shared [adapterruntime.RenderSubscriberSidecar], pinning the // vLLM-specific knobs: --hash-scheme=vllm and the vLLM ZMQ PUB port. The // eviction-forwarding policy (--ignore-block-removed) is mode-dependent and // computed by the shared builder (suppressed in Offload where the L2 tier @@ -239,13 +203,13 @@ func (vllmLMCacheAdapter) InjectRouterConfig(pod *corev1.PodSpec, endpoint strin // subscriber shape is identical for every vLLM-engine L2 backend (LMCache, // Mooncake) because the KV-event stream comes from vLLM itself, not the L2 store. func (a vllmLMCacheAdapter) ObservationSidecar(cache *cachev1alpha1.CacheBackend, pod *corev1.Pod) (*corev1.Container, error) { - return RenderSubscriberSidecar(SubscriberSidecarParams{ + return adapterruntime.RenderSubscriberSidecar(adapterruntime.SubscriberSidecarParams{ Image: a.subscriberImage, ServerAddr: a.policyServerGRPCAddress, Cache: cache, Pod: pod, - HashScheme: subscriberHashScheme, - EngineZMQPortStr: defaultEngineZMQPortStr, + HashScheme: vllmSubscriberHashScheme, + EngineZMQPortStr: vllmDefaultEngineZMQPortStr, }) } @@ -253,63 +217,19 @@ func (a vllmLMCacheAdapter) ObservationSidecar(cache *cachev1alpha1.CacheBackend // unit tests in vllm_lmcache_test.go continue to assert on the wire format // through the canonical adapter API surface. New tests for the shared wire // (LMCache, Mooncake, and External all speak the LMCache connector) belong in -// pkg/adapters/runtime/internal/enginewire. +// this package alongside vllm_lmcache_wire_test.go. const defaultEngineKVTransferConfigArg = "--kv-transfer-config" var ( - kvTransferConfig = enginewire.KVTransferConfig - upsertArgPair = enginewire.UpsertArgPair + kvTransferConfig = KVTransferConfig + upsertArgPair = UpsertArgPair ) -// ValidateLMCacheEndpoint re-exports [enginewire.ValidateLMCacheEndpoint] for -// the legacy External API and LMCache-specific callers. Canonical -// remoteStorage callers use [ValidateExternalEndpoint], which dispatches this -// same host/port shape check according to the selected provider. -func ValidateLMCacheEndpoint(s string) error { - return enginewire.ValidateLMCacheEndpoint(s) -} - // ValidateExternalEndpoint is the shared canonical endpoint seam used by // admission, reconciliation, and pod injection. It validates an // operator-supplied endpoint against the selected remote provider's wire // protocol. Bare host:port is portable across providers; explicit schemes are // accepted only when the provider's engine wire consumes them. func ValidateExternalEndpoint(provider cachev1alpha1.CacheBackendRemoteStorageProvider, endpoint string) error { - trimmed := strings.TrimSpace(endpoint) - switch provider { - case cachev1alpha1.CacheBackendRemoteStorageProviderLMCacheServer: - return enginewire.ValidateLMCacheEndpoint(trimmed) - case cachev1alpha1.CacheBackendRemoteStorageProviderRedis: - if scheme, _, ok := strings.Cut(trimmed, "://"); ok { - return fmt.Errorf("scheme %q is not supported for remoteStorage.provider=%s; use bare host:port", - scheme, provider) - } - if err := enginewire.ValidateLMCacheEndpoint(trimmed); err != nil { - return err - } - _, port, err := net.SplitHostPort(trimmed) - if err != nil { - return fmt.Errorf("Redis endpoint must be a bare host:port: %w", err) - } - n, err := strconv.Atoi(port) - if err != nil || n < 1 || n > 65535 { - return fmt.Errorf("Redis endpoint port %q must be an integer in 1-65535", port) - } - return nil - case cachev1alpha1.CacheBackendRemoteStorageProviderMooncake: - if scheme, address, ok := strings.Cut(trimmed, "://"); ok { - if !strings.EqualFold(scheme, "mooncakestore") { - return fmt.Errorf("scheme %q is not supported for remoteStorage.provider=%s; use bare host:port or mooncakestore://host:port", - scheme, provider) - } - if strings.Contains(address, "://") { - return fmt.Errorf("nested endpoint schemes are not supported for remoteStorage.provider=%s; use mooncakestore://host:port", - provider) - } - trimmed = address - } - return enginewire.ValidateLMCacheEndpoint(trimmed) - default: - return fmt.Errorf("remote-storage provider %q has no endpoint protocol", provider) - } + return adapterruntime.ValidateExternalEndpoint(provider, endpoint) } diff --git a/pkg/adapters/runtime/vllm_lmcache_test.go b/internal/adapters/builtin/runtime/vllm_lmcache_test.go similarity index 82% rename from pkg/adapters/runtime/vllm_lmcache_test.go rename to internal/adapters/builtin/runtime/vllm_lmcache_test.go index 0d9e39eb..c46974eb 100644 --- a/pkg/adapters/runtime/vllm_lmcache_test.go +++ b/internal/adapters/builtin/runtime/vllm_lmcache_test.go @@ -4,6 +4,7 @@ import ( "flag" "fmt" "io" + "strconv" "strings" "testing" @@ -12,20 +13,70 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" cachev1alpha1 "github.com/cachebox-project/inference-cache/api/v1alpha1" + provideradapter "github.com/cachebox-project/inference-cache/internal/adapters/builtin/storage" backendadapter "github.com/cachebox-project/inference-cache/pkg/adapters/backend" ) func newLMCacheBackend(cfg map[string]string) *cachev1alpha1.CacheBackend { cb := newCacheBackend(cachev1alpha1.CacheBackendTypeLMCache, "vllm") - cb.Spec.BackendConfig = cfg + cb.Spec.LMCache = &cachev1alpha1.LMCacheEngineSpec{} + cb.Spec.RemoteStorage = &cachev1alpha1.CacheBackendRemoteStorageSpec{ + Provider: cachev1alpha1.CacheBackendRemoteStorageProviderLMCacheServer, + Ownership: cachev1alpha1.CacheBackendRemoteStorageOwnershipManaged, + LMCacheServer: &cachev1alpha1.LMCacheServerRemoteStorageSpec{}, + } + if value := cfg["chunkSize"]; value != "" { + parsed, _ := strconv.ParseInt(value, 10, 32) + chunkSize := int32(parsed) + cb.Spec.LMCache.ChunkSizeTokens = &chunkSize + } + cb.Spec.LMCache.RemoteSerde = cfg["remoteSerde"] + if value := cfg["maxLocalCPU"]; value != "" { + capacity := resource.MustParse(value + "Gi") + cb.Spec.LMCache.HostMemory = &cachev1alpha1.CacheBackendHostMemorySpec{Capacity: &capacity} + } else if cfg["localCPU"] == "True" { + capacity := resource.MustParse("20Gi") + cb.Spec.LMCache.HostMemory = &cachev1alpha1.CacheBackendHostMemorySpec{Capacity: &capacity} + } + cb.Spec.RemoteStorage.LMCacheServer.Image = cfg["serverImage"] + if value := cfg["serverCommand"]; value != "" { + cb.Spec.RemoteStorage.LMCacheServer.Command = strings.Fields(value) + } + if value := cfg["model"]; value != "" { + cb.Spec.Observation = &cachev1alpha1.CacheBackendObservationSpec{ModelID: value} + } + return cb +} + +func newCacheBackend(backendType cachev1alpha1.CacheBackendType, engine string) *cachev1alpha1.CacheBackend { + cb := &cachev1alpha1.CacheBackend{ + ObjectMeta: metav1.ObjectMeta{Name: "cache", Namespace: "ns1"}, + Spec: cachev1alpha1.CacheBackendSpec{Type: backendType}, + } + switch engine { + case "vllm": + cb.Spec.Runtime = cachev1alpha1.CacheBackendRuntimeVLLM + case "sglang": + cb.Spec.Runtime = cachev1alpha1.CacheBackendRuntimeSGLang + } return cb } -// resolvePod unwraps ResolveCacheServer for tests that only assert on the +func lmCacheBinding(endpoint string) *backendadapter.Binding { + return &backendadapter.Binding{Protocol: backendadapter.ProtocolLMCache, Endpoint: endpoint} +} + +// resolveLMCacheServer keeps the provider-rendering assertions independent +// from the runtime adapter now that provider lifecycle is a separate seam. +func resolveLMCacheServer(_ KVCacheRuntimeAdapter, cb *cachev1alpha1.CacheBackend) (*corev1.PodSpec, *corev1.Service, error) { + return provideradapter.ResolveLMCacheServer(cb) +} + +// resolvePod unwraps the provider renderer for tests that only assert on the // rendered pod, failing on error or a nil result. func resolvePod(t *testing.T, a KVCacheRuntimeAdapter, cb *cachev1alpha1.CacheBackend) *corev1.PodSpec { t.Helper() - pod, _, err := ResolveLegacyCacheServer(a, cb) + pod, _, err := resolveLMCacheServer(a, cb) if err != nil { t.Fatalf("ResolveCacheServer: %v", err) } @@ -45,8 +96,7 @@ func TestVLLMLMCacheSupports(t *testing.T) { want bool }{ {"vllm+lmcache", RuntimeVLLM, newLMCacheBackend(nil), true}, - {"vllm+external", RuntimeVLLM, newCacheBackend(cachev1alpha1.CacheBackendTypeExternal, "vllm"), false}, - {"vllm+mooncake", RuntimeVLLM, newCacheBackend(cachev1alpha1.CacheBackendTypeMooncake, "vllm"), false}, + {"vllm+unsupported", RuntimeVLLM, newCacheBackend(cachev1alpha1.CacheBackendType("unsupported"), "vllm"), false}, {"sglang+lmcache", RuntimeSGLang, newLMCacheBackend(nil), false}, {"reference+lmcache", RuntimeReference, newLMCacheBackend(nil), false}, {"nil cache", RuntimeVLLM, nil, false}, @@ -64,7 +114,7 @@ func TestVLLMLMCacheResolveCacheServer(t *testing.T) { a := NewVLLMLMCacheAdapter() cb := newLMCacheBackend(nil) - pod, svc, err := ResolveLegacyCacheServer(a, cb) + pod, svc, err := resolveLMCacheServer(a, cb) if err != nil { t.Fatalf("ResolveCacheServer: %v", err) } @@ -121,7 +171,7 @@ func TestVLLMLMCacheResolveCacheServer(t *testing.T) { // whose data plane genuinely cannot work without it. func TestVLLMLMCacheResolveCacheServerStaysPodNetworkAndVirtualIP(t *testing.T) { a := NewVLLMLMCacheAdapter() - pod, svc, err := ResolveLegacyCacheServer(a, newLMCacheBackend(nil)) + pod, svc, err := resolveLMCacheServer(a, newLMCacheBackend(nil)) if err != nil { t.Fatalf("ResolveCacheServer: %v", err) } @@ -140,7 +190,7 @@ func TestVLLMLMCacheResolveCacheServerHasReadinessProbe(t *testing.T) { // adapter must render a TCP probe targeting the named lmcache port so // Ready waits on the real accept loop. a := NewVLLMLMCacheAdapter() - pod, _, err := ResolveLegacyCacheServer(a, newLMCacheBackend(nil)) + pod, _, err := resolveLMCacheServer(a, newLMCacheBackend(nil)) if err != nil { t.Fatalf("ResolveCacheServer: %v", err) } @@ -160,7 +210,7 @@ func TestVLLMLMCacheResolveCacheServerBoundsRawNilResources(t *testing.T) { // The renderer keeps the 4Gi/8Gi safety bounds even when an object bypasses // the mutating webhook and reaches the raw-struct path with nil resources. a := NewVLLMLMCacheAdapter() - pod, _, err := ResolveLegacyCacheServer(a, newLMCacheBackend(nil)) + pod, _, err := resolveLMCacheServer(a, newLMCacheBackend(nil)) if err != nil { t.Fatalf("ResolveCacheServer: %v", err) } @@ -183,7 +233,7 @@ func TestVLLMLMCacheResolveCacheServerHasCPURequestWhenAutoscaled(t *testing.T) a := NewVLLMLMCacheAdapter() cb := newLMCacheBackend(nil) cb.Spec.Autoscaling = &cachev1alpha1.CacheBackendAutoscalingSpec{MaxReplicas: 3} - pod, _, err := ResolveLegacyCacheServer(a, cb) + pod, _, err := resolveLMCacheServer(a, cb) if err != nil { t.Fatalf("ResolveCacheServer: %v", err) } @@ -198,7 +248,7 @@ func TestVLLMLMCacheResolveCacheServerHasCPURequestWhenAutoscaled(t *testing.T) } func TestVLLMLMCacheResolveCacheServerAutoscalingPreservesLimitsOnlyResources(t *testing.T) { - // Operator-supplied limits-only spec.resources combined with + // Operator-supplied limits-only spec.remoteStorage.lmCacheServer.resources combined with // autoscaling MUST surface as: limits intact, requests carry only // the HPA CPU fallback (no synthesised memory request). The // previous behavior synthesised a 1Gi memory request whenever @@ -208,7 +258,7 @@ func TestVLLMLMCacheResolveCacheServerAutoscalingPreservesLimitsOnlyResources(t a := NewVLLMLMCacheAdapter() cb := newLMCacheBackend(nil) cb.Spec.Autoscaling = &cachev1alpha1.CacheBackendAutoscalingSpec{MaxReplicas: 3} - cb.Spec.Resources = &corev1.ResourceRequirements{ + cb.Spec.RemoteStorage.LMCacheServer.Resources = &corev1.ResourceRequirements{ Limits: corev1.ResourceList{ corev1.ResourceMemory: resource.MustParse("8Gi"), }, @@ -228,8 +278,8 @@ func TestVLLMLMCacheResolveCacheServerAutoscalingPreservesLimitsOnlyResources(t } } -func TestVLLMLMCacheResolveCacheServerHonorsSpecResources(t *testing.T) { - // spec.resources is the operator-owned knob for the lmcache-server +func TestVLLMLMCacheResolveCacheServerHonorsProviderResources(t *testing.T) { + // spec.remoteStorage.lmCacheServer.resources is the operator-owned knob for the lmcache-server // container's Resources. When set the adapter MUST pass it through // verbatim (modulo the autoscaling CPU fallback covered in a separate // test) — the CRD-schema default supplies memory limits to every @@ -237,7 +287,7 @@ func TestVLLMLMCacheResolveCacheServerHonorsSpecResources(t *testing.T) { // rather than OOM-killed by the kubelet under T2 load. a := NewVLLMLMCacheAdapter() cb := newLMCacheBackend(nil) - cb.Spec.Resources = &corev1.ResourceRequirements{ + cb.Spec.RemoteStorage.LMCacheServer.Resources = &corev1.ResourceRequirements{ Requests: corev1.ResourceList{ corev1.ResourceMemory: resource.MustParse("4Gi"), }, @@ -261,28 +311,28 @@ func TestVLLMLMCacheResolveCacheServerHonorsSpecResources(t *testing.T) { } } -func TestVLLMLMCacheResolveCacheServerSpecResourcesNotMutated(t *testing.T) { - // The adapter must not mutate the CacheBackend's spec.resources in +func TestVLLMLMCacheResolveCacheServerProviderResourcesNotMutated(t *testing.T) { + // The adapter must not mutate the CacheBackend's spec.remoteStorage.lmCacheServer.resources in // place — controllers reconcile against an informer-cached object, // and a write through the pointer would propagate back to every // subsequent reader on the same shared cache. a := NewVLLMLMCacheAdapter() cb := newLMCacheBackend(nil) cb.Spec.Autoscaling = &cachev1alpha1.CacheBackendAutoscalingSpec{MaxReplicas: 3} - cb.Spec.Resources = &corev1.ResourceRequirements{ + cb.Spec.RemoteStorage.LMCacheServer.Resources = &corev1.ResourceRequirements{ Limits: corev1.ResourceList{ corev1.ResourceMemory: resource.MustParse("8Gi"), }, } _ = resolvePod(t, a, cb) - if cb.Spec.Resources.Requests != nil { - t.Fatalf("spec.resources.requests = %v, want nil (adapter mutated the spec)", cb.Spec.Resources.Requests) + if cb.Spec.RemoteStorage.LMCacheServer.Resources.Requests != nil { + t.Fatalf("spec.remoteStorage.lmCacheServer.resources.requests = %v, want nil (adapter mutated the spec)", cb.Spec.RemoteStorage.LMCacheServer.Resources.Requests) } } -func TestVLLMLMCacheResolveCacheServerEmptySpecResourcesIsRespected(t *testing.T) { - // An operator who explicitly supplies `spec.resources: {}` is +func TestVLLMLMCacheResolveCacheServerEmptyProviderResourcesIsRespected(t *testing.T) { + // An operator who explicitly supplies `spec.remoteStorage.lmCacheServer.resources: {}` is // suppressing the CRD-default memory budget. The adapter MUST honor // the empty struct as "no Resources" rather than synthesising a // fallback — otherwise the documented suppress-the-default workflow @@ -291,27 +341,27 @@ func TestVLLMLMCacheResolveCacheServerEmptySpecResourcesIsRespected(t *testing.T // the orthogonal HPA-CPU behavior.) a := NewVLLMLMCacheAdapter() cb := newLMCacheBackend(nil) - cb.Spec.Resources = &corev1.ResourceRequirements{} + cb.Spec.RemoteStorage.LMCacheServer.Resources = &corev1.ResourceRequirements{} pod := resolvePod(t, a, cb) got := pod.Containers[0].Resources if len(got.Requests) != 0 { - t.Fatalf("Requests = %v, want empty when spec.resources is {} (operator suppressed default)", got.Requests) + t.Fatalf("Requests = %v, want empty when spec.remoteStorage.lmCacheServer.resources is {} (operator suppressed default)", got.Requests) } if len(got.Limits) != 0 { - t.Fatalf("Limits = %v, want empty when spec.resources is {} (operator suppressed default)", got.Limits) + t.Fatalf("Limits = %v, want empty when spec.remoteStorage.lmCacheServer.resources is {} (operator suppressed default)", got.Limits) } } func TestVLLMLMCacheResolveCacheServerAutoscalingFillsMissingCPU(t *testing.T) { - // When spec.resources is set but omits a CPU request, autoscaling + // When spec.remoteStorage.lmCacheServer.resources is set but omits a CPU request, autoscaling // must still get a CPU-request denominator filled in by the adapter - // — otherwise the operator's memory-only spec.resources silently + // — otherwise the operator's memory-only spec.remoteStorage.lmCacheServer.resources silently // breaks the HPA metric path. The adapter MUST NOT overwrite a // CPU request the operator did supply. a := NewVLLMLMCacheAdapter() cb := newLMCacheBackend(nil) cb.Spec.Autoscaling = &cachev1alpha1.CacheBackendAutoscalingSpec{MaxReplicas: 3} - cb.Spec.Resources = &corev1.ResourceRequirements{ + cb.Spec.RemoteStorage.LMCacheServer.Resources = &corev1.ResourceRequirements{ Requests: corev1.ResourceList{ corev1.ResourceMemory: resource.MustParse("4Gi"), }, @@ -346,7 +396,7 @@ func TestVLLMLMCacheResolveCacheServerAutoscalingReplacesZeroCPU(t *testing.T) { a := NewVLLMLMCacheAdapter() cb := newLMCacheBackend(nil) cb.Spec.Autoscaling = &cachev1alpha1.CacheBackendAutoscalingSpec{MaxReplicas: 3} - cb.Spec.Resources = &corev1.ResourceRequirements{ + cb.Spec.RemoteStorage.LMCacheServer.Resources = &corev1.ResourceRequirements{ Requests: corev1.ResourceList{corev1.ResourceCPU: resource.MustParse("0")}, } pod := resolvePod(t, a, cb) @@ -364,7 +414,7 @@ func TestVLLMLMCacheResolveCacheServerAutoscalingRespectsOperatorCPU(t *testing. a := NewVLLMLMCacheAdapter() cb := newLMCacheBackend(nil) cb.Spec.Autoscaling = &cachev1alpha1.CacheBackendAutoscalingSpec{MaxReplicas: 3} - cb.Spec.Resources = &corev1.ResourceRequirements{ + cb.Spec.RemoteStorage.LMCacheServer.Resources = &corev1.ResourceRequirements{ Requests: corev1.ResourceList{ corev1.ResourceCPU: resource.MustParse("750m"), }, @@ -380,7 +430,7 @@ func TestVLLMLMCacheResolveCacheServerImageOverride(t *testing.T) { a := NewVLLMLMCacheAdapter() cb := newLMCacheBackend(map[string]string{"serverImage": "registry.example.com/lmcache:pinned"}) - pod, _, err := ResolveLegacyCacheServer(a, cb) + pod, _, err := resolveLMCacheServer(a, cb) if err != nil { t.Fatalf("ResolveCacheServer: %v", err) } @@ -395,7 +445,7 @@ func TestVLLMLMCacheResolveCacheServerCommandOverride(t *testing.T) { "serverCommand": "python3 -m lmcache.v1.multiprocess.server --cpu-buffer-size 60", }) - pod, _, err := ResolveLegacyCacheServer(a, cb) + pod, _, err := resolveLMCacheServer(a, cb) if err != nil { t.Fatalf("ResolveCacheServer: %v", err) } @@ -416,7 +466,7 @@ func TestVLLMLMCacheResolveCacheServerCommandOverride(t *testing.T) { func TestVLLMLMCacheResolveCacheServerNilCache(t *testing.T) { a := NewVLLMLMCacheAdapter() - if _, _, err := ResolveLegacyCacheServer(a, nil); err == nil { + if _, _, err := resolveLMCacheServer(a, nil); err == nil { t.Fatalf("ResolveCacheServer(nil) returned no error") } } @@ -440,7 +490,7 @@ func TestVLLMLMCacheInjectEngineConfig(t *testing.T) { }, } - if err := a.InjectEngineConfig(pod, "cache.ns1.svc.cluster.local:65432", cb); err != nil { + if err := a.InjectEngineConfig(pod, lmCacheBinding("cache.ns1.svc.cluster.local:65432"), cb); err != nil { t.Fatalf("InjectEngineConfig: %v", err) } @@ -473,11 +523,11 @@ func TestVLLMLMCacheInjectEngineConfig(t *testing.T) { t.Fatalf("HF_TOKEN was clobbered: got %q, want secret-token", v) } // Existing args are preserved + the connector arg pair is appended. - if !containsArg(engine.Args, "--enable-prefix-caching") { + if !vllmContainsArg(engine.Args, "--enable-prefix-caching") { t.Fatalf("--enable-prefix-caching was dropped: %v", engine.Args) } wantTransfer := kvTransferConfig(cachev1alpha1.CacheBackendIntegrationRoleReadWrite) - if !containsArgPair(engine.Args, defaultEngineKVTransferConfigArg, wantTransfer) { + if !vllmContainsArgPair(engine.Args, defaultEngineKVTransferConfigArg, wantTransfer) { t.Fatalf("connector args missing %s %s: %v", defaultEngineKVTransferConfigArg, wantTransfer, engine.Args) } @@ -498,7 +548,7 @@ func TestVLLMLMCacheInjectEngineConfigSingleContainerPodAcceptsAnyName(t *testin cb := newLMCacheBackend(nil) pod := &corev1.PodSpec{Containers: []corev1.Container{{Name: "engine"}}} - if err := a.InjectEngineConfig(pod, "cache.ns1.svc.cluster.local:65432", cb); err != nil { + if err := a.InjectEngineConfig(pod, lmCacheBinding("cache.ns1.svc.cluster.local:65432"), cb); err != nil { t.Fatalf("InjectEngineConfig: %v", err) } if _, ok := lookupEnv(pod.Containers[0].Env, EnvLMCacheRemoteURL); !ok { @@ -517,7 +567,7 @@ func TestVLLMLMCacheInjectEngineConfigMultiContainerWithoutVLLMNameErrors(t *tes {Name: "sidecar", Env: []corev1.EnvVar{{Name: "SIDECAR_VAR", Value: "untouched"}}}, }} - err := a.InjectEngineConfig(pod, "cache.ns1.svc.cluster.local:65432", cb) + err := a.InjectEngineConfig(pod, lmCacheBinding("cache.ns1.svc.cluster.local:65432"), cb) if err == nil { t.Fatalf("expected an error for multi-container pod without a vllm-named container") } @@ -534,10 +584,10 @@ func TestVLLMLMCacheInjectEngineConfigIdempotent(t *testing.T) { cb := newLMCacheBackend(nil) pod := &corev1.PodSpec{Containers: []corev1.Container{{Name: EngineContainerName}}} - if err := a.InjectEngineConfig(pod, "first.svc:65432", cb); err != nil { + if err := a.InjectEngineConfig(pod, lmCacheBinding("first.svc:65432"), cb); err != nil { t.Fatalf("first InjectEngineConfig: %v", err) } - if err := a.InjectEngineConfig(pod, "second.svc:65432", cb); err != nil { + if err := a.InjectEngineConfig(pod, lmCacheBinding("second.svc:65432"), cb); err != nil { t.Fatalf("second InjectEngineConfig: %v", err) } @@ -588,11 +638,10 @@ func TestVLLMLMCacheInjectEngineConfigFailOpen(t *testing.T) { t.Run(tc.name, func(t *testing.T) { cb := newLMCacheBackend(nil) cb.Spec.Integration = &cachev1alpha1.CacheBackendIntegrationSpec{ - Engine: "vllm", FailOpen: tc.failOpen, } pod := &corev1.PodSpec{Containers: []corev1.Container{{Name: EngineContainerName}}} - if err := a.InjectEngineConfig(pod, "x.svc:65432", cb); err != nil { + if err := a.InjectEngineConfig(pod, lmCacheBinding("x.svc:65432"), cb); err != nil { t.Fatalf("InjectEngineConfig: %v", err) } if v, _ := lookupEnv(pod.Containers[0].Env, EnvInferenceCacheFailOpen); v != tc.want { @@ -618,22 +667,21 @@ func TestVLLMLMCacheInjectEngineConfigRoleMapping(t *testing.T) { t.Run(tc.description, func(t *testing.T) { cb := newLMCacheBackend(nil) cb.Spec.Integration = &cachev1alpha1.CacheBackendIntegrationSpec{ - Engine: "vllm", - Role: tc.role, + Role: tc.role, } pod := &corev1.PodSpec{Containers: []corev1.Container{{Name: EngineContainerName}}} - if err := a.InjectEngineConfig(pod, "x.svc:65432", cb); err != nil { + if err := a.InjectEngineConfig(pod, lmCacheBinding("x.svc:65432"), cb); err != nil { t.Fatalf("InjectEngineConfig: %v", err) } wantValue := fmt.Sprintf(`{"kv_connector":"LMCacheConnectorV1","kv_role":%q}`, tc.wantKVRole) - if !containsArgPair(pod.Containers[0].Args, defaultEngineKVTransferConfigArg, wantValue) { + if !vllmContainsArgPair(pod.Containers[0].Args, defaultEngineKVTransferConfigArg, wantValue) { t.Fatalf("Args = %v, want pair (%s, %s)", pod.Containers[0].Args, defaultEngineKVTransferConfigArg, wantValue) } }) } } -func TestVLLMLMCacheInjectEngineConfigConfigOverrides(t *testing.T) { +func TestVLLMLMCacheInjectEngineConfigTypedOverrides(t *testing.T) { a := NewVLLMLMCacheAdapter() cb := newLMCacheBackend(map[string]string{ "chunkSize": "512", @@ -642,7 +690,7 @@ func TestVLLMLMCacheInjectEngineConfigConfigOverrides(t *testing.T) { "maxLocalCPU": "40", }) pod := &corev1.PodSpec{Containers: []corev1.Container{{Name: EngineContainerName}}} - if err := a.InjectEngineConfig(pod, "x.svc:65432", cb); err != nil { + if err := a.InjectEngineConfig(pod, lmCacheBinding("x.svc:65432"), cb); err != nil { t.Fatalf("InjectEngineConfig: %v", err) } checks := map[string]string{ @@ -653,24 +701,18 @@ func TestVLLMLMCacheInjectEngineConfigConfigOverrides(t *testing.T) { } for name, want := range checks { if v, _ := lookupEnv(pod.Containers[0].Env, name); v != want { - t.Fatalf("%s = %q, want %q (BackendConfig override)", name, v, want) + t.Fatalf("%s = %q, want %q (typed LMCache override)", name, v, want) } } } -func TestVLLMLMCacheCanonicalEngineConfigIgnoresLegacyMap(t *testing.T) { +func TestVLLMLMCacheHostOnlyEngineConfigUsesTypedConfig(t *testing.T) { chunkSize := int32(128) cb := &cachev1alpha1.CacheBackend{ Spec: cachev1alpha1.CacheBackendSpec{ Runtime: cachev1alpha1.CacheBackendRuntimeVLLM, Type: cachev1alpha1.CacheBackendTypeLMCache, LMCache: &cachev1alpha1.LMCacheEngineSpec{ChunkSizeTokens: &chunkSize}, - BackendConfig: map[string]string{ - "chunkSize": "999", - "remoteSerde": "legacy-serde", - "localCPU": "True", - "maxLocalCPU": "99", - }, }, } pod := &corev1.PodSpec{Containers: []corev1.Container{{ @@ -680,9 +722,9 @@ func TestVLLMLMCacheCanonicalEngineConfigIgnoresLegacyMap(t *testing.T) { {Name: "KEEP_ME", Value: "preserved"}, }, }}} - adapter := NewVLLMLMCacheAdapter().(RemoteBindingAdapter) - if err := adapter.InjectEngineConfigWithBinding(pod, nil, cb); err != nil { - t.Fatalf("InjectEngineConfigWithBinding: %v", err) + adapter := NewVLLMLMCacheAdapter() + if err := adapter.InjectEngineConfig(pod, nil, cb); err != nil { + t.Fatalf("InjectEngineConfig: %v", err) } env := pod.Containers[0].Env checks := map[string]string{ @@ -723,9 +765,9 @@ func TestVLLMLMCacheCanonicalMooncakeBindingHonorsEngineHostNetwork(t *testing.T Protocol: backendadapter.ProtocolMooncakeStore, Endpoint: "mooncake.engines.svc.cluster.local:50051", } - adapter := NewVLLMLMCacheAdapter().(RemoteBindingAdapter) - if err := adapter.InjectEngineConfigWithBinding(pod, binding, cb); err != nil { - t.Fatalf("InjectEngineConfigWithBinding: %v", err) + adapter := NewVLLMLMCacheAdapter() + if err := adapter.InjectEngineConfig(pod, binding, cb); err != nil { + t.Fatalf("InjectEngineConfig: %v", err) } if pod.HostNetwork != optIn { @@ -750,7 +792,7 @@ func TestVLLMLMCacheInjectEngineConfigPassesThroughLMScheme(t *testing.T) { cb := newLMCacheBackend(nil) pod := &corev1.PodSpec{Containers: []corev1.Container{{Name: EngineContainerName}}} // A caller that already prefixed lm:// must not produce lm://lm://. - if err := a.InjectEngineConfig(pod, "lm://already.scheme:65432", cb); err != nil { + if err := a.InjectEngineConfig(pod, lmCacheBinding("lm://already.scheme:65432"), cb); err != nil { t.Fatalf("InjectEngineConfig: %v", err) } url, _ := lookupEnv(pod.Containers[0].Env, EnvLMCacheRemoteURL) @@ -770,10 +812,10 @@ func TestVLLMLMCacheInjectEngineConfigBadInput(t *testing.T) { name string fn func() error }{ - {"nil pod", func() error { return a.InjectEngineConfig(nil, "x.svc:65432", cb) }}, - {"nil cache", func() error { return a.InjectEngineConfig(good, "x.svc:65432", nil) }}, - {"empty endpoint", func() error { return a.InjectEngineConfig(good, "", cb) }}, - {"no containers", func() error { return a.InjectEngineConfig(&corev1.PodSpec{}, "x.svc:65432", cb) }}, + {"nil pod", func() error { return a.InjectEngineConfig(nil, lmCacheBinding("x.svc:65432"), cb) }}, + {"nil cache", func() error { return a.InjectEngineConfig(good, lmCacheBinding("x.svc:65432"), nil) }}, + {"empty endpoint", func() error { return a.InjectEngineConfig(good, lmCacheBinding(""), cb) }}, + {"no containers", func() error { return a.InjectEngineConfig(&corev1.PodSpec{}, lmCacheBinding("x.svc:65432"), cb) }}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { @@ -788,7 +830,7 @@ func TestVLLMLMCacheInjectRouterConfigIsNoop(t *testing.T) { a := NewVLLMLMCacheAdapter() cb := newLMCacheBackend(nil) pod := &corev1.PodSpec{Containers: []corev1.Container{{Name: "router", Env: []corev1.EnvVar{{Name: "EXISTING", Value: "x"}}}}} - if err := a.InjectRouterConfig(pod, "x.svc:65432", cb); err != nil { + if err := a.InjectRouterConfig(pod, lmCacheBinding("x.svc:65432"), cb); err != nil { t.Fatalf("InjectRouterConfig: %v", err) } // LMCache has no router; the pod must come back untouched (existing env kept, @@ -810,10 +852,10 @@ func TestVLLMLMCacheInjectRouterConfigTrulyNoopsOnBadInput(t *testing.T) { name string fn func() error }{ - {"nil pod", func() error { return a.InjectRouterConfig(nil, "x", cb) }}, - {"nil cache", func() error { return a.InjectRouterConfig(good, "x", nil) }}, - {"empty endpoint", func() error { return a.InjectRouterConfig(good, "", cb) }}, - {"no containers", func() error { return a.InjectRouterConfig(&corev1.PodSpec{}, "x", cb) }}, + {"nil pod", func() error { return a.InjectRouterConfig(nil, lmCacheBinding("x"), cb) }}, + {"nil cache", func() error { return a.InjectRouterConfig(good, lmCacheBinding("x"), nil) }}, + {"empty endpoint", func() error { return a.InjectRouterConfig(good, lmCacheBinding(""), cb) }}, + {"no containers", func() error { return a.InjectRouterConfig(&corev1.PodSpec{}, lmCacheBinding("x"), cb) }}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { @@ -878,13 +920,21 @@ func TestValidateExternalEndpointProviderSchemes(t *testing.T) { {name: "redis bare", provider: cachev1alpha1.CacheBackendRemoteStorageProviderRedis, endpoint: "redis.example:6379"}, {name: "redis rejects lm", provider: cachev1alpha1.CacheBackendRemoteStorageProviderRedis, endpoint: "lm://redis.example:6379", wantErr: true}, {name: "redis rejects named port", provider: cachev1alpha1.CacheBackendRemoteStorageProviderRedis, endpoint: "redis.example:redis", wantErr: true}, + {name: "redis rejects zero port", provider: cachev1alpha1.CacheBackendRemoteStorageProviderRedis, endpoint: "redis.example:0", wantErr: true}, + {name: "redis rejects out-of-range port", provider: cachev1alpha1.CacheBackendRemoteStorageProviderRedis, endpoint: "redis.example:70000", wantErr: true}, {name: "lmcache bare", provider: cachev1alpha1.CacheBackendRemoteStorageProviderLMCacheServer, endpoint: "cache.example:8200"}, {name: "lmcache explicit", provider: cachev1alpha1.CacheBackendRemoteStorageProviderLMCacheServer, endpoint: "lm://cache.example:8200"}, {name: "lmcache rejects mooncake", provider: cachev1alpha1.CacheBackendRemoteStorageProviderLMCacheServer, endpoint: "mooncakestore://cache.example:50051", wantErr: true}, + {name: "lmcache rejects named port", provider: cachev1alpha1.CacheBackendRemoteStorageProviderLMCacheServer, endpoint: "cache.example:not-a-port", wantErr: true}, + {name: "lmcache rejects zero port", provider: cachev1alpha1.CacheBackendRemoteStorageProviderLMCacheServer, endpoint: "cache.example:0", wantErr: true}, + {name: "lmcache rejects out-of-range port", provider: cachev1alpha1.CacheBackendRemoteStorageProviderLMCacheServer, endpoint: "cache.example:70000", wantErr: true}, {name: "mooncake bare", provider: cachev1alpha1.CacheBackendRemoteStorageProviderMooncake, endpoint: "cache.example:50051"}, {name: "mooncake explicit", provider: cachev1alpha1.CacheBackendRemoteStorageProviderMooncake, endpoint: "mooncakestore://cache.example:50051"}, {name: "mooncake rejects lm", provider: cachev1alpha1.CacheBackendRemoteStorageProviderMooncake, endpoint: "lm://cache.example:50051", wantErr: true}, {name: "mooncake rejects nested scheme", provider: cachev1alpha1.CacheBackendRemoteStorageProviderMooncake, endpoint: "mooncakestore://lm://cache.example:50051", wantErr: true}, + {name: "mooncake rejects named port", provider: cachev1alpha1.CacheBackendRemoteStorageProviderMooncake, endpoint: "mooncakestore://cache.example:not-a-port", wantErr: true}, + {name: "mooncake rejects zero port", provider: cachev1alpha1.CacheBackendRemoteStorageProviderMooncake, endpoint: "mooncakestore://cache.example:0", wantErr: true}, + {name: "mooncake rejects out-of-range port", provider: cachev1alpha1.CacheBackendRemoteStorageProviderMooncake, endpoint: "mooncakestore://cache.example:70000", wantErr: true}, } for _, tt := range tests { @@ -909,10 +959,11 @@ func TestVLLMLMCacheEngineContainerName(t *testing.T) { } } -func TestNewCoreRegistryResolvesVLLMLMCache(t *testing.T) { - r := NewCoreRegistry() +func TestRegistryResolvesVLLMLMCache(t *testing.T) { + r := NewRegistry() + r.Register(NewVLLMLMCacheAdapter()) if r.Len() == 0 { - t.Fatalf("NewCoreRegistry has no adapters") + t.Fatalf("registry has no adapters") } got, err := r.Select(RuntimeVLLM, newLMCacheBackend(nil)) if err != nil { @@ -985,10 +1036,10 @@ func TestVLLMLMCacheObservationSidecarShape(t *testing.T) { } // Downward-API env vars carry the pod's name/namespace at start time — // vital because pod.Name is empty at admission for generateName pods. - if !envHasFieldRef(c.Env, "POD_NAME", "metadata.name") { + if !vllmEnvHasFieldRef(c.Env, "POD_NAME", "metadata.name") { t.Fatalf("env missing POD_NAME via downward API: %v", c.Env) } - if !envHasFieldRef(c.Env, "POD_NAMESPACE", "metadata.namespace") { + if !vllmEnvHasFieldRef(c.Env, "POD_NAMESPACE", "metadata.namespace") { t.Fatalf("env missing POD_NAMESPACE via downward API: %v", c.Env) } wantArgFragments := []string{ @@ -1008,7 +1059,7 @@ func TestVLLMLMCacheObservationSidecarShape(t *testing.T) { "--ignore-block-removed=true", } for _, want := range wantArgFragments { - if !containsArg(c.Args, want) { + if !vllmContainsArg(c.Args, want) { t.Fatalf("subscriber args missing %q; args = %v", want, c.Args) } } @@ -1024,7 +1075,7 @@ func TestVLLMLMCacheInjectEngineConfigEventsOnlyIsNoOp(t *testing.T) { // Events-only (tier-1 routing) wires NO KV connector — the engine container // must be left untouched so a hybrid-attention model's KV-cache manager is // not disabled — and it requires no endpoint (nothing dials a cache server), - // so an empty endpoint must NOT error the way the managed path does. + // so a nil binding must NOT error the way the managed path does. a := NewVLLMLMCacheAdapter() cb := newLMCacheBackend(map[string]string{"model": "Qwen/Qwen3.6-27B"}) cb.Spec.Integration = &cachev1alpha1.CacheBackendIntegrationSpec{ @@ -1032,8 +1083,8 @@ func TestVLLMLMCacheInjectEngineConfigEventsOnlyIsNoOp(t *testing.T) { } pod := &corev1.PodSpec{Containers: []corev1.Container{{Name: EngineContainerName, Args: []string{"--model", "x"}}}} - if err := a.InjectEngineConfig(pod, "", cb); err != nil { - t.Fatalf("events-only InjectEngineConfig must be a no-op with no endpoint, got error: %v", err) + if err := a.InjectEngineConfig(pod, nil, cb); err != nil { + t.Fatalf("events-only InjectEngineConfig must be a no-op with no binding, got error: %v", err) } if got := len(pod.Containers[0].Args); got != 2 { t.Fatalf("events-only must not add engine args; args = %v", pod.Containers[0].Args) @@ -1041,7 +1092,7 @@ func TestVLLMLMCacheInjectEngineConfigEventsOnlyIsNoOp(t *testing.T) { if got := len(pod.Containers[0].Env); got != 0 { t.Fatalf("events-only must not inject connector env; env = %v", pod.Containers[0].Env) } - if containsArg(pod.Containers[0].Args, defaultEngineKVTransferConfigArg) { + if vllmContainsArg(pod.Containers[0].Args, defaultEngineKVTransferConfigArg) { t.Fatalf("events-only must not inject the KV connector arg; args = %v", pod.Containers[0].Args) } } @@ -1075,7 +1126,7 @@ func TestVLLMLMCacheObservationSidecarEventsOnlyForwardsEvictions(t *testing.T) "--model-id=Qwen/Qwen3.6-27B", "--hash-scheme=vllm", } { - if !containsArg(c.Args, want) { + if !vllmContainsArg(c.Args, want) { t.Fatalf("events-only subscriber missing %q; args = %v", want, c.Args) } } @@ -1096,14 +1147,14 @@ func TestVLLMLMCacheObservationSidecarHonoursOptions(t *testing.T) { if c.Image != "registry.example.com/subscriber:pinned" { t.Fatalf("image override ignored: got %q", c.Image) } - if !containsArg(c.Args, "--server=ic-server.custom-ns.svc.cluster.local:9090") { + if !vllmContainsArg(c.Args, "--server=ic-server.custom-ns.svc.cluster.local:9090") { t.Fatalf("server address override ignored; args = %v", c.Args) } } func TestVLLMLMCacheObservationSidecarSkipsWithoutModel(t *testing.T) { - // BackendConfig["model"] is the documented source of --model-id. Without - // it the subscriber binary would refuse to start (model-id is a required + // observation.modelID is the source of --model-id. Without it the + // subscriber binary would refuse to start (model-id is a required // flag), so the adapter returns (nil, nil) to skip the append. The next // admission picks up the sidecar once the operator sets the field. a := NewVLLMLMCacheAdapter(WithSubscriberImage(DefaultSubscriberImage)) @@ -1115,14 +1166,14 @@ func TestVLLMLMCacheObservationSidecarSkipsWithoutModel(t *testing.T) { t.Fatalf("ObservationSidecar: %v", err) } if c != nil { - t.Fatalf("expected nil sidecar when backendConfig.model is unset, got %+v", c) + t.Fatalf("expected nil sidecar when observation.modelID is unset, got %+v", c) } } func TestVLLMLMCacheObservationSidecarSkipsWithoutImage(t *testing.T) { // Default install opts OUT of auto-attach: when the controller flag // --kvevent-subscriber-image is unset, the adapter returns no sidecar - // at all — even when backendConfig.model is set — so an operator that + // at all — even when observation.modelID is set — so an operator that // hasn't yet shipped a subscriber image can't end up with engine pods // stuck in ImagePullBackOff. Opt-in by passing WithSubscriberImage. a := NewVLLMLMCacheAdapter() // no image configured @@ -1203,11 +1254,11 @@ func TestVLLMLMCacheObservationSidecarBadInput(t *testing.T) { } } -// envHasFieldRef returns true if env contains an entry named name backed by +// vllmEnvHasFieldRef returns true if env contains an entry named name backed by // a fieldRef whose FieldPath matches the given path. Used to assert the // downward-API env the subscriber needs to resolve $(POD_NAME) / // $(POD_NAMESPACE) at container start. -func envHasFieldRef(env []corev1.EnvVar, name, path string) bool { +func vllmEnvHasFieldRef(env []corev1.EnvVar, name, path string) bool { for _, e := range env { if e.Name == name && e.ValueFrom != nil && e.ValueFrom.FieldRef != nil && e.ValueFrom.FieldRef.FieldPath == path { return true @@ -1218,7 +1269,7 @@ func envHasFieldRef(env []corev1.EnvVar, name, path string) bool { func TestReferenceAdapterObservationSidecarIsNil(t *testing.T) { a := NewReferenceAdapter() - cb := newCacheBackend(cachev1alpha1.CacheBackendTypeExternal, "") + cb := newCacheBackend(cachev1alpha1.CacheBackendTypeLMCache, "") pod := &corev1.Pod{ObjectMeta: metav1.ObjectMeta{Name: "ref-pod"}} c, err := a.ObservationSidecar(cb, pod) if err != nil { @@ -1229,7 +1280,7 @@ func TestReferenceAdapterObservationSidecarIsNil(t *testing.T) { } } -func containsArg(args []string, want string) bool { +func vllmContainsArg(args []string, want string) bool { for _, a := range args { if a == want { return true @@ -1238,7 +1289,7 @@ func containsArg(args []string, want string) bool { return false } -func containsArgPair(args []string, flag, value string) bool { +func vllmContainsArgPair(args []string, flag, value string) bool { for i, a := range args { if a == flag && i+1 < len(args) && args[i+1] == value { return true diff --git a/pkg/adapters/runtime/internal/enginewire/enginewire.go b/internal/adapters/builtin/runtime/vllm_lmcache_wire.go similarity index 83% rename from pkg/adapters/runtime/internal/enginewire/enginewire.go rename to internal/adapters/builtin/runtime/vllm_lmcache_wire.go index 61989b6d..e6e018f5 100644 --- a/pkg/adapters/runtime/internal/enginewire/enginewire.go +++ b/internal/adapters/builtin/runtime/vllm_lmcache_wire.go @@ -1,42 +1,40 @@ -// Package enginewire holds the engine-side wire format shared by every -// runtime adapter that fronts an LMCache-compatible cache (the in-tree -// vLLM+LMCache adapter, the vLLM+Mooncake adapter, and the External -// passthrough adapter today; future adapters that also speak the LMCache -// connector protocol can import it the same way). +// This file holds the engine-side wire format used by built-in runtime +// adapters that front an LMCache-compatible cache. The in-tree +// vLLM+LMCache adapter uses it for LMCacheServer, Mooncake, and externally +// owned remote bindings; future adapters that speak the protocol can share it. // // Centralising the wire keeps the adapters from drifting: an external cache // the operator manages themselves still presents the same lm:// endpoint // and the engine still parses the same --kv-transfer-config / LMCACHE_* // env, so the injection logic is identical and only the endpoint source -// differs. The Mooncake adapter reuses the same connector wire — vLLM runs +// differs. The Mooncake binding reuses the same connector wire — vLLM runs // the LMCache connector pointed at a mooncakestore:// remote store instead // of an lm:// one — so it differs from the LMCache path in nothing but the -// remote-URL scheme (see [InjectVLLMMooncake]). The package lives under -// internal/ so it stays import-scoped to adapter authors and is never -// confused with a public API the engine team can rely on. -package enginewire +// remote-URL scheme (see [InjectVLLMMooncake]). It lives with the concrete +// adapters and is not part of the public extension contract. +package runtime import ( "fmt" "strings" - "unicode" corev1 "k8s.io/api/core/v1" cachev1alpha1 "github.com/cachebox-project/inference-cache/api/v1alpha1" + adapterruntime "github.com/cachebox-project/inference-cache/pkg/adapters/runtime" ) // Engine env var names. The cache plane's contract with the engine: an // engine pod that carries these variables (plus the --kv-transfer-config // arg below) is wired to an LMCache-compatible cache. const ( - EnvLMCacheRemoteURL = "LMCACHE_REMOTE_URL" - EnvLMCacheRemoteSerde = "LMCACHE_REMOTE_SERDE" - EnvLMCacheChunkSize = "LMCACHE_CHUNK_SIZE" - EnvLMCacheLocalCPU = "LMCACHE_LOCAL_CPU" - EnvLMCacheMaxLocalCPU = "LMCACHE_MAX_LOCAL_CPU_SIZE" - EnvVLLMUseV1 = "VLLM_USE_V1" - EnvInferenceCacheFailOpen = "INFERENCECACHE_FAIL_OPEN" + EnvLMCacheRemoteURL = adapterruntime.EnvLMCacheRemoteURL + EnvLMCacheRemoteSerde = adapterruntime.EnvLMCacheRemoteSerde + EnvLMCacheChunkSize = adapterruntime.EnvLMCacheChunkSize + EnvLMCacheLocalCPU = adapterruntime.EnvLMCacheLocalCPU + EnvLMCacheMaxLocalCPU = adapterruntime.EnvLMCacheMaxLocalCPU + EnvVLLMUseV1 = adapterruntime.EnvVLLMUseV1 + EnvInferenceCacheFailOpen = adapterruntime.EnvInferenceCacheFailOpen // EnvPythonHashSeed pins Python's hash seed so the NONE_HASH that seeds // vLLM's prefix-cache block-hash chain is deterministic across the // scheduler and the TP worker processes. Under TP>1 those are separate @@ -45,7 +43,7 @@ const ( // stored hashes — LMCache reload silently 0-hits and the engine fully // recomputes with no crash and no error. A correctness invariant, not a // tunable. - EnvPythonHashSeed = "PYTHONHASHSEED" + EnvPythonHashSeed = adapterruntime.EnvPythonHashSeed ) // EngineContainerName is the conventional name of the vLLM container in an @@ -53,10 +51,10 @@ const ( // pod is treated as the engine; a multi-container pod is rejected — silently // mutating every container would inject vLLM-only flags onto sidecars and // crash them. -const EngineContainerName = "vllm" +const EngineContainerName = adapterruntime.EngineContainerName // Defaults the engine env carries when the operator does not override them -// through typed LMCache config (or legacy backendConfig). The CPU-safe +// through typed LMCache config. The CPU-safe // LMCACHE_REMOTE_SERDE is "naive"; "cachegen" is faster but pulls in // CUDA-only codepaths. const ( @@ -72,10 +70,6 @@ const ( kvRoleConsumer = "kv_consumer" kvRoleProducer = "kv_producer" kvRoleBoth = "kv_both" - cfgKeyChunkSize = "chunkSize" - cfgKeyRemoteSerde = "remoteSerde" - cfgKeyLocalCPU = "localCPU" - cfgKeyMaxLocalCPU = "maxLocalCPU" ) // InjectVLLMLMCache adds the LMCache connector arg and LMCACHE_* env to the @@ -90,10 +84,10 @@ const ( // lone container is treated as the engine); a multi-container pod with // no `vllm` container is rejected. // -// Both the in-tree vLLM+LMCache adapter (managed backend) and the External -// passthrough adapter call this — same wire shape, the only difference is -// the source of endpoint (controller-resolved Service DNS vs operator- -// supplied address in spec.endpoint). +// The in-tree vLLM+LMCache adapter calls this for both managed and externally +// owned bindings. The wire shape is identical; only the endpoint source differs +// (controller-resolved Service DNS vs the operator-supplied +// spec.remoteStorage.endpoint). func InjectVLLMLMCache(pod *corev1.PodSpec, endpoint string, cache *cachev1alpha1.CacheBackend) error { return injectLMCacheConnector(pod, endpoint, LMCacheRemoteURL(endpoint), cache) } @@ -146,12 +140,11 @@ func injectLMCacheConnector(pod *corev1.PodSpec, endpoint, remoteURL string, cac if remoteURL != "" && endpoint == "" { return fmt.Errorf("inject engine config: endpoint is empty") } - cfg := cache.Spec.BackendConfig env := []corev1.EnvVar{ - {Name: EnvLMCacheRemoteSerde, Value: effectiveRemoteSerde(cache, cfg)}, - {Name: EnvLMCacheChunkSize, Value: effectiveChunkSize(cache, cfg)}, - {Name: EnvLMCacheLocalCPU, Value: effectiveLocalCPU(cache, cfg)}, - {Name: EnvLMCacheMaxLocalCPU, Value: effectiveHostMemoryGB(cache, cfg)}, + {Name: EnvLMCacheRemoteSerde, Value: effectiveRemoteSerde(cache)}, + {Name: EnvLMCacheChunkSize, Value: effectiveChunkSize(cache)}, + {Name: EnvLMCacheLocalCPU, Value: effectiveLocalCPU(cache)}, + {Name: EnvLMCacheMaxLocalCPU, Value: effectiveHostMemoryGB(cache)}, {Name: EnvVLLMUseV1, Value: defaultVLLMUseV1}, {Name: EnvInferenceCacheFailOpen, Value: FailOpenString(cache)}, {Name: EnvPythonHashSeed, Value: defaultPythonHashSeed}, @@ -214,27 +207,21 @@ func validateInjectPodCacheInputs(pod *corev1.PodSpec, cache *cachev1alpha1.Cach return nil } -func effectiveChunkSize(cache *cachev1alpha1.CacheBackend, cfg map[string]string) string { +func effectiveChunkSize(cache *cachev1alpha1.CacheBackend) string { if cache.Spec.LMCache != nil && cache.Spec.LMCache.ChunkSizeTokens != nil { return fmt.Sprintf("%d", *cache.Spec.LMCache.ChunkSizeTokens) } - if cache.Spec.UsesCanonicalCacheHierarchy() { - return defaultChunkSize - } - return ConfigOr(cfg, cfgKeyChunkSize, defaultChunkSize) + return defaultChunkSize } -func effectiveRemoteSerde(cache *cachev1alpha1.CacheBackend, cfg map[string]string) string { +func effectiveRemoteSerde(cache *cachev1alpha1.CacheBackend) string { if cache.Spec.LMCache != nil && cache.Spec.LMCache.RemoteSerde != "" { return cache.Spec.LMCache.RemoteSerde } - if cache.Spec.UsesCanonicalCacheHierarchy() { - return defaultRemoteSerde - } - return ConfigOr(cfg, cfgKeyRemoteSerde, defaultRemoteSerde) + return defaultRemoteSerde } -func effectiveHostMemoryGB(cache *cachev1alpha1.CacheBackend, cfg map[string]string) string { +func effectiveHostMemoryGB(cache *cachev1alpha1.CacheBackend) string { if cache.Spec.LMCache != nil && cache.Spec.LMCache.HostMemory != nil && cache.Spec.LMCache.HostMemory.Capacity != nil { bytes := cache.Spec.LMCache.HostMemory.Capacity.Value() @@ -242,10 +229,7 @@ func effectiveHostMemoryGB(cache *cachev1alpha1.CacheBackend, cfg map[string]str return fmt.Sprintf("%d", ceilPositiveBytesToGiB(bytes)) } } - if cache.Spec.UsesCanonicalCacheHierarchy() { - return defaultMaxLocalCPU - } - return ConfigOr(cfg, cfgKeyMaxLocalCPU, defaultMaxLocalCPU) + return defaultMaxLocalCPU } func ceilPositiveBytesToGiB(bytes int64) int64 { @@ -257,18 +241,15 @@ func ceilPositiveBytesToGiB(bytes int64) int64 { return gibibytes } -func effectiveLocalCPU(cache *cachev1alpha1.CacheBackend, cfg map[string]string) string { +func effectiveLocalCPU(cache *cachev1alpha1.CacheBackend) string { if cache.Spec.LMCache != nil && cache.Spec.LMCache.HostMemory != nil && cache.Spec.LMCache.HostMemory.Capacity != nil { return "True" } - if cache.Spec.UsesCanonicalCacheHierarchy() { - if cache.Spec.RemoteStorage == nil { - return "True" - } - return defaultLocalCPU + if cache.Spec.RemoteStorage == nil { + return "True" } - return ConfigOr(cfg, cfgKeyLocalCPU, defaultLocalCPU) + return defaultLocalCPU } // EngineContainerIndex returns the index of the vLLM engine container the @@ -472,31 +453,7 @@ func ConfigOr(cfg map[string]string, key, fallback string) string { // Centralising the rule here means a future tightening only needs to // touch one place to ripple to all three layers. func ValidateLMCacheEndpoint(s string) error { - raw := strings.TrimSpace(s) - if raw == "" { - return fmt.Errorf("endpoint is empty") - } - if strings.ContainsFunc(raw, func(r rune) bool { - return unicode.IsSpace(r) || unicode.IsControl(r) - }) { - return fmt.Errorf("endpoint must not contain whitespace or control characters within the host or port; use host:port or lm://host:port with no embedded spaces") - } - rest := raw - if i := strings.Index(raw, "://"); i >= 0 { - scheme := strings.ToLower(raw[:i]) - rest = raw[i+3:] - if scheme != "lm" { - return fmt.Errorf("endpoint scheme %q is not supported; use a bare host:port (the LMCache adapter adds the lm:// scheme) or an explicit lm://host:port URL", scheme) - } - } - if strings.ContainsAny(rest, "/?#") { - return fmt.Errorf("endpoint must be host:port (optionally prefixed lm://); paths/queries/fragments are not part of the LMCache wire and would be silently dropped") - } - host, port, ok := splitLMCacheHostPort(rest) - if !ok || host == "" || port == "" { - return fmt.Errorf("endpoint must be a non-empty host AND port (e.g. cache.example.com:8200 or lm://cache.example.com:8200); a scheme alone, a host with no port, an empty port, or a port with no host is not a valid LMCache endpoint") - } - return nil + return adapterruntime.ValidateLMCacheEndpoint(s) } // splitLMCacheHostPort parses a host:port string into its host and port diff --git a/pkg/adapters/runtime/internal/enginewire/enginewire_test.go b/internal/adapters/builtin/runtime/vllm_lmcache_wire_test.go similarity index 93% rename from pkg/adapters/runtime/internal/enginewire/enginewire_test.go rename to internal/adapters/builtin/runtime/vllm_lmcache_wire_test.go index 3d91cfee..427d4ed1 100644 --- a/pkg/adapters/runtime/internal/enginewire/enginewire_test.go +++ b/internal/adapters/builtin/runtime/vllm_lmcache_wire_test.go @@ -1,4 +1,4 @@ -package enginewire +package runtime import ( "math" @@ -102,7 +102,7 @@ func TestEffectiveHostMemoryGBRoundsWithoutOverflow(t *testing.T) { }, }, } - if got := effectiveHostMemoryGB(cache, nil); got != tt.want { + if got := effectiveHostMemoryGB(cache); got != tt.want { t.Fatalf("effectiveHostMemoryGB(%d) = %q, want %q", tt.bytes, got, tt.want) } }) @@ -158,7 +158,7 @@ func TestLMCacheRemoteURL_ShortInputDoesNotPanic(t *testing.T) { // Defensive: an input shorter than the scheme length must not // index out of bounds. (Admission rejects this at the webhook, // but the helper is part of the engine wire seam and is called - // from the External adapter without re-validating.) + // from an external remote binding without re-validating.) for _, in := range []string{"", "lm", "lm:", "lm:/"} { got := LMCacheRemoteURL(in) want := "lm://" + in @@ -285,6 +285,8 @@ func TestValidateLMCacheEndpoint(t *testing.T) { {name: "ipv4-host-port", input: "10.0.0.1:8200"}, {name: "bracketed-ipv6", input: "[2001:db8::1]:8200"}, {name: "bracketed-ipv6-loopback", input: "[::1]:8200"}, + {name: "minimum-port", input: "cache.example:1"}, + {name: "maximum-port", input: "cache.example:65535"}, {name: "leading-trailing-whitespace-trimmed", input: " cache.example:8200 "}, // Invalid shapes — empty. @@ -309,6 +311,13 @@ func TestValidateLMCacheEndpoint(t *testing.T) { {name: "trailing-colon-empty-port", input: "cache.example:", wantErr: true, wantMatch: "non-empty host AND port"}, {name: "bracketed-ipv6-no-port", input: "[::1]", wantErr: true, wantMatch: "non-empty host AND port"}, + // Invalid shapes — port must be a decimal integer in the TCP range. + {name: "named-port", input: "cache.example:not-a-port", wantErr: true, wantMatch: "integer in 1-65535"}, + {name: "signed-port", input: "cache.example:+8200", wantErr: true, wantMatch: "integer in 1-65535"}, + {name: "zero-port", input: "cache.example:0", wantErr: true, wantMatch: "integer in 1-65535"}, + {name: "out-of-range-port", input: "cache.example:70000", wantErr: true, wantMatch: "integer in 1-65535"}, + {name: "ipv6-named-port", input: "[2001:db8::1]:not-a-port", wantErr: true, wantMatch: "integer in 1-65535"}, + // Invalid shapes — unbracketed IPv6. {name: "unbracketed-ipv6", input: "2001:db8::1", wantErr: true, wantMatch: "non-empty host AND port"}, {name: "unbracketed-ipv6-loopback", input: "::1", wantErr: true, wantMatch: "non-empty host AND port"}, diff --git a/pkg/adapters/backend/provider/effective_config.go b/internal/adapters/builtin/storage/effective_config.go similarity index 74% rename from pkg/adapters/backend/provider/effective_config.go rename to internal/adapters/builtin/storage/effective_config.go index c9f5fbf8..a93dd141 100644 --- a/pkg/adapters/backend/provider/effective_config.go +++ b/internal/adapters/builtin/storage/effective_config.go @@ -1,4 +1,4 @@ -package provider +package storage import ( corev1 "k8s.io/api/core/v1" @@ -7,7 +7,7 @@ import ( cachev1alpha1 "github.com/cachebox-project/inference-cache/api/v1alpha1" ) -func defaultCanonicalProviderResources() *corev1.ResourceRequirements { +func defaultProviderResources() *corev1.ResourceRequirements { return &corev1.ResourceRequirements{ Requests: corev1.ResourceList{ corev1.ResourceMemory: resource.MustParse("4Gi"), @@ -39,16 +39,10 @@ func effectiveProviderResources(cache *cachev1alpha1.CacheBackend) *corev1.Resou } } } - if cache.Spec.UsesCanonicalCacheHierarchy() { - return defaultCanonicalProviderResources() - } - if cache.Spec.Resources != nil { - return cache.Spec.Resources - } - return defaultCanonicalProviderResources() + return defaultProviderResources() } -func effectiveProviderImage(cache *cachev1alpha1.CacheBackend, provider cachev1alpha1.CacheBackendRemoteStorageProvider, legacyKey, fallback string) string { +func effectiveProviderImage(cache *cachev1alpha1.CacheBackend, provider cachev1alpha1.CacheBackendRemoteStorageProvider, fallback string) string { if cache == nil { return fallback } @@ -69,10 +63,7 @@ func effectiveProviderImage(cache *cachev1alpha1.CacheBackend, provider cachev1a } } } - if cache.Spec.UsesCanonicalCacheHierarchy() { - return fallback - } - return configOr(cache.Spec.BackendConfig, legacyKey, fallback) + return fallback } func effectiveProviderCommand(cache *cachev1alpha1.CacheBackend, provider cachev1alpha1.CacheBackendRemoteStorageProvider) []string { @@ -95,17 +86,3 @@ func effectiveProviderCommand(cache *cachev1alpha1.CacheBackend, provider cachev } return nil } - -func legacyProviderConfig(cache *cachev1alpha1.CacheBackend) map[string]string { - if cache == nil || cache.Spec.UsesCanonicalCacheHierarchy() { - return nil - } - return cache.Spec.BackendConfig -} - -func configOr(cfg map[string]string, key, fallback string) string { - if value := cfg[key]; value != "" { - return value - } - return fallback -} diff --git a/pkg/adapters/backend/provider/lmcache_server.go b/internal/adapters/builtin/storage/lmcache_server.go similarity index 84% rename from pkg/adapters/backend/provider/lmcache_server.go rename to internal/adapters/builtin/storage/lmcache_server.go index 4d78b705..4e61e55c 100644 --- a/pkg/adapters/backend/provider/lmcache_server.go +++ b/internal/adapters/builtin/storage/lmcache_server.go @@ -1,8 +1,7 @@ -package provider +package storage import ( "fmt" - "strings" corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/api/resource" @@ -11,9 +10,8 @@ import ( cachev1alpha1 "github.com/cachebox-project/inference-cache/api/v1alpha1" ) -// LMCache standalone-server defaults. Canonical resources override them -// through remoteStorage.lmCacheServer; deprecated BackendConfig keys remain -// readable only for legacy resources. +// LMCache standalone-server defaults. Resources override them through +// remoteStorage.lmCacheServer. const ( // The server and the LMCache client compiled into the engine communicate // over a versioned wire protocol, so this must never become a floating tag. @@ -29,9 +27,6 @@ const ( defaultLMCacheServerHost = "0.0.0.0" defaultLMCacheServerStorage = "cpu" defaultLMCacheServerPortName = "lmcache" - - cfgKeyServerImage = "serverImage" - cfgKeyServerCommand = "serverCommand" ) // ResolveLMCacheServer renders the provider-owned standalone LMCache server. @@ -40,15 +35,13 @@ func ResolveLMCacheServer(cache *cachev1alpha1.CacheBackend) (*corev1.PodSpec, * if cache == nil { return nil, nil, fmt.Errorf("resolve cache server: cache is nil") } - cfg := legacyProviderConfig(cache) image := effectiveProviderImage( cache, cachev1alpha1.CacheBackendRemoteStorageProviderLMCacheServer, - cfgKeyServerImage, defaultLMCacheServerImage, ) - command, args := lmCacheServerCommand(cfg) + command, args := lmCacheServerCommand() if typed := effectiveProviderCommand(cache, cachev1alpha1.CacheBackendRemoteStorageProviderLMCacheServer); len(typed) > 0 { command, args = typed[:1], typed[1:] } @@ -109,13 +102,7 @@ func defaultServerResources(cache *cachev1alpha1.CacheBackend) corev1.ResourceRe return out } -func lmCacheServerCommand(cfg map[string]string) (command, args []string) { - if raw := configOr(cfg, cfgKeyServerCommand, ""); raw != "" { - fields := strings.Fields(raw) - if len(fields) > 0 { - return []string{fields[0]}, fields[1:] - } - } +func lmCacheServerCommand() (command, args []string) { return []string{"lmcache_server"}, []string{ defaultLMCacheServerHost, fmt.Sprintf("%d", defaultLMCacheServerPort), diff --git a/pkg/adapters/backend/provider/mooncake.go b/internal/adapters/builtin/storage/mooncake.go similarity index 82% rename from pkg/adapters/backend/provider/mooncake.go rename to internal/adapters/builtin/storage/mooncake.go index 60d68072..007e2c0f 100644 --- a/pkg/adapters/backend/provider/mooncake.go +++ b/internal/adapters/builtin/storage/mooncake.go @@ -1,8 +1,7 @@ -package provider +package storage import ( "fmt" - "strings" corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/util/intstr" @@ -10,9 +9,8 @@ import ( cachev1alpha1 "github.com/cachebox-project/inference-cache/api/v1alpha1" ) -// Mooncake provider defaults. Canonical resources override them through the -// typed remoteStorage.mooncake fields; deprecated BackendConfig keys remain -// readable only for legacy resources. +// Mooncake provider defaults. Resources override them through the typed +// remoteStorage.mooncake fields. const ( // This reference is fully qualified for CRI-O nodes without short-name // resolution and pinned to the release validated with the matching @@ -37,15 +35,13 @@ func ResolveMooncakeServer(cache *cachev1alpha1.CacheBackend) (*corev1.PodSpec, if cache == nil { return nil, nil, fmt.Errorf("resolve cache server: cache is nil") } - cfg := legacyProviderConfig(cache) image := effectiveProviderImage( cache, cachev1alpha1.CacheBackendRemoteStorageProviderMooncake, - cfgKeyServerImage, defaultMooncakeMasterImage, ) - command, args := mooncakeMasterCommand(cfg) + command, args := mooncakeMasterCommand() if typed := effectiveProviderCommand(cache, cachev1alpha1.CacheBackendRemoteStorageProviderMooncake); len(typed) > 0 { command, args = typed[:1], typed[1:] } @@ -103,16 +99,7 @@ func ResolveMooncakeServer(cache *cachev1alpha1.CacheBackend) (*corev1.PodSpec, return pod, service, nil } -func mooncakeMasterCommand(cfg map[string]string) (command, args []string) { - // Command overrides must retain the fixed RPC and metadata ports rendered - // into the container, readiness probe, Service, status endpoint, and engine - // URL. Free-form command text cannot safely drive those structured fields. - if raw := configOr(cfg, cfgKeyServerCommand, ""); raw != "" { - fields := strings.Fields(raw) - if len(fields) > 0 { - return []string{fields[0]}, fields[1:] - } - } +func mooncakeMasterCommand() (command, args []string) { return []string{"mooncake_master"}, []string{ fmt.Sprintf("--rpc_port=%d", defaultMooncakeMasterRPCPort), fmt.Sprintf("--metrics_port=%d", defaultMooncakeMetricsPort), diff --git a/pkg/adapters/backend/provider/redis_l2.go b/internal/adapters/builtin/storage/redis.go similarity index 95% rename from pkg/adapters/backend/provider/redis_l2.go rename to internal/adapters/builtin/storage/redis.go index a1723bbb..5815862d 100644 --- a/pkg/adapters/backend/provider/redis_l2.go +++ b/internal/adapters/builtin/storage/redis.go @@ -1,4 +1,4 @@ -package provider +package storage import ( "fmt" @@ -32,8 +32,7 @@ const ( // pins. A major.minor-alpine tag is more stable than :7 / :latest but still // mutable within its patch line, so it is a sane default, NOT a reproducible // pin: production MUST pin an exact release or @sha256 digest via - // remoteStorage.redis.image (or deprecated backendConfig.redisImage on a - // legacy resource), per the image-pin policy in + // remoteStorage.redis.image, per the image-pin policy in // docs/design/sglang-lmcache-mp-mode.md. This redis:7 line is what // validation exercised against the pinned lmcache MP worker. Redis needs no // lmcache version alignment (the MP worker speaks RESP), so this pin moves @@ -47,11 +46,9 @@ const ( // redisMaxmemoryDefaultBytes is the memory sizing assumed when provider // resources carry no limit; the derived --maxmemory is a fraction of it. - // It matches the provider/legacy 8Gi memory default. + // It matches the provider's 8Gi memory default. redisMaxmemoryDefaultBytes = int64(8) * 1024 * 1024 * 1024 // 8Gi - // cfgKeyRedisImage overrides the Redis image (production should pin a digest). - cfgKeyRedisImage = "redisImage" ) // ResolveRedisL2Server renders the managed Redis L2 store's container set and the @@ -78,7 +75,7 @@ func ResolveRedisL2Server(cache *cachev1alpha1.CacheBackend) (*corev1.PodSpec, * if cache == nil { return nil, nil, fmt.Errorf("resolve redis L2: cache is nil") } - image := effectiveProviderImage(cache, cachev1alpha1.CacheBackendRemoteStorageProviderRedis, cfgKeyRedisImage, defaultRedisImage) + image := effectiveProviderImage(cache, cachev1alpha1.CacheBackendRemoteStorageProviderRedis, defaultRedisImage) container := corev1.Container{ Name: "redis-l2", diff --git a/pkg/adapters/backend/provider/redis_l2_test.go b/internal/adapters/builtin/storage/redis_test.go similarity index 92% rename from pkg/adapters/backend/provider/redis_l2_test.go rename to internal/adapters/builtin/storage/redis_test.go index 7e722d0d..1755764d 100644 --- a/pkg/adapters/backend/provider/redis_l2_test.go +++ b/internal/adapters/builtin/storage/redis_test.go @@ -1,4 +1,4 @@ -package provider +package storage import ( "strconv" @@ -14,10 +14,20 @@ import ( func newCacheBackend(t cachev1alpha1.CacheBackendType, engine string) *cachev1alpha1.CacheBackend { cb := &cachev1alpha1.CacheBackend{ ObjectMeta: metav1.ObjectMeta{Name: "cache", Namespace: "ns1"}, - Spec: cachev1alpha1.CacheBackendSpec{Type: t}, - } - if engine != "" { - cb.Spec.Integration = &cachev1alpha1.CacheBackendIntegrationSpec{Engine: engine} + Spec: cachev1alpha1.CacheBackendSpec{ + Type: t, + RemoteStorage: &cachev1alpha1.CacheBackendRemoteStorageSpec{ + Provider: cachev1alpha1.CacheBackendRemoteStorageProviderRedis, + Ownership: cachev1alpha1.CacheBackendRemoteStorageOwnershipManaged, + Redis: &cachev1alpha1.RedisRemoteStorageSpec{}, + }, + }, + } + switch engine { + case "vllm": + cb.Spec.Runtime = cachev1alpha1.CacheBackendRuntimeVLLM + case "sglang": + cb.Spec.Runtime = cachev1alpha1.CacheBackendRuntimeSGLang } return cb } @@ -44,7 +54,7 @@ func withMemory(cb *cachev1alpha1.CacheBackend, limit, request string) *cachev1a if request != "" { rr.Requests = corev1.ResourceList{corev1.ResourceMemory: resource.MustParse(request)} } - cb.Spec.Resources = rr + cb.Spec.RemoteStorage.Redis.Resources = rr return cb } @@ -126,12 +136,12 @@ func TestResolveRedisL2Server(t *testing.T) { func TestResolveRedisL2ServerResourceContract(t *testing.T) { // The renderer's resource contract, asserted on the surface its consumer uses - // (the rendered container) rather than only on the shared helper: spec.resources + // (the rendered container) rather than only on the shared helper: spec.remoteStorage.redis.resources // is the operator-owned baseline and passes through; autoscaling adds the // CPU-request fallback the HPA needs as a utilization denominator; and the // rendered resources must not ALIAS the CR — a caller mutating the pod it got // back would otherwise be writing into the CacheBackend's spec. - t.Run("spec.resources passes through", func(t *testing.T) { + t.Run("spec.remoteStorage.redis.resources passes through", func(t *testing.T) { cb := withMemory(newCacheBackend(cachev1alpha1.CacheBackendTypeLMCache, "sglang"), "3Gi", "1Gi") pod, _, err := ResolveRedisL2Server(cb) if err != nil { @@ -167,21 +177,21 @@ func TestResolveRedisL2ServerResourceContract(t *testing.T) { } // Mutate what the renderer handed back; the CR must be untouched. pod.Containers[0].Resources.Limits[corev1.ResourceMemory] = resource.MustParse("99Gi") - if q := cb.Spec.Resources.Limits[corev1.ResourceMemory]; q.String() != "3Gi" { - t.Fatalf("mutating the rendered pod wrote through to the CacheBackend: spec.resources.limits.memory = %q, want 3Gi", q.String()) + if q := cb.Spec.RemoteStorage.Redis.Resources.Limits[corev1.ResourceMemory]; q.String() != "3Gi" { + t.Fatalf("mutating the rendered pod wrote through to the CacheBackend: remoteStorage.redis.resources.limits.memory = %q, want 3Gi", q.String()) } }) } func TestResolveRedisL2ServerImageOverride(t *testing.T) { cb := newCacheBackend(cachev1alpha1.CacheBackendTypeLMCache, "sglang") - cb.Spec.BackendConfig = map[string]string{cfgKeyRedisImage: "registry.example/redis@sha256:deadbeef"} + cb.Spec.RemoteStorage.Redis.Image = "registry.example/redis@sha256:deadbeef" pod, _, err := ResolveRedisL2Server(cb) if err != nil { t.Fatalf("ResolveRedisL2Server: %v", err) } if got := pod.Containers[0].Image; got != "registry.example/redis@sha256:deadbeef" { - t.Errorf("image = %q, want the backendConfig override", got) + t.Errorf("image = %q, want the typed Redis override", got) } } diff --git a/pkg/adapters/backend/provider/provider.go b/internal/adapters/builtin/storage/registry.go similarity index 97% rename from pkg/adapters/backend/provider/provider.go rename to internal/adapters/builtin/storage/registry.go index 55002ebe..4a2121a4 100644 --- a/pkg/adapters/backend/provider/provider.go +++ b/internal/adapters/builtin/storage/registry.go @@ -1,5 +1,5 @@ -// Package provider contains the shipping remote-storage provider adapters. -package provider +// Package storage contains the shipping remote-storage provider adapters. +package storage import ( "fmt" diff --git a/pkg/adapters/backend/provider/provider_test.go b/internal/adapters/builtin/storage/registry_test.go similarity index 78% rename from pkg/adapters/backend/provider/provider_test.go rename to internal/adapters/builtin/storage/registry_test.go index 45f41376..3b0837ee 100644 --- a/pkg/adapters/backend/provider/provider_test.go +++ b/internal/adapters/builtin/storage/registry_test.go @@ -1,4 +1,4 @@ -package provider +package storage import ( "testing" @@ -48,7 +48,7 @@ func TestManagedRedisProviderOwnsTypedWorkloadConfig(t *testing.T) { } } -func TestCanonicalProviderDoesNotInheritLegacyWorkloadConfig(t *testing.T) { +func TestCanonicalProviderUsesBoundedDefaults(t *testing.T) { cache := &cachev1alpha1.CacheBackend{ Spec: cachev1alpha1.CacheBackendSpec{ Runtime: cachev1alpha1.CacheBackendRuntimeSGLang, @@ -58,10 +58,6 @@ func TestCanonicalProviderDoesNotInheritLegacyWorkloadConfig(t *testing.T) { Ownership: cachev1alpha1.CacheBackendRemoteStorageOwnershipManaged, Redis: &cachev1alpha1.RedisRemoteStorageSpec{}, }, - BackendConfig: map[string]string{"redisImage": "legacy.example/redis:wrong"}, - Resources: &corev1.ResourceRequirements{ - Limits: corev1.ResourceList{corev1.ResourceMemory: resource.MustParse("1Gi")}, - }, }, } @@ -74,18 +70,22 @@ func TestCanonicalProviderDoesNotInheritLegacyWorkloadConfig(t *testing.T) { t.Fatalf("Render: %v", err) } container := rendered.PodSpec.Containers[0] - if container.Image == "legacy.example/redis:wrong" { - t.Fatal("canonical provider inherited legacy backendConfig.redisImage") - } wantMemory := resource.MustParse("8Gi") if got := container.Resources.Limits[corev1.ResourceMemory]; got.Cmp(wantMemory) != 0 { t.Fatalf("canonical default memory limit = %s, want %s", got.String(), wantMemory.String()) } } -func TestLegacyProviderRetainsBoundedResourcesWithoutDefaulter(t *testing.T) { +func TestProviderRetainsBoundedResourcesWithoutDefaulter(t *testing.T) { cache := &cachev1alpha1.CacheBackend{ - Spec: cachev1alpha1.CacheBackendSpec{Type: cachev1alpha1.CacheBackendTypeLMCache}, + Spec: cachev1alpha1.CacheBackendSpec{ + Runtime: cachev1alpha1.CacheBackendRuntimeVLLM, + Type: cachev1alpha1.CacheBackendTypeLMCache, + RemoteStorage: &cachev1alpha1.CacheBackendRemoteStorageSpec{ + Provider: cachev1alpha1.CacheBackendRemoteStorageProviderLMCacheServer, + Ownership: cachev1alpha1.CacheBackendRemoteStorageOwnershipManaged, + }, + }, } rendered, _, err := ResolveLMCacheServer(cache) if err != nil { @@ -95,9 +95,9 @@ func TestLegacyProviderRetainsBoundedResourcesWithoutDefaulter(t *testing.T) { wantRequest := resource.MustParse("4Gi") resources := rendered.Containers[0].Resources if got := resources.Limits[corev1.ResourceMemory]; got.Cmp(wantLimit) != 0 { - t.Fatalf("legacy fallback memory limit = %s, want %s", got.String(), wantLimit.String()) + t.Fatalf("fallback memory limit = %s, want %s", got.String(), wantLimit.String()) } if got := resources.Requests[corev1.ResourceMemory]; got.Cmp(wantRequest) != 0 { - t.Fatalf("legacy fallback memory request = %s, want %s", got.String(), wantRequest.String()) + t.Fatalf("fallback memory request = %s, want %s", got.String(), wantRequest.String()) } } diff --git a/internal/controller/cachebackend_autoscaling_test.go b/internal/controller/cachebackend_autoscaling_test.go index c214f544..01f828da 100644 --- a/internal/controller/cachebackend_autoscaling_test.go +++ b/internal/controller/cachebackend_autoscaling_test.go @@ -140,8 +140,9 @@ func TestReconcileHPACleanedUpOnSwitchToExternal(t *testing.T) { _ = getHPA(t, r, "cache", "ns1") live := getBackend(t, r, "cache", "ns1") - live.Spec.Type = cachev1alpha1.CacheBackendTypeExternal - live.Spec.Endpoint = "external.ns1.svc:8080" + live.Spec.Runtime = cachev1alpha1.CacheBackendRuntimeVLLM + live.Spec.Type = cachev1alpha1.CacheBackendTypeLMCache + live.Spec.RemoteStorage = externalLMCacheStorage("external.ns1.svc:8080") if err := r.Update(context.Background(), live); err != nil { t.Fatalf("switch to external: %v", err) } @@ -435,7 +436,14 @@ func TestDesiredReplicasReflectsSingletonClamp(t *testing.T) { // expects three and reports RolloutInProgress forever. t.Run("sglang Redis L2 (pair-driven) clamps to 1", func(t *testing.T) { cb := lmcacheBackend("cache", "ns1") - cb.Spec.Integration = &cachev1alpha1.CacheBackendIntegrationSpec{Engine: "sglang"} + cb.Spec.Runtime = cachev1alpha1.CacheBackendRuntimeSGLang + cb.Spec.Runtime = cachev1alpha1.CacheBackendRuntimeSGLang + cb.Spec.Integration = &cachev1alpha1.CacheBackendIntegrationSpec{} + cb.Spec.RemoteStorage = &cachev1alpha1.CacheBackendRemoteStorageSpec{ + Provider: cachev1alpha1.CacheBackendRemoteStorageProviderRedis, + Ownership: cachev1alpha1.CacheBackendRemoteStorageOwnershipManaged, + Redis: &cachev1alpha1.RedisRemoteStorageSpec{}, + } cb.Spec.Replicas = ptrInt32(3) if got := desiredReplicas(cb, newDep(3)); got != 1 { t.Fatalf("desiredReplicas = %d, want 1 (singleton readiness must match the clamp)", got) @@ -452,7 +460,8 @@ func TestDesiredReplicasReflectsSingletonClamp(t *testing.T) { }) t.Run("disabled (0) is preserved, not clamped up", func(t *testing.T) { cb := lmcacheBackend("cache", "ns1") - cb.Spec.Integration = &cachev1alpha1.CacheBackendIntegrationSpec{Engine: "sglang"} + cb.Spec.Runtime = cachev1alpha1.CacheBackendRuntimeSGLang + cb.Spec.Integration = &cachev1alpha1.CacheBackendIntegrationSpec{} cb.Spec.Replicas = ptrInt32(0) if got := desiredReplicas(cb, newDep(0)); got != 0 { t.Fatalf("desiredReplicas = %d, want 0 (disabled preserved)", got) @@ -461,8 +470,7 @@ func TestDesiredReplicasReflectsSingletonClamp(t *testing.T) { t.Run("EventsOnly is NOT a singleton — no cache-server is rendered", func(t *testing.T) { cb := lmcacheBackend("cache", "ns1") cb.Spec.Integration = &cachev1alpha1.CacheBackendIntegrationSpec{ - Engine: "sglang", - Mode: cachev1alpha1.CacheBackendIntegrationModeEventsOnly, + Mode: cachev1alpha1.CacheBackendIntegrationModeEventsOnly, } cb.Spec.Replicas = ptrInt32(3) if got := desiredReplicas(cb, newDep(3)); got != 3 { diff --git a/internal/controller/cachebackend_controller.go b/internal/controller/cachebackend_controller.go index c4ede0e5..e43e2d0b 100644 --- a/internal/controller/cachebackend_controller.go +++ b/internal/controller/cachebackend_controller.go @@ -26,7 +26,6 @@ import ( "sigs.k8s.io/controller-runtime/pkg/log" cachev1alpha1 "github.com/cachebox-project/inference-cache/api/v1alpha1" - builtinadapters "github.com/cachebox-project/inference-cache/internal/adapters/builtin" backendadapter "github.com/cachebox-project/inference-cache/pkg/adapters/backend" adapterruntime "github.com/cachebox-project/inference-cache/pkg/adapters/runtime" ) @@ -62,7 +61,7 @@ const ( annotationRequireKVEvents = "inferencecache.io/require-kv-events" // defaultFirstEventTimeout is the fallback when - // spec.integration.firstEventTimeout is unset and the API-server default + // spec.observation.firstEventTimeout is unset and the API-server default // ("5m") was not applied (e.g. fake-client unit tests). Mirrors the // +kubebuilder:default on the field. defaultFirstEventTimeout = 5 * time.Minute @@ -156,28 +155,27 @@ const ( // that watches lastEventAt, operator dashboards) can switch on reason // instead of regexing the message. const ( - // conditionReasonExternalEndpointAccepted is set when an External - // CacheBackend's spec.endpoint is non-empty: admission accepted the - // operator-supplied endpoint and we trust it without probing - // reachability. A future enhancement could degrade Ready on a - // connection-probe failure, but that's out of scope for the - // passthrough adapter today (fail-soft, trust the operator). + // conditionReasonExternalEndpointAccepted is set when a CacheBackend uses + // external remote-storage ownership and spec.remoteStorage.endpoint is + // non-empty: admission accepted the operator-supplied endpoint and we trust + // it without probing reachability. A future enhancement could degrade Ready + // on a connection-probe failure, but that's out of scope for the structured + // external binding path today (fail-soft, trust the operator). conditionReasonExternalEndpointAccepted = "ExternalEndpointAccepted" // conditionReasonExternalEndpointMissing is set defensively when an - // External CacheBackend has spec.endpoint empty. Admission rejects - // this at the validating webhook, so reaching this branch means a CR - // already in etcd from before the webhook was installed. + // externally owned CacheBackend has spec.remoteStorage.endpoint empty. + // Admission rejects this at the validating webhook, so this branch covers + // objects that bypassed current admission. conditionReasonExternalEndpointMissing = "ExternalEndpointMissing" // conditionReasonExternalEndpointInvalid is set defensively when an - // External CacheBackend has a non-empty spec.endpoint that fails the - // shared shape check (bad scheme, no port, embedded whitespace, - // unbracketed IPv6, …). Current admission rejects all of these at - // the validating webhook; the reason is reachable only for a CR - // stored before the relevant shape rule shipped. Status reflects - // the gap loudly rather than advertising the malformed value as - // Ready=True (which would let the pod webhook then inject an - // LMCACHE_REMOTE_URL the engine connector refuses at startup — - // turning a cache misconfiguration into a serving outage). + // externally owned CacheBackend has a non-empty + // spec.remoteStorage.endpoint that fails the shared shape check (bad scheme, + // no port, embedded whitespace, unbracketed IPv6, …). Current admission + // rejects all of these; this defensive reason covers objects that bypassed + // admission. Status reflects the gap loudly rather than advertising the + // malformed value as Ready=True (which would let the pod webhook then inject + // an LMCACHE_REMOTE_URL the engine connector refuses at startup — turning a + // cache misconfiguration into a serving outage). conditionReasonExternalEndpointInvalid = "ExternalEndpointInvalid" ) @@ -198,13 +196,11 @@ type CacheBackendReconciler struct { // refreshMatchedEnginePods fall through to the embedded // client.Client so existing fake-client tests still work). APIReader client.Reader - // Registry resolves the runtime adapter to use for a CacheBackend. Nil - // falls back to the complete built-in runtime registry assembled by - // internal/adapters/builtin, matching the shipping controller. Set - // explicitly only in tests that need a custom adapter set. + // Registry resolves the runtime adapter to use for a CacheBackend. The + // composition root must inject it before reconciliation starts. Registry *adapterruntime.Registry // BackendRegistry resolves remote provider lifecycle independently from - // engine/runtime wiring. Nil uses the shipping provider registry. + // engine/runtime wiring. The composition root must inject it. BackendRegistry *backendadapter.Registry // MatchedEnginePodsRequeueInterval overrides the self-requeue cadence // that keeps status.matchedEnginePods fresh between unrelated reconcile @@ -394,14 +390,10 @@ func (r *CacheBackendReconciler) Reconcile(ctx context.Context, req ctrl.Request // the selected runtime/provider adapter. Unsupported combinations also shed any // previously managed workload. func (r *CacheBackendReconciler) dispatch(ctx context.Context, logger logr.Logger, backend *cachev1alpha1.CacheBackend) (ctrl.Result, error) { - var shippingRegistries builtinadapters.Registries if r.Registry == nil || r.BackendRegistry == nil { - shippingRegistries = builtinadapters.New() + return ctrl.Result{}, fmt.Errorf("adapter registries are not configured") } registry := r.Registry - if registry == nil { - registry = shippingRegistries.Runtime - } runtimeID := adapterruntime.ResolveRuntimeID(backend) storage := backend.Spec.EffectiveRemoteStorage() @@ -413,15 +405,12 @@ func (r *CacheBackendReconciler) dispatch(ctx context.Context, logger logr.Logge // StatefulSet routing because the mode decides provisioning regardless of // deploymentKind (a server-less backend ignores deploymentKind). // - // EventsOnly is checked BEFORE the External branch so it takes precedence - // over spec.type. Admission's rejectEventsOnlyMisconfiguration rejects a - // spec.type=External + mode=EventsOnly pair, but an admission-bypassed / - // pre-existing stored object with both set must NOT reconcile as External - // (which would publish an endpoint and let the pod webhook inject the KV - // connector via the External adapter) — that violates the events-only "no - // connector, no server" contract. Letting EventsOnly win here mirrors the + // EventsOnly is checked before external remote-storage ownership so it takes + // precedence over provider lifecycle. An admission-bypassed object carrying + // both must not publish an external endpoint or inject a KV connector. Letting + // EventsOnly win here mirrors the // webhook's adapter-independent connector skip, so both layers agree on the - // mode's precedence over type. + // mode's precedence over remote storage. // // First confirm an adapter is selectable for this (runtime, backend) pair. // A stored / admission-bypassed EventsOnly CR with an unsupported (engine, @@ -432,12 +421,19 @@ func (r *CacheBackendReconciler) dispatch(ctx context.Context, logger logr.Logge // unmanaged (no Ready/Progressing published), so the CR isn't advertised as // a working routing tier the substrate can never feed. if backend.Spec.IsEventsOnly() { - if _, err := registry.Select(runtimeID, backend); err != nil { + adapter, err := registry.Select(runtimeID, backend) + if err != nil { logger.V(1).Info("no runtime adapter for events-only backend; treating as unmanaged", "runtime", runtimeID, "type", backend.Spec.Type, "namespace", backend.Namespace, "name", backend.Name, "error", err.Error()) return ctrl.Result{}, r.reconcileUnmanaged(ctx, backend) } + if !adapter.SupportsBinding(nil) { + logger.V(1).Info("runtime adapter does not support host-only events; treating as unmanaged", + "runtime", runtimeID, "type", backend.Spec.Type, + "namespace", backend.Namespace, "name", backend.Name) + return ctrl.Result{}, r.reconcileUnmanaged(ctx, backend) + } if err := r.cleanupOwnedWorkload(ctx, backend); err != nil { return ctrl.Result{}, err } @@ -465,10 +461,10 @@ func (r *CacheBackendReconciler) dispatch(ctx context.Context, logger logr.Logge return ctrl.Result{}, r.reconcileUnmanaged(ctx, backend) } binding := backendadapter.BindingFor(storage, protocol, storage.Endpoint) - if err := adapterruntime.ValidateRemoteBinding(adapter, binding, backend); err != nil { + if !adapter.SupportsBinding(binding) { logger.V(1).Info("runtime adapter does not accept external-storage binding; treating as unmanaged", "runtime", runtimeID, "type", backend.Spec.EffectiveCacheType(), "protocol", protocol, - "namespace", backend.Namespace, "name", backend.Name, "error", err.Error()) + "namespace", backend.Namespace, "name", backend.Name) return ctrl.Result{}, r.reconcileUnmanaged(ctx, backend) } // A backend switched from a managed type to External must shed its workload. @@ -492,7 +488,7 @@ func (r *CacheBackendReconciler) dispatch(ctx context.Context, logger logr.Logge if err := r.cleanupOwnedWorkload(ctx, backend); err != nil { return ctrl.Result{}, err } - if bindingAware, ok := adapter.(adapterruntime.RemoteBindingAdapter); !ok || !bindingAware.SupportsRemoteBinding(nil) { + if !adapter.SupportsBinding(nil) { logger.V(1).Info("runtime adapter does not support host-only caching; treating as unmanaged", "runtime", runtimeID, "type", backend.Spec.EffectiveCacheType(), "namespace", backend.Namespace, "name", backend.Name) @@ -506,11 +502,7 @@ func (r *CacheBackendReconciler) dispatch(ctx context.Context, logger logr.Logge return r.reconcileHostOnly(ctx, backend) } - backendRegistry := r.BackendRegistry - if backendRegistry == nil { - backendRegistry = shippingRegistries.Storage - } - provider, err := backendRegistry.Select(storage) + provider, err := r.BackendRegistry.Select(storage) if err != nil { logger.V(1).Info("no remote-storage provider for backend; treating as unmanaged", "provider", storage.Provider, "ownership", storage.Ownership, @@ -522,22 +514,22 @@ func (r *CacheBackendReconciler) dispatch(ctx context.Context, logger logr.Logge return ctrl.Result{}, fmt.Errorf("render remote storage for %s/%s: %w", backend.Namespace, backend.Name, err) } binding := &backendadapter.Binding{Protocol: rendered.Protocol} - if err := adapterruntime.ValidateRemoteBinding(adapter, binding, backend); err != nil { + if !adapter.SupportsBinding(binding) { logger.V(1).Info("runtime adapter does not accept remote-storage binding; treating as unmanaged", "runtime", runtimeID, "type", backend.Spec.EffectiveCacheType(), "protocol", rendered.Protocol, - "namespace", backend.Namespace, "name", backend.Name, "error", err.Error()) + "namespace", backend.Namespace, "name", backend.Name) return ctrl.Result{}, r.reconcileUnmanaged(ctx, backend) } return r.reconcileManaged(ctx, logger, backend, rendered) } -// reconcileExternal mirrors a pre-existing backend's configured endpoint to -// status and marks the backend Ready: there is no Service to wait on, so -// admission acceptance of spec.endpoint is the only readiness signal the -// controller has. The Ready condition flips to True in lock step so the -// Ready printcolumn (kubectl get cb) reflects the accepted endpoint for -// External CRs that admission has already accepted. +// reconcileExternal mirrors an externally owned backend's configured endpoint +// to status and marks the backend Ready: there is no Service to wait on, so +// admission acceptance of spec.remoteStorage.endpoint is the only readiness +// signal the controller has. The Ready condition flips to True in lock step so +// the Ready printcolumn (kubectl get cb) reflects the accepted endpoint for +// externally owned resources that admission has already accepted. // // Three terminal states, each driven by the SAME shape rule the // validating webhook applies on CREATE/UPDATE — so the reconciler is @@ -623,11 +615,7 @@ func (r *CacheBackendReconciler) reconcileExternal(ctx context.Context, backend // apply. if err := adapterruntime.ValidateExternalEndpoint(storage.Provider, endpoint); err != nil { readyReason = conditionReasonExternalEndpointInvalid - fieldPrefix := "spec." - if backend.Spec.UsesCanonicalCacheHierarchy() { - fieldPrefix = "spec.remoteStorage." - } - readyMsg = fieldPrefix + err.Error() + readyMsg = "spec.remoteStorage." + err.Error() break } readyStatus = metav1.ConditionTrue @@ -1876,7 +1864,7 @@ func evaluateKVEventReadiness(backend *cachev1alpha1.CacheBackend, readyStatus m // Sticky Degraded: once the timeout has been breached // (Conditions[Ready].Reason == NoKVEventsObserved), stay Degraded until an // event arrives — never recompute the window. This guards the case where an - // operator INCREASES spec.integration.firstEventTimeout after the window + // operator INCREASES spec.observation.firstEventTimeout after the window // already elapsed, which would otherwise move the backend back to // AwaitingFirstKVEvent (hiding a known publisher outage for another window), // contradicting the documented "once Degraded, stays Degraded until an diff --git a/internal/controller/cachebackend_controller_test.go b/internal/controller/cachebackend_controller_test.go index 2981e6d7..14c8bd8e 100644 --- a/internal/controller/cachebackend_controller_test.go +++ b/internal/controller/cachebackend_controller_test.go @@ -26,11 +26,21 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client/interceptor" cachev1alpha1 "github.com/cachebox-project/inference-cache/api/v1alpha1" + builtinadapters "github.com/cachebox-project/inference-cache/internal/adapters/builtin" + builtinruntime "github.com/cachebox-project/inference-cache/internal/adapters/builtin/runtime" podwebhook "github.com/cachebox-project/inference-cache/internal/webhook/pod" + backendadapter "github.com/cachebox-project/inference-cache/pkg/adapters/backend" adapterruntime "github.com/cachebox-project/inference-cache/pkg/adapters/runtime" - externaladapter "github.com/cachebox-project/inference-cache/pkg/adapters/runtime/external" ) +type remoteOnlyRuntimeAdapter struct { + adapterruntime.KVCacheRuntimeAdapter +} + +func (remoteOnlyRuntimeAdapter) SupportsBinding(binding *backendadapter.Binding) bool { + return binding != nil +} + func newScheme(t *testing.T) *runtime.Scheme { t.Helper() scheme := runtime.NewScheme() @@ -49,7 +59,7 @@ func newReconciler(scheme *runtime.Scheme, objs ...client.Object) *CacheBackendR WithStatusSubresource(&cachev1alpha1.CacheBackend{}, &appsv1.Deployment{}). WithObjects(objs...). Build() - return &CacheBackendReconciler{ + r := &CacheBackendReconciler{ Client: c, Scheme: scheme, Log: logr.Discard(), @@ -59,10 +69,47 @@ func newReconciler(scheme *runtime.Scheme, objs ...client.Object) *CacheBackendR // the check on a nil pointer. serverInstanceCascade: newServerInstanceCascade(), } + configureTestRegistries(r) + return r +} + +func configureTestRegistries(r *CacheBackendReconciler) { + if r.Registry != nil && r.BackendRegistry != nil { + return + } + registries := builtinadapters.New() + if r.Registry == nil { + r.Registry = registries.Runtime + } + if r.BackendRegistry == nil { + r.BackendRegistry = registries.Storage + } +} + +func setupTestCacheBackendReconciler(mgr ctrl.Manager, r *CacheBackendReconciler) error { + configureTestRegistries(r) + return r.SetupWithManager(mgr) +} + +func TestDispatchRequiresAdapterRegistries(t *testing.T) { + r := &CacheBackendReconciler{} + _, err := r.dispatch(context.Background(), logr.Discard(), lmcacheBackend("cache", "ns1")) + if err == nil || !strings.Contains(err.Error(), "adapter registries are not configured") { + t.Fatalf("dispatch error = %v, want missing-registry error", err) + } +} + +func externalLMCacheStorage(endpoint string) *cachev1alpha1.CacheBackendRemoteStorageSpec { + return &cachev1alpha1.CacheBackendRemoteStorageSpec{ + Provider: cachev1alpha1.CacheBackendRemoteStorageProviderLMCacheServer, + Ownership: cachev1alpha1.CacheBackendRemoteStorageOwnershipExternal, + Endpoint: endpoint, + } } func reconcile(t *testing.T, r *CacheBackendReconciler, name, namespace string) { t.Helper() + configureTestRegistries(r) if _, err := r.Reconcile(context.Background(), ctrl.Request{ NamespacedName: types.NamespacedName{Name: name, Namespace: namespace}, }); err != nil { @@ -87,7 +134,15 @@ func lmcacheBackend(name, namespace string) *cachev1alpha1.CacheBackend { Generation: 1, Annotations: map[string]string{"inferencecache.io/require-kv-events": "false"}, }, - Spec: cachev1alpha1.CacheBackendSpec{Type: cachev1alpha1.CacheBackendTypeLMCache}, + Spec: cachev1alpha1.CacheBackendSpec{ + Runtime: cachev1alpha1.CacheBackendRuntimeVLLM, + Type: cachev1alpha1.CacheBackendTypeLMCache, + RemoteStorage: &cachev1alpha1.CacheBackendRemoteStorageSpec{ + Provider: cachev1alpha1.CacheBackendRemoteStorageProviderLMCacheServer, + Ownership: cachev1alpha1.CacheBackendRemoteStorageOwnershipManaged, + LMCacheServer: &cachev1alpha1.LMCacheServerRemoteStorageSpec{}, + }, + }, } } @@ -192,7 +247,9 @@ func TestReconcileCanonicalHostOnlyCacheCreatesNoProviderWorkload(t *testing.T) scheme := newScheme(t) cb := lmcacheBackend("host-only", "ns1") cb.Spec.Runtime = cachev1alpha1.CacheBackendRuntimeSGLang - cb.Spec.Integration = &cachev1alpha1.CacheBackendIntegrationSpec{Engine: "sglang"} + cb.Spec.RemoteStorage = nil + cb.Spec.Runtime = cachev1alpha1.CacheBackendRuntimeSGLang + cb.Spec.Integration = &cachev1alpha1.CacheBackendIntegrationSpec{} r := newReconciler(scheme, cb) reconcile(t, r, cb.Name, cb.Namespace) @@ -323,13 +380,20 @@ func mooncakeBackend(name, namespace string) *cachev1alpha1.CacheBackend { Generation: 1, Annotations: map[string]string{"inferencecache.io/require-kv-events": "false"}, }, - Spec: cachev1alpha1.CacheBackendSpec{Type: cachev1alpha1.CacheBackendTypeMooncake}, + Spec: cachev1alpha1.CacheBackendSpec{ + Runtime: cachev1alpha1.CacheBackendRuntimeVLLM, + Type: cachev1alpha1.CacheBackendTypeLMCache, + RemoteStorage: &cachev1alpha1.CacheBackendRemoteStorageSpec{ + Provider: cachev1alpha1.CacheBackendRemoteStorageProviderMooncake, + Ownership: cachev1alpha1.CacheBackendRemoteStorageOwnershipManaged, + }, + }, } } -// TestReconcileManagedMooncake is the C2-reconciles-Mooncake DoD: a -// CacheBackend{type: Mooncake} must reconcile into a managed mooncake_master -// Deployment + Service via the Mooncake adapter's ResolveCacheServer, and +// TestReconcileManagedMooncake is the C2-reconciles-Mooncake DoD: a canonical +// Mooncake remote provider must reconcile into a managed mooncake_master +// Deployment + Service, and // status.endpoint must be the master's RPC host:port (the engine-agnostic // address the pod webhook later turns into mooncakestore://). The RPC port // being first in the rendered Service is what makes serviceEndpoint resolve it. @@ -410,7 +474,7 @@ func TestReconcileCanonicalManagedMooncake(t *testing.T) { func TestReconcileLMCacheImageOverride(t *testing.T) { scheme := newScheme(t) cb := lmcacheBackend("cache", "ns1") - cb.Spec.BackendConfig = map[string]string{"serverImage": "registry.example.com/lmcache-server:pinned"} + cb.Spec.RemoteStorage.LMCacheServer.Image = "registry.example.com/lmcache-server:pinned" r := newReconciler(scheme, cb) reconcile(t, r, "cache", "ns1") @@ -467,7 +531,7 @@ func TestReconcileLMCacheUpdatesImage(t *testing.T) { reconcile(t, r, "cache", "ns1") live := getBackend(t, r, "cache", "ns1") - live.Spec.BackendConfig = map[string]string{"serverImage": "example.com/lmcache-server:v2"} + live.Spec.RemoteStorage.LMCacheServer.Image = "example.com/lmcache-server:v2" if err := r.Update(context.Background(), live); err != nil { t.Fatalf("update image: %v", err) } @@ -479,7 +543,7 @@ func TestReconcileLMCacheUpdatesImage(t *testing.T) { } // TestReconcileLMCacheProfileSwitchGPUToCPU is retired: the "profile" -// backendConfig key and the all-in-one vLLM+LMCache container shape it +// historical all-in-one vLLM+LMCache container shape it // switched between are gone. The CacheBackend now renders a CPU-only // standalone lmcache-server regardless of the engine the user runs // alongside it — engine choice (GPU vs CPU image) is the user's, not a @@ -661,8 +725,9 @@ func TestReconcileTypeSwitchToExternalCleansUpChildren(t *testing.T) { } live := getBackend(t, r, "cache", "ns1") - live.Spec.Type = cachev1alpha1.CacheBackendTypeExternal - live.Spec.Endpoint = "external.ns1.svc:8080" + live.Spec.Runtime = cachev1alpha1.CacheBackendRuntimeVLLM + live.Spec.Type = cachev1alpha1.CacheBackendTypeLMCache + live.Spec.RemoteStorage = externalLMCacheStorage("external.ns1.svc:8080") if err := r.Update(context.Background(), live); err != nil { t.Fatalf("switch to external: %v", err) } @@ -729,8 +794,9 @@ func TestReconcileTypeSwitchToExternalClearsObservedServerInstance(t *testing.T) // Switch to External. switching := getBackend(t, r, "cache", "ns1") - switching.Spec.Type = cachev1alpha1.CacheBackendTypeExternal - switching.Spec.Endpoint = "external.ns1.svc:8080" + switching.Spec.Runtime = cachev1alpha1.CacheBackendRuntimeVLLM + switching.Spec.Type = cachev1alpha1.CacheBackendTypeLMCache + switching.Spec.RemoteStorage = externalLMCacheStorage("external.ns1.svc:8080") if err := r.Update(context.Background(), switching); err != nil { t.Fatalf("switch to external: %v", err) } @@ -840,9 +906,8 @@ func TestReconcileSwitchToSGLangHiCacheCleansManagedState(t *testing.T) { switching.Spec.Type = cachev1alpha1.CacheBackendTypeSGLangHiCache switching.Spec.DeploymentKind = cachev1alpha1.CacheBackendDeploymentKindStatefulSet switching.Spec.Integration = &cachev1alpha1.CacheBackendIntegrationSpec{ - Engine: "sglang", - Mode: cachev1alpha1.CacheBackendIntegrationModeOffload, - Role: cachev1alpha1.CacheBackendIntegrationRoleReadWrite, + Mode: cachev1alpha1.CacheBackendIntegrationModeOffload, + Role: cachev1alpha1.CacheBackendIntegrationRoleReadWrite, } switching.Spec.EngineSelector = &cachev1alpha1.CacheBackendEngineSelector{ MatchLabels: map[string]string{"app": "sglang"}, @@ -899,8 +964,9 @@ func TestReconcileLifecycleExitsClearProbeRateLimiter(t *testing.T) { { name: "managed → External", mutate: func(cb *cachev1alpha1.CacheBackend) { - cb.Spec.Type = cachev1alpha1.CacheBackendTypeExternal - cb.Spec.Endpoint = "external.ns1.svc:8080" + cb.Spec.Runtime = cachev1alpha1.CacheBackendRuntimeVLLM + cb.Spec.Type = cachev1alpha1.CacheBackendTypeLMCache + cb.Spec.RemoteStorage = externalLMCacheStorage("external.ns1.svc:8080") }, }, { @@ -959,8 +1025,9 @@ func TestReconcileLifecycleExitsClearEngineCompatibility(t *testing.T) { { name: "managed → External", mutate: func(cb *cachev1alpha1.CacheBackend) { - cb.Spec.Type = cachev1alpha1.CacheBackendTypeExternal - cb.Spec.Endpoint = "external.ns1.svc:8080" + cb.Spec.Runtime = cachev1alpha1.CacheBackendRuntimeVLLM + cb.Spec.Type = cachev1alpha1.CacheBackendTypeLMCache + cb.Spec.RemoteStorage = externalLMCacheStorage("external.ns1.svc:8080") }, }, { @@ -1125,8 +1192,9 @@ func TestReconcileExternalAdvancesObservedGeneration(t *testing.T) { cb := &cachev1alpha1.CacheBackend{ ObjectMeta: metav1.ObjectMeta{Name: "ext", Namespace: "default", Generation: 7}, Spec: cachev1alpha1.CacheBackendSpec{ - Type: cachev1alpha1.CacheBackendTypeExternal, - Endpoint: "external.default.svc:8080", + Runtime: cachev1alpha1.CacheBackendRuntimeVLLM, + Type: cachev1alpha1.CacheBackendTypeLMCache, + RemoteStorage: externalLMCacheStorage("external.default.svc:8080"), }, Status: cachev1alpha1.CacheBackendStatus{Endpoint: "external.default.svc:8080"}, } @@ -1142,13 +1210,11 @@ func TestReconcileExternalAdvancesObservedGeneration(t *testing.T) { func TestReconcileUnmanagedTypeNoop(t *testing.T) { scheme := newScheme(t) - // AIBrix has no registered runtime adapter, so it exercises the - // "unsupported managed type → reconcileUnmanaged" path. (Mooncake is no - // longer a stand-in for an unsupported type — it has an adapter now and - // reconciles managed; see TestReconcileManagedMooncake.) + // An arbitrary unsupported value exercises the admission-bypassed + // "unsupported type → reconcileUnmanaged" path. cb := &cachev1alpha1.CacheBackend{ ObjectMeta: metav1.ObjectMeta{Name: "cache", Namespace: "ns1"}, - Spec: cachev1alpha1.CacheBackendSpec{Type: cachev1alpha1.CacheBackendTypeAIBrix}, + Spec: cachev1alpha1.CacheBackendSpec{Type: cachev1alpha1.CacheBackendType("unsupported")}, } r := newReconciler(scheme, cb) @@ -1172,17 +1238,15 @@ func TestReconcileEventsOnlyUnsupportedPairIsUnmanaged(t *testing.T) { // adapter for an unsupported pair, so it could never inject the // kvevent-subscriber and no KV event would ever flow. dispatch confirms an // adapter is selectable before routing to reconcileEventsOnly; on failure it - // falls to reconcileUnmanaged. AIBrix has no shipping adapter (the default - // registry supports (vllm, LMCache) + (vllm, Mooncake) + External), so it - // is the unsupported-type fixture here — Mooncake is no longer unsupported. + // falls to reconcileUnmanaged. The arbitrary value below is the unsupported + // fixture. scheme := newScheme(t) cb := &cachev1alpha1.CacheBackend{ ObjectMeta: metav1.ObjectMeta{Name: "cache", Namespace: "ns1", Generation: 1}, Spec: cachev1alpha1.CacheBackendSpec{ - Type: cachev1alpha1.CacheBackendTypeAIBrix, + Type: cachev1alpha1.CacheBackendType("unsupported"), Integration: &cachev1alpha1.CacheBackendIntegrationSpec{ - Engine: "vllm", - Mode: cachev1alpha1.CacheBackendIntegrationModeEventsOnly, + Mode: cachev1alpha1.CacheBackendIntegrationModeEventsOnly, }, }, } @@ -1211,52 +1275,72 @@ func TestReconcileEventsOnlyUnsupportedPairIsUnmanaged(t *testing.T) { } } +func TestReconcileEventsOnlyAdapterRejectingHostOnlyBindingIsUnmanaged(t *testing.T) { + scheme := newScheme(t) + cb := &cachev1alpha1.CacheBackend{ + ObjectMeta: metav1.ObjectMeta{Name: "cache", Namespace: "ns1", Generation: 1}, + Spec: cachev1alpha1.CacheBackendSpec{ + Runtime: cachev1alpha1.CacheBackendRuntimeVLLM, + Type: cachev1alpha1.CacheBackendTypeLMCache, + Integration: &cachev1alpha1.CacheBackendIntegrationSpec{ + Mode: cachev1alpha1.CacheBackendIntegrationModeEventsOnly, + }, + }, + } + r := newReconciler(scheme, cb) + r.Registry = adapterruntime.NewRegistry() + r.Registry.Register(remoteOnlyRuntimeAdapter{KVCacheRuntimeAdapter: builtinruntime.NewVLLMLMCacheAdapter()}) + + reconcile(t, r, "cache", "ns1") + + got := getBackend(t, r, "cache", "ns1") + if ready := findCondition(got.Status.Conditions, conditionTypeReady); ready != nil { + t.Fatalf("events-only adapter rejecting nil binding must not publish Ready; got %+v", ready) + } + if prog := findCondition(got.Status.Conditions, conditionTypeProgressing); prog != nil { + t.Fatalf("events-only adapter rejecting nil binding must not publish Progressing; got %+v", prog) + } +} + func TestReconcileEventsOnlyTakesPrecedenceOverExternal(t *testing.T) { - // An admission-bypassed / stored object that sets BOTH spec.type=External - // AND integration.mode=EventsOnly must reconcile via the events-only path, - // NOT the External path. Admission's rejectEventsOnlyMisconfiguration rejects - // this pair, so this is defense-in-depth for a stored CR: dispatch checks - // IsEventsOnly() before the Type==External branch, so EventsOnly wins. If it - // reconciled as External it would mirror spec.endpoint to status and mark - // Ready=True/ExternalEndpointAccepted, letting the pod webhook's External - // adapter inject the KV connector — violating events-only's "no connector, - // no server" contract. The (vllm, External) pair has a registered adapter, so + // An admission-bypassed object that sets both externally owned remote storage + // and integration.mode=EventsOnly must reconcile via the events-only path. + // Admission rejects this pair, so this is defense-in-depth for stored CRs. If + // it reconciled as external storage it would publish an endpoint and allow KV + // connector injection, violating events-only's "no connector, no server" + // contract. The vLLM/LMCache pair has a registered adapter, so // the events-only adapter-selectability check passes and the events-only // reconcile runs. scheme := newScheme(t) cb := &cachev1alpha1.CacheBackend{ ObjectMeta: metav1.ObjectMeta{Name: "cache", Namespace: "ns1", Generation: 1}, Spec: cachev1alpha1.CacheBackendSpec{ - Type: cachev1alpha1.CacheBackendTypeExternal, - Endpoint: "external-cache.ns1.svc:8200", + Runtime: cachev1alpha1.CacheBackendRuntimeVLLM, + Type: cachev1alpha1.CacheBackendTypeLMCache, + RemoteStorage: externalLMCacheStorage("external-cache.ns1.svc:8200"), Integration: &cachev1alpha1.CacheBackendIntegrationSpec{ - Engine: "vllm", - Mode: cachev1alpha1.CacheBackendIntegrationModeEventsOnly, + Mode: cachev1alpha1.CacheBackendIntegrationModeEventsOnly, }, }, } r := newReconciler(scheme, cb) - // Build the relevant subset of the built-in composition: the External - // adapter is registered on the core registry because it cannot live in its - // parent package without an import cycle. Without it the (vllm, External) - // pair is unselectable and the events-only branch falls to - // reconcileUnmanaged — masking the precedence we want to assert. - reg := adapterruntime.NewCoreRegistry() - reg.Register(externaladapter.NewAdapter()) - r.Registry = reg + r.Registry = adapterruntime.NewRegistry() + r.Registry.Register(builtinruntime.NewVLLMLMCacheAdapter()) reconcile(t, r, "cache", "ns1") got := getBackend(t, r, "cache", "ns1") // status.endpoint stays EMPTY — events-only publishes no endpoint. The - // External path would have mirrored spec.endpoint here. + // external-ownership path would have mirrored + // spec.remoteStorage.endpoint here. if got.Status.Endpoint != "" { t.Fatalf("status.endpoint = %q, want empty (events-only wins over External; no endpoint mirrored)", got.Status.Endpoint) } // Ready is published by the events-only gate (AwaitingFirstKVEvent before any - // event), NOT by the External path (ExternalEndpointAccepted). The reason is + // event), NOT by the external-ownership path + // (ExternalEndpointAccepted). The reason is // the discriminator between the two reconcile paths. ready := findCondition(got.Status.Conditions, conditionTypeReady) if ready == nil { @@ -1291,8 +1375,9 @@ func TestReconcileExternalMirrorsEndpointToStatus(t *testing.T) { cb := &cachev1alpha1.CacheBackend{ ObjectMeta: metav1.ObjectMeta{Name: "example", Namespace: "default"}, Spec: cachev1alpha1.CacheBackendSpec{ - Type: cachev1alpha1.CacheBackendTypeExternal, - Endpoint: "external-cache.default.svc:8080", + Runtime: cachev1alpha1.CacheBackendRuntimeVLLM, + Type: cachev1alpha1.CacheBackendTypeLMCache, + RemoteStorage: externalLMCacheStorage("external-cache.default.svc:8080"), }, } r := newReconciler(scheme, cb) @@ -1300,12 +1385,13 @@ func TestReconcileExternalMirrorsEndpointToStatus(t *testing.T) { reconcile(t, r, "example", "default") if got := getBackend(t, r, "example", "default").Status.Endpoint; got != "external-cache.default.svc:8080" { - t.Fatalf("status.endpoint = %q, want spec endpoint", got) + t.Fatalf("status.endpoint = %q, want spec.remoteStorage.endpoint", got) } } func TestReconcileExternalSetsReadyTrue(t *testing.T) { - // External admission accepts spec.endpoint at write time, so the + // Admission accepts spec.remoteStorage.endpoint for external ownership at + // write time, so the // readiness signal is "operator says this endpoint exists and we // accepted it" — there's no Service to wait on. Consumers (the // future readiness gate, kubectl get cb, the indexParticipation @@ -1314,8 +1400,9 @@ func TestReconcileExternalSetsReadyTrue(t *testing.T) { cb := &cachev1alpha1.CacheBackend{ ObjectMeta: metav1.ObjectMeta{Name: "ext", Namespace: "default", Generation: 3}, Spec: cachev1alpha1.CacheBackendSpec{ - Type: cachev1alpha1.CacheBackendTypeExternal, - Endpoint: "ext.default.svc:8080", + Runtime: cachev1alpha1.CacheBackendRuntimeVLLM, + Type: cachev1alpha1.CacheBackendTypeLMCache, + RemoteStorage: externalLMCacheStorage("ext.default.svc:8080"), }, } r := newReconciler(scheme, cb) @@ -1346,7 +1433,8 @@ func TestReconcileExternalSetsReadyTrue(t *testing.T) { } func TestReconcileExternalInvalidEndpointSetsReadyFalse(t *testing.T) { - // An External CR with a non-empty but malformed spec.endpoint must + // An externally owned CR with a non-empty but malformed + // spec.remoteStorage.endpoint must // be marked Ready=False/ExternalEndpointInvalid — current admission // rejects these at write time, but a CR stored before the shape // rule shipped can still carry e.g. `https://...`. Without this, @@ -1358,6 +1446,9 @@ func TestReconcileExternalInvalidEndpointSetsReadyFalse(t *testing.T) { }{ {"bad-scheme", "https://cache.example.com:443/api"}, {"portless-host", "cache.example.com"}, + {"non-numeric-port", "cache.example.com:not-a-port"}, + {"zero-port", "cache.example.com:0"}, + {"out-of-range-port", "cache.example.com:70000"}, {"unbracketed-ipv6", "2001:db8::1"}, {"embedded-whitespace", "cache example:8200"}, } { @@ -1365,8 +1456,9 @@ func TestReconcileExternalInvalidEndpointSetsReadyFalse(t *testing.T) { cb := &cachev1alpha1.CacheBackend{ ObjectMeta: metav1.ObjectMeta{Name: "ext-bad", Namespace: "default"}, Spec: cachev1alpha1.CacheBackendSpec{ - Type: cachev1alpha1.CacheBackendTypeExternal, - Endpoint: tc.endpoint, + Runtime: cachev1alpha1.CacheBackendRuntimeVLLM, + Type: cachev1alpha1.CacheBackendTypeLMCache, + RemoteStorage: externalLMCacheStorage(tc.endpoint), }, } r := newReconciler(scheme, cb) @@ -1380,8 +1472,8 @@ func TestReconcileExternalInvalidEndpointSetsReadyFalse(t *testing.T) { if ready.Reason != "ExternalEndpointInvalid" { t.Fatalf("Ready reason = %q, want ExternalEndpointInvalid", ready.Reason) } - if !strings.Contains(ready.Message, "spec.endpoint") { - t.Fatalf("Ready message = %q, want legacy field spec.endpoint", ready.Message) + if !strings.Contains(ready.Message, "spec.remoteStorage.endpoint") { + t.Fatalf("Ready message = %q, want canonical field spec.remoteStorage.endpoint", ready.Message) } }) } @@ -1517,7 +1609,11 @@ func TestReconcileExternalEmptyEndpointSetsReadyFalse(t *testing.T) { scheme := newScheme(t) cb := &cachev1alpha1.CacheBackend{ ObjectMeta: metav1.ObjectMeta{Name: "ext-no-ep", Namespace: "default"}, - Spec: cachev1alpha1.CacheBackendSpec{Type: cachev1alpha1.CacheBackendTypeExternal}, + Spec: cachev1alpha1.CacheBackendSpec{ + Runtime: cachev1alpha1.CacheBackendRuntimeVLLM, + Type: cachev1alpha1.CacheBackendTypeLMCache, + RemoteStorage: externalLMCacheStorage(""), + }, } r := newReconciler(scheme, cb) @@ -1540,8 +1636,8 @@ func TestReconcileExternalEmptyEndpointSetsReadyFalse(t *testing.T) { } func TestReconcileExternalWhitespaceEndpointTreatedAsMissing(t *testing.T) { - // Admission rejects a whitespace-only spec.endpoint, but a CR already - // in etcd from before admission was installed can still carry one. + // Admission rejects a whitespace-only spec.remoteStorage.endpoint, but a + // caller that bypasses admission can still construct one. // The reconciler must treat it as missing — publishing a raw // "LMCACHE_REMOTE_URL=lm:// " to the engine env is worse than // publishing nothing, and Ready=True on whitespace would mislead @@ -1550,8 +1646,9 @@ func TestReconcileExternalWhitespaceEndpointTreatedAsMissing(t *testing.T) { cb := &cachev1alpha1.CacheBackend{ ObjectMeta: metav1.ObjectMeta{Name: "ext-ws", Namespace: "default"}, Spec: cachev1alpha1.CacheBackendSpec{ - Type: cachev1alpha1.CacheBackendTypeExternal, - Endpoint: " \t ", + Runtime: cachev1alpha1.CacheBackendRuntimeVLLM, + Type: cachev1alpha1.CacheBackendTypeLMCache, + RemoteStorage: externalLMCacheStorage(" \t "), }, } r := newReconciler(scheme, cb) @@ -1575,8 +1672,12 @@ func TestReconcileExternalClearsRemovedEndpoint(t *testing.T) { scheme := newScheme(t) cb := &cachev1alpha1.CacheBackend{ ObjectMeta: metav1.ObjectMeta{Name: "example", Namespace: "default"}, - Spec: cachev1alpha1.CacheBackendSpec{Type: cachev1alpha1.CacheBackendTypeExternal}, - Status: cachev1alpha1.CacheBackendStatus{Endpoint: "stale-cache.default.svc:8080"}, + Spec: cachev1alpha1.CacheBackendSpec{ + Runtime: cachev1alpha1.CacheBackendRuntimeVLLM, + Type: cachev1alpha1.CacheBackendTypeLMCache, + RemoteStorage: externalLMCacheStorage(""), + }, + Status: cachev1alpha1.CacheBackendStatus{Endpoint: "stale-cache.default.svc:8080"}, } r := newReconciler(scheme, cb) @@ -1588,23 +1689,22 @@ func TestReconcileExternalClearsRemovedEndpoint(t *testing.T) { } func TestReconcileLMCacheCaseInsensitiveEngine(t *testing.T) { - // Common user spellings ("vLLM", "VLLM") must route to the canonical - // RuntimeVLLM, not silently drop the CR into the unmanaged path. - for _, engine := range []string{"vLLM", "VLLM", "vllm"} { - t.Run(engine, func(t *testing.T) { + // The canonical VLLM runtime must route to the managed adapter path. + for _, runtime := range []cachev1alpha1.CacheBackendRuntime{cachev1alpha1.CacheBackendRuntimeVLLM} { + t.Run(string(runtime), func(t *testing.T) { scheme := newScheme(t) cb := lmcacheBackend("cache", "ns1") - cb.Spec.Integration = &cachev1alpha1.CacheBackendIntegrationSpec{Engine: engine} + cb.Spec.Runtime = runtime r := newReconciler(scheme, cb) reconcile(t, r, "cache", "ns1") dep, err := getOptionalDeployment(t, r, "cache", "ns1") if err != nil { - t.Fatalf("expected a managed Deployment for engine=%q, got error: %v", engine, err) + t.Fatalf("expected a managed Deployment for runtime=%q, got error: %v", runtime, err) } if got := dep.Spec.Template.Spec.Containers[0].Name; got != "lmcache-server" { - t.Fatalf("container = %q, want lmcache-server (engine=%q must resolve to RuntimeVLLM)", got, engine) + t.Fatalf("container = %q, want lmcache-server (runtime=%q must resolve to RuntimeVLLM)", got, runtime) } }) } @@ -1861,12 +1961,14 @@ func newReconcilerWithInterceptor(scheme *runtime.Scheme, funcs interceptor.Func WithObjects(objs...). WithInterceptorFuncs(funcs). Build() - return &CacheBackendReconciler{ + r := &CacheBackendReconciler{ Client: c, Scheme: scheme, Log: logr.Discard(), serverInstanceCascade: newServerInstanceCascade(), } + configureTestRegistries(r) + return r } // TestReconcileLMCacheConflictThenConverge guards against a stuck-Degraded @@ -1908,7 +2010,7 @@ func TestReconcileLMCacheConflictThenConverge(t *testing.T) { // (Image override mutates the managed container in-place; a no-op reconcile // would not call Update at all.) live := getBackend(t, r, "cache", "ns1") - live.Spec.BackendConfig = map[string]string{"serverImage": "example.com/lmcache-server:v9"} + live.Spec.RemoteStorage.LMCacheServer.Image = "example.com/lmcache-server:v9" live.Generation = 2 if err := r.Update(context.Background(), live); err != nil { t.Fatalf("update CR: %v", err) @@ -1968,7 +2070,7 @@ func TestReconcileLMCacheStatusIndependentOfApplyError(t *testing.T) { // an Update to happen by changing the image in the CR. blockDeploymentUpdate.Store(true) live := getBackend(t, r, "cache", "ns1") - live.Spec.BackendConfig = map[string]string{"serverImage": "example.com/lmcache-server:v9"} + live.Spec.RemoteStorage.LMCacheServer.Image = "example.com/lmcache-server:v9" live.Generation = 2 if err := r.Update(context.Background(), live); err != nil { t.Fatalf("update CR: %v", err) diff --git a/internal/controller/cachebackend_engine_compat.go b/internal/controller/cachebackend_engine_compat.go index 3fb6d36f..3cccaa06 100644 --- a/internal/controller/cachebackend_engine_compat.go +++ b/internal/controller/cachebackend_engine_compat.go @@ -9,7 +9,6 @@ import ( "sigs.k8s.io/controller-runtime/pkg/log" cachev1alpha1 "github.com/cachebox-project/inference-cache/api/v1alpha1" - builtinadapters "github.com/cachebox-project/inference-cache/internal/adapters/builtin" "github.com/cachebox-project/inference-cache/internal/enginebinding" adapterruntime "github.com/cachebox-project/inference-cache/pkg/adapters/runtime" ) @@ -104,7 +103,7 @@ func (r *CacheBackendReconciler) detectEngineConnectorCrashLoop(ctx context.Cont func (r *CacheBackendReconciler) engineContainerName(backend *cachev1alpha1.CacheBackend) string { registry := r.Registry if registry == nil { - registry = builtinadapters.New().Runtime + return "" } adapter, err := registry.Select(adapterruntime.ResolveRuntimeID(backend), backend) if err != nil { diff --git a/internal/controller/cachebackend_events_only_integration_test.go b/internal/controller/cachebackend_events_only_integration_test.go index c7901ab6..2868792d 100644 --- a/internal/controller/cachebackend_events_only_integration_test.go +++ b/internal/controller/cachebackend_events_only_integration_test.go @@ -24,15 +24,15 @@ func eventsOnlyBackend(name, ns string) *cachev1alpha1.CacheBackend { return &cachev1alpha1.CacheBackend{ ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: ns, Generation: 1}, Spec: cachev1alpha1.CacheBackendSpec{ - Type: cachev1alpha1.CacheBackendTypeLMCache, + Runtime: cachev1alpha1.CacheBackendRuntimeVLLM, + Type: cachev1alpha1.CacheBackendTypeLMCache, Integration: &cachev1alpha1.CacheBackendIntegrationSpec{ - Engine: "vllm", - Mode: cachev1alpha1.CacheBackendIntegrationModeEventsOnly, + Mode: cachev1alpha1.CacheBackendIntegrationModeEventsOnly, }, EngineSelector: &cachev1alpha1.CacheBackendEngineSelector{ MatchLabels: map[string]string{"app": "vllm"}, }, - BackendConfig: map[string]string{"model": "Qwen/Qwen2.5-0.5B-Instruct"}, + Observation: &cachev1alpha1.CacheBackendObservationSpec{ModelID: "Qwen/Qwen2.5-0.5B-Instruct"}, }, } } @@ -151,7 +151,7 @@ func TestIntegrationEventsOnlyMode(t *testing.T) { // Degraded=True — exactly like a managed backend, but with no Deployment. ns := freshNS(t, k8s) cb := eventsOnlyBackend("cache", ns) - cb.Spec.Integration.FirstEventTimeout = &metav1.Duration{Duration: time.Second} + cb.Spec.Observation.FirstEventTimeout = &metav1.Duration{Duration: time.Second} if err := k8s.Create(ctx, cb); err != nil { t.Fatalf("create: %v", err) } @@ -199,10 +199,15 @@ func TestIntegrationEventsOnlyMode(t *testing.T) { cb := &cachev1alpha1.CacheBackend{ ObjectMeta: metav1.ObjectMeta{Name: "cache", Namespace: ns, Generation: 1}, Spec: cachev1alpha1.CacheBackendSpec{ - Type: cachev1alpha1.CacheBackendTypeLMCache, + Runtime: cachev1alpha1.CacheBackendRuntimeVLLM, + Type: cachev1alpha1.CacheBackendTypeLMCache, + RemoteStorage: &cachev1alpha1.CacheBackendRemoteStorageSpec{ + Provider: cachev1alpha1.CacheBackendRemoteStorageProviderLMCacheServer, + Ownership: cachev1alpha1.CacheBackendRemoteStorageOwnershipManaged, + LMCacheServer: &cachev1alpha1.LMCacheServerRemoteStorageSpec{}, + }, Integration: &cachev1alpha1.CacheBackendIntegrationSpec{ - Engine: "vllm", - Mode: cachev1alpha1.CacheBackendIntegrationModeOffload, + Mode: cachev1alpha1.CacheBackendIntegrationModeOffload, }, }, } diff --git a/internal/controller/cachebackend_events_test.go b/internal/controller/cachebackend_events_test.go index 6319f9e6..35f6c708 100644 --- a/internal/controller/cachebackend_events_test.go +++ b/internal/controller/cachebackend_events_test.go @@ -43,7 +43,9 @@ func newReconcilerWithRecorder(t *testing.T, objs ...client.Object) (*CacheBacke WithObjects(objs...). Build() rec := events.NewFakeRecorder(16) - return &CacheBackendReconciler{Client: c, Scheme: scheme, Log: logr.Discard(), Recorder: rec}, rec + r := &CacheBackendReconciler{Client: c, Scheme: scheme, Log: logr.Discard(), Recorder: rec} + configureTestRegistries(r) + return r, rec } // drainEvents pulls every event currently on the recorder channel. The channel @@ -206,6 +208,7 @@ func TestReconcileEmitsTransitionEventEvenWhenApplyErrors(t *testing.T) { Build() rec := events.NewFakeRecorder(16) r := &CacheBackendReconciler{Client: c, Scheme: scheme, Log: logr.Discard(), Recorder: rec} + configureTestRegistries(r) // First pass establishes the Deployment + drives Ready (no events; only // Degraded transitions are loud by design). @@ -220,7 +223,7 @@ func TestReconcileEmitsTransitionEventEvenWhenApplyErrors(t *testing.T) { // drives the readiness transition. blockUpdate.Store(true) live := getBackend(t, r, "cache", "ns1") - live.Spec.BackendConfig = map[string]string{"serverImage": "example.com/lmcache-server:v9"} + live.Spec.RemoteStorage.LMCacheServer.Image = "example.com/lmcache-server:v9" live.Generation = 2 if err := r.Update(context.Background(), live); err != nil { t.Fatalf("update CR: %v", err) @@ -278,6 +281,7 @@ func TestReconcileNoPhantomEventOnStatusPatchFailure(t *testing.T) { Build() rec := events.NewFakeRecorder(16) r := &CacheBackendReconciler{Client: c, Scheme: scheme, Log: logr.Discard(), Recorder: rec} + configureTestRegistries(r) // Drive to Ready first. Pending → Ready emits no event by design (only // Degraded entry/exit are loud). diff --git a/internal/controller/cachebackend_hostnetwork_test.go b/internal/controller/cachebackend_hostnetwork_test.go index 2d81df0a..c205e3ce 100644 --- a/internal/controller/cachebackend_hostnetwork_test.go +++ b/internal/controller/cachebackend_hostnetwork_test.go @@ -59,15 +59,15 @@ func TestClampSingletonReplicas(t *testing.T) { // host-network one. sglangLMCache := func() *cachev1alpha1.CacheBackend { cb := &cachev1alpha1.CacheBackend{Spec: cachev1alpha1.CacheBackendSpec{ - Type: cachev1alpha1.CacheBackendTypeLMCache, - Integration: &cachev1alpha1.CacheBackendIntegrationSpec{Engine: "sglang"}, + Runtime: cachev1alpha1.CacheBackendRuntimeSGLang, + Type: cachev1alpha1.CacheBackendTypeLMCache, }} return cb } vllmLMCache := func() *cachev1alpha1.CacheBackend { return &cachev1alpha1.CacheBackend{Spec: cachev1alpha1.CacheBackendSpec{ - Type: cachev1alpha1.CacheBackendTypeLMCache, - Integration: &cachev1alpha1.CacheBackendIntegrationSpec{Engine: "vllm"}, + Runtime: cachev1alpha1.CacheBackendRuntimeVLLM, + Type: cachev1alpha1.CacheBackendTypeLMCache, }} } for _, tc := range []struct { diff --git a/internal/controller/cachebackend_kvevent_gate_test.go b/internal/controller/cachebackend_kvevent_gate_test.go index d9c32894..6176282b 100644 --- a/internal/controller/cachebackend_kvevent_gate_test.go +++ b/internal/controller/cachebackend_kvevent_gate_test.go @@ -25,8 +25,14 @@ func gatedLMCacheBackend(name, ns string) *cachev1alpha1.CacheBackend { return &cachev1alpha1.CacheBackend{ ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: ns, Generation: 1}, Spec: cachev1alpha1.CacheBackendSpec{ + Runtime: cachev1alpha1.CacheBackendRuntimeVLLM, Type: cachev1alpha1.CacheBackendTypeLMCache, Replicas: ptrInt32(1), + RemoteStorage: &cachev1alpha1.CacheBackendRemoteStorageSpec{ + Provider: cachev1alpha1.CacheBackendRemoteStorageProviderLMCacheServer, + Ownership: cachev1alpha1.CacheBackendRemoteStorageOwnershipManaged, + LMCacheServer: &cachev1alpha1.LMCacheServerRemoteStorageSpec{}, + }, }, } } @@ -185,7 +191,7 @@ func TestIntegrationKVEventReadinessGate(t *testing.T) { t.Run("TimeoutBreachedIsDegradedNoKVEventsObserved", func(t *testing.T) { ns := freshNS(t, k8s) cb := gatedLMCacheBackend("cache", ns) - cb.Spec.Integration = &cachev1alpha1.CacheBackendIntegrationSpec{ + cb.Spec.Observation = &cachev1alpha1.CacheBackendObservationSpec{ FirstEventTimeout: &metav1.Duration{Duration: time.Second}, } if err := k8s.Create(ctx, cb); err != nil { @@ -215,7 +221,7 @@ func TestIntegrationKVEventReadinessGate(t *testing.T) { // anchor (not the flappable live Available condition) guarantees this. ns := freshNS(t, k8s) cb := gatedLMCacheBackend("cache", ns) - cb.Spec.Integration = &cachev1alpha1.CacheBackendIntegrationSpec{ + cb.Spec.Observation = &cachev1alpha1.CacheBackendObservationSpec{ FirstEventTimeout: &metav1.Duration{Duration: time.Second}, } if err := k8s.Create(ctx, cb); err != nil { @@ -248,7 +254,7 @@ func TestIntegrationKVEventReadinessGate(t *testing.T) { // Degraded it stays Degraded until an event arrives. ns := freshNS(t, k8s) cb := gatedLMCacheBackend("cache", ns) - cb.Spec.Integration = &cachev1alpha1.CacheBackendIntegrationSpec{ + cb.Spec.Observation = &cachev1alpha1.CacheBackendObservationSpec{ FirstEventTimeout: &metav1.Duration{Duration: time.Second}, } if err := k8s.Create(ctx, cb); err != nil { @@ -264,7 +270,7 @@ func TestIntegrationKVEventReadinessGate(t *testing.T) { // Operator increases the timeout to well beyond the elapsed window. live := getBackend(t, r, "cache", ns) - live.Spec.Integration.FirstEventTimeout = &metav1.Duration{Duration: time.Hour} + live.Spec.Observation.FirstEventTimeout = &metav1.Duration{Duration: time.Hour} if err := k8s.Update(ctx, live); err != nil { t.Fatalf("update firstEventTimeout: %v", err) } @@ -320,8 +326,9 @@ func TestIntegrationKVEventReadinessGate(t *testing.T) { cb := &cachev1alpha1.CacheBackend{ ObjectMeta: metav1.ObjectMeta{Name: "ext", Namespace: ns}, Spec: cachev1alpha1.CacheBackendSpec{ - Type: cachev1alpha1.CacheBackendTypeExternal, - Endpoint: "external.example.svc:6379", + Runtime: cachev1alpha1.CacheBackendRuntimeVLLM, + Type: cachev1alpha1.CacheBackendTypeLMCache, + RemoteStorage: externalLMCacheStorage("external.example.svc:6379"), }, } if err := k8s.Create(ctx, cb); err != nil { @@ -348,17 +355,18 @@ func TestIntegrationKVEventReadinessGate(t *testing.T) { } }) - t.Run("BackwardCompatDefaultsTimeoutTo5m", func(t *testing.T) { + t.Run("ObservationDefaultsTimeoutTo5m", func(t *testing.T) { ns := freshNS(t, k8s) cb := gatedLMCacheBackend("cache", ns) - // Provide integration but omit firstEventTimeout: the apiserver applies - // the +kubebuilder:default of 5m. - cb.Spec.Integration = &cachev1alpha1.CacheBackendIntegrationSpec{Engine: "vllm"} + // The CRD defaults firstEventTimeout when the canonical observation + // block is present. Webhook materialization of an omitted parent is + // covered by cachebackend_defaulter_envtest_test.go. + cb.Spec.Observation = &cachev1alpha1.CacheBackendObservationSpec{} if err := k8s.Create(ctx, cb); err != nil { t.Fatalf("create: %v", err) } got := getBackend(t, r, "cache", ns) - ft := got.Spec.Integration.FirstEventTimeout + ft := got.Spec.Observation.FirstEventTimeout if ft == nil || ft.Duration != 5*time.Minute { t.Fatalf("firstEventTimeout = %v, want defaulted 5m", ft) } @@ -383,11 +391,11 @@ func TestIntegrationKVEventGateAutoReconcileOnPollerWrite(t *testing.T) { if err != nil { t.Fatalf("new manager: %v", err) } - if err := (&CacheBackendReconciler{ + if err := setupTestCacheBackendReconciler(mgr, &CacheBackendReconciler{ Client: mgr.GetClient(), Scheme: mgr.GetScheme(), Log: logr.Discard(), - }).SetupWithManager(mgr); err != nil { + }); err != nil { t.Fatalf("setup with manager: %v", err) } @@ -450,7 +458,7 @@ func TestKVEventGateEmitsTransitionEvents(t *testing.T) { // fires when the first-event window elapses with no event. func TestKVEventGateEmitsNoKVEventsObservedOnTimeout(t *testing.T) { cb := gatedLMCacheBackend("cache", "ns1") - cb.Spec.Integration = &cachev1alpha1.CacheBackendIntegrationSpec{ + cb.Spec.Observation = &cachev1alpha1.CacheBackendObservationSpec{ FirstEventTimeout: &metav1.Duration{Duration: time.Second}, } r, rec := newReconcilerWithRecorder(t, cb) diff --git a/internal/controller/cachebackend_matched_pods_test.go b/internal/controller/cachebackend_matched_pods_test.go index 3e4359b3..bb11687b 100644 --- a/internal/controller/cachebackend_matched_pods_test.go +++ b/internal/controller/cachebackend_matched_pods_test.go @@ -291,6 +291,7 @@ func TestReconcileSchedulesRequeueWhenSelectorRemovedButStatusStillSet(t *testin WithInterceptorFuncs(funcs). Build() r := &CacheBackendReconciler{Client: fc, Scheme: scheme, Log: logr.Discard()} + configureTestRegistries(r) res, err := r.Reconcile(context.Background(), ctrl.Request{ NamespacedName: types.NamespacedName{Name: "cache", Namespace: "ns1"}, @@ -349,6 +350,7 @@ func TestReconcileMatchedEnginePodsWriteOnlyOnChange(t *testing.T) { WithInterceptorFuncs(funcs). Build() r := &CacheBackendReconciler{Client: c, Scheme: scheme, Log: logr.Discard()} + configureTestRegistries(r) // First reconcile establishes status (Endpoint, Conditions, FailOpen) AND // the matchedEnginePods count — both go through SubResourcePatch. @@ -406,6 +408,7 @@ func TestReconcileMatchedEnginePodsFailSoftOnListError(t *testing.T) { WithInterceptorFuncs(funcs). Build() r := &CacheBackendReconciler{Client: fc, Scheme: scheme, Log: logr.Discard()} + configureTestRegistries(r) reconcile(t, r, "cache", "ns1") // no Fatal because reconcile must not surface the list error @@ -459,6 +462,7 @@ func TestReconcileMatchedEnginePodsNoUnmatchedDiagnosticOnDeploymentListError(t Build() rec := events.NewFakeRecorder(16) r := &CacheBackendReconciler{Client: fc, Scheme: scheme, Log: logr.Discard(), Recorder: rec} + configureTestRegistries(r) reconcile(t, r, "cache", "ns1") @@ -530,6 +534,7 @@ func TestReconcileMatchedEnginePodsFailSoftOnStatusPatchError(t *testing.T) { WithInterceptorFuncs(funcs). Build() r := &CacheBackendReconciler{Client: fc, Scheme: scheme, Log: logr.Discard()} + configureTestRegistries(r) // reconcile() t.Fatal's on any reconcile error → if we get here, the // reconciler swallowed the patch error as designed. @@ -617,6 +622,7 @@ func TestReconcileMatchedEnginePodsUsesAPIReaderForPods(t *testing.T) { Log: logr.Discard(), APIReader: apireader, } + configureTestRegistries(r) reconcile(t, r, "cache", "ns1") diff --git a/internal/controller/cachebackend_mooncake_hostnetwork_integration_test.go b/internal/controller/cachebackend_mooncake_hostnetwork_integration_test.go index 59c4b824..003bed7c 100644 --- a/internal/controller/cachebackend_mooncake_hostnetwork_integration_test.go +++ b/internal/controller/cachebackend_mooncake_hostnetwork_integration_test.go @@ -141,8 +141,13 @@ func TestIntegrationMooncakeHostNetworkAndHeadlessService(t *testing.T) { cb := getBackend(t, r, "cache", ns) cb.Spec.Type = cachev1alpha1.CacheBackendTypeLMCache + cb.Spec.RemoteStorage = &cachev1alpha1.CacheBackendRemoteStorageSpec{ + Provider: cachev1alpha1.CacheBackendRemoteStorageProviderLMCacheServer, + Ownership: cachev1alpha1.CacheBackendRemoteStorageOwnershipManaged, + LMCacheServer: &cachev1alpha1.LMCacheServerRemoteStorageSpec{}, + } if err := k8s.Update(ctx, cb); err != nil { - t.Fatalf("switch backend type to LMCache: %v", err) + t.Fatalf("switch backend from Mooncake to LMCache server: %v", err) } reconcile(t, r, "cache", ns) diff --git a/internal/controller/cachebackend_probe.go b/internal/controller/cachebackend_probe.go index 5e6a8fa5..d2213819 100644 --- a/internal/controller/cachebackend_probe.go +++ b/internal/controller/cachebackend_probe.go @@ -359,7 +359,7 @@ func evaluateFunctionalProbe( } // Call the probe. Note: backend = / per the wire - // contract; hashScheme is derived from spec.integration.engine so the + // contract; hashScheme is derived from spec.runtime so the // probe lands under the same engine domain a real KV event from this // backend would; backendType is spec.type so the server's Stage C gate // fires correctly (T2 runs only for LMCache). @@ -585,18 +585,15 @@ func isProbeBypassed(backend *cachev1alpha1.CacheBackend) bool { } // probeHashSchemeForBackend derives the probe's hashScheme from a backend's -// engine setting so the synthesized probe state lives under the same engine -// domain real KV events from this backend would. The CacheBackend CRD's -// spec.integration.engine carries the runtime ID (canonical: "vllm" | -// "sglang"); an empty value falls back to "vllm" matching the CRD -// defaulter. Kept here next to the probe gate rather than in the runtime +// runtime so the synthesized probe state lives under the same engine domain +// real KV events from this backend would. Kept here next to the probe gate rather than in the runtime // adapter package because the probe's identifier scheme is server-contract // (not adapter behavior). func probeHashSchemeForBackend(backend *cachev1alpha1.CacheBackend) string { - if backend == nil || backend.Spec.Integration == nil || strings.TrimSpace(backend.Spec.Integration.Engine) == "" { + if backend == nil || backend.Spec.Runtime == "" { return "vllm" } - return strings.ToLower(strings.TrimSpace(backend.Spec.Integration.Engine)) + return strings.ToLower(string(backend.Spec.Runtime)) } // truncateMessage caps a diagnostic string at a length that fits cleanly in diff --git a/internal/controller/cachebackend_probe_integration_test.go b/internal/controller/cachebackend_probe_integration_test.go index 4f1c1eef..d686bcc8 100644 --- a/internal/controller/cachebackend_probe_integration_test.go +++ b/internal/controller/cachebackend_probe_integration_test.go @@ -245,13 +245,19 @@ func TestIntegrationFunctionalProbeGate(t *testing.T) { t.Fatalf("FunctionalProbeOK should be present before cleanup") } - // Flip to External so the reconciler runs reconcileExternal → which - // invokes cleanupOwnedWorkload → the conditions-clear branch. + // Flip to canonical external ownership so the reconciler runs + // reconcileExternal → which invokes cleanupOwnedWorkload → the + // conditions-clear branch. before := cb.DeepCopy() - cb.Spec.Type = cachev1alpha1.CacheBackendTypeExternal - cb.Spec.Endpoint = "lm://test.example.com:9999" + cb.Spec.Runtime = cachev1alpha1.CacheBackendRuntimeVLLM + cb.Spec.Type = cachev1alpha1.CacheBackendTypeLMCache + cb.Spec.RemoteStorage = &cachev1alpha1.CacheBackendRemoteStorageSpec{ + Provider: cachev1alpha1.CacheBackendRemoteStorageProviderLMCacheServer, + Ownership: cachev1alpha1.CacheBackendRemoteStorageOwnershipExternal, + Endpoint: "lm://test.example.com:9999", + } if err := k8s.Patch(ctx, cb, client.MergeFrom(before)); err != nil { - t.Fatalf("patch to External: %v", err) + t.Fatalf("patch to external ownership: %v", err) } reconcile(t, r, "cache", ns) diff --git a/internal/controller/cachebackend_probe_test.go b/internal/controller/cachebackend_probe_test.go index 881cd0df..f1fb21de 100644 --- a/internal/controller/cachebackend_probe_test.go +++ b/internal/controller/cachebackend_probe_test.go @@ -36,8 +36,8 @@ func newProbeBackend(name string) *cachev1alpha1.CacheBackend { return &cachev1alpha1.CacheBackend{ ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: "team-a"}, Spec: cachev1alpha1.CacheBackendSpec{ - Type: cachev1alpha1.CacheBackendTypeLMCache, - Integration: &cachev1alpha1.CacheBackendIntegrationSpec{Engine: "vllm"}, + Runtime: cachev1alpha1.CacheBackendRuntimeVLLM, + Type: cachev1alpha1.CacheBackendTypeLMCache, }, } } @@ -574,7 +574,7 @@ func TestEvaluateFunctionalProbeRequestShape(t *testing.T) { client := &ProbeClient{ProbeURL: srv.URL, HTTPClient: srv.Client()} backend := newProbeBackend("cb-1") - backend.Spec.Integration.Engine = "SGLang" // mixed-case → lowercased on the wire + backend.Spec.Runtime = cachev1alpha1.CacheBackendRuntimeSGLang // mixed-case → lowercased on the wire _ = evaluateFunctionalProbe(context.Background(), backend, kvReadinessTrue, client, &probeRateLimiter{}, time.Second, time.Now()) @@ -603,7 +603,6 @@ func TestProbeHashSchemeForBackendDefaults(t *testing.T) { }{ {"nil integration", nil, "vllm"}, {"empty engine", stringPtr(""), "vllm"}, - {"whitespace engine", stringPtr(" "), "vllm"}, {"vllm lowercase", stringPtr("vllm"), "vllm"}, {"VLLM uppercase", stringPtr("VLLM"), "vllm"}, {"sglang mixed", stringPtr("SGLang"), "sglang"}, @@ -612,9 +611,9 @@ func TestProbeHashSchemeForBackendDefaults(t *testing.T) { t.Run(tc.name, func(t *testing.T) { backend := newProbeBackend("cb") if tc.engine == nil { - backend.Spec.Integration = nil + backend.Spec.Runtime = "" } else { - backend.Spec.Integration = &cachev1alpha1.CacheBackendIntegrationSpec{Engine: *tc.engine} + backend.Spec.Runtime = cachev1alpha1.CacheBackendRuntime(*tc.engine) } if got := probeHashSchemeForBackend(backend); got != tc.want { t.Errorf("got %q, want %q", got, tc.want) diff --git a/internal/controller/cachebackend_resources_integration_test.go b/internal/controller/cachebackend_resources_integration_test.go index 656bc53d..e64c2846 100644 --- a/internal/controller/cachebackend_resources_integration_test.go +++ b/internal/controller/cachebackend_resources_integration_test.go @@ -11,16 +11,16 @@ import ( cachev1alpha1 "github.com/cachebox-project/inference-cache/api/v1alpha1" ) -// TestIntegrationCacheBackendResources exercises canonical provider resources +// TestIntegrationCacheBackendResources exercises provider resources // end-to-end against a real apiserver. A managed provider with no explicit -// resource block receives bounded renderer defaults without persisting the -// deprecated top-level spec.resources field, while an operator-supplied typed -// resource block is threaded verbatim into the rendered Deployment container. +// resource block receives bounded renderer defaults without persisting those +// defaults, while an operator-supplied typed resource block is threaded verbatim +// into the rendered Deployment container. // // The unit-level renderer test -// (TestVLLMLMCacheResolveCacheServerHonorsSpecResources) constructs -// CacheBackend objects directly; this test also guards the persisted canonical -// API shape after real-apiserver admission. +// (TestVLLMLMCacheResolveCacheServerHonorsProviderResources) constructs +// CacheBackend objects directly; this test also guards the persisted API shape +// after real-apiserver admission. func TestIntegrationCacheBackendResources(t *testing.T) { skipWithoutEnvtest(t) k8s, scheme, _ := startEnv(t) @@ -38,9 +38,9 @@ func TestIntegrationCacheBackendResources(t *testing.T) { return cb } - t.Run("CanonicalDefaultBoundsProviderWithoutPersistingLegacyResources", func(t *testing.T) { - // Canonical provider defaults belong to the renderer, not deprecated - // spec.resources. The child container MUST still carry memory requests + t.Run("DefaultBoundsProviderWithoutPersistingResources", func(t *testing.T) { + // Provider defaults belong to the renderer, not the persisted API. + // The child container MUST still carry memory requests // and limits so heavy T2 write load cannot leave it unbounded. ns := freshNS(t, k8s) if err := k8s.Create(ctx, newCanonicalBackend(ns)); err != nil { @@ -49,9 +49,6 @@ func TestIntegrationCacheBackendResources(t *testing.T) { reconcile(t, r, "cache", ns) cb := getBackend(t, r, "cache", ns) - if cb.Spec.Resources != nil { - t.Fatalf("deprecated spec.resources persisted on canonical backend: %+v", cb.Spec.Resources) - } if cb.Spec.RemoteStorage.LMCacheServer.Resources != nil { t.Fatalf("renderer default leaked into spec.remoteStorage.lmCacheServer.resources: %+v", cb.Spec.RemoteStorage.LMCacheServer.Resources) diff --git a/internal/controller/cachebackend_schema_trim_integration_test.go b/internal/controller/cachebackend_schema_trim_integration_test.go index acc6f034..39acaf0b 100644 --- a/internal/controller/cachebackend_schema_trim_integration_test.go +++ b/internal/controller/cachebackend_schema_trim_integration_test.go @@ -4,6 +4,7 @@ import ( "context" "testing" + apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "sigs.k8s.io/controller-runtime/pkg/client" @@ -37,10 +38,10 @@ func TestIntegrationCacheBackendSchemaTrim(t *testing.T) { trimmed := &cachev1alpha1.CacheBackend{ ObjectMeta: metav1.ObjectMeta{Name: "trimmed", Namespace: ns}, Spec: cachev1alpha1.CacheBackendSpec{ - Type: cachev1alpha1.CacheBackendTypeLMCache, + Runtime: cachev1alpha1.CacheBackendRuntimeVLLM, + Type: cachev1alpha1.CacheBackendTypeLMCache, Integration: &cachev1alpha1.CacheBackendIntegrationSpec{ - Engine: "vllm", - Role: cachev1alpha1.CacheBackendIntegrationRoleReadWrite, + Role: cachev1alpha1.CacheBackendIntegrationRoleReadWrite, }, EngineSelector: &cachev1alpha1.CacheBackendEngineSelector{ MatchLabels: map[string]string{"app.kubernetes.io/name": "vllm"}, @@ -64,6 +65,9 @@ func TestIntegrationCacheBackendSchemaTrim(t *testing.T) { u.SetKind("CacheBackend") u.SetNamespace(ns) u.SetName(name) + if err := unstructured.SetNestedField(u.Object, "VLLM", "spec", "runtime"); err != nil { + t.Fatalf("set spec.runtime: %v", err) + } if err := unstructured.SetNestedField(u.Object, "LMCache", "spec", "type"); err != nil { t.Fatalf("set spec.type: %v", err) } @@ -82,6 +86,27 @@ func TestIntegrationCacheBackendSchemaTrim(t *testing.T) { return got } + for _, tc := range []struct { + name string + backendType string + }{ + {name: "legacy-mooncake", backendType: "Mooncake"}, + {name: "legacy-external", backendType: "External"}, + {name: "unsupported-aibrix", backendType: "AIBrix"}, + {name: "unsupported-nixl", backendType: "NIXL"}, + {name: "unknown", backendType: "Unknown"}, + } { + t.Run("reject-type-"+tc.name, func(t *testing.T) { + u := newManaged("reject-type-" + tc.name) + if err := unstructured.SetNestedField(u.Object, tc.backendType, "spec", "type"); err != nil { + t.Fatalf("set spec.type: %v", err) + } + if err := c.Create(ctx, u); !apierrors.IsInvalid(err) { + t.Fatalf("create with spec.type=%q error = %v, want Invalid from CRD enum", tc.backendType, err) + } + }) + } + // Removed spec fields are pruned on create and never round-trip. obj is a // separate RFC-1123 object name (the field name is mixed-case and cannot be // used as metadata.name). diff --git a/internal/controller/cachebackend_t2degraded_test.go b/internal/controller/cachebackend_t2degraded_test.go index d3dd4fb0..d17aabd4 100644 --- a/internal/controller/cachebackend_t2degraded_test.go +++ b/internal/controller/cachebackend_t2degraded_test.go @@ -108,8 +108,9 @@ func TestIntegrationT2DegradedCondition(t *testing.T) { if err := k8s.Get(ctx, types.NamespacedName{Name: "cache", Namespace: ns}, &cb); err != nil { t.Fatalf("get: %v", err) } - cb.Spec.Type = cachev1alpha1.CacheBackendTypeExternal - cb.Spec.Endpoint = "shared.svc.cluster.local:9000" + cb.Spec.Runtime = cachev1alpha1.CacheBackendRuntimeVLLM + cb.Spec.Type = cachev1alpha1.CacheBackendTypeLMCache + cb.Spec.RemoteStorage = externalLMCacheStorage("shared.svc.cluster.local:9000") if err := k8s.Update(ctx, &cb); err != nil { t.Fatalf("flip to External: %v", err) } diff --git a/internal/controller/contract_coverage_sweep_test.go b/internal/controller/contract_coverage_sweep_test.go index 9dfc123c..09bd9986 100644 --- a/internal/controller/contract_coverage_sweep_test.go +++ b/internal/controller/contract_coverage_sweep_test.go @@ -28,6 +28,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/webhook/admission" cachev1alpha1 "github.com/cachebox-project/inference-cache/api/v1alpha1" + builtinadapters "github.com/cachebox-project/inference-cache/internal/adapters/builtin" podwebhook "github.com/cachebox-project/inference-cache/internal/webhook/pod" adapterruntime "github.com/cachebox-project/inference-cache/pkg/adapters/runtime" "github.com/cachebox-project/inference-cache/pkg/index" @@ -310,10 +311,15 @@ func readyCacheBackendForSweep(name, namespace string, selector map[string]strin UID: types.UID("cb-" + namespace + "-" + name + "-uid"), }, Spec: cachev1alpha1.CacheBackendSpec{ - Type: cachev1alpha1.CacheBackendTypeLMCache, + Runtime: cachev1alpha1.CacheBackendRuntimeVLLM, + Type: cachev1alpha1.CacheBackendTypeLMCache, + RemoteStorage: &cachev1alpha1.CacheBackendRemoteStorageSpec{ + Provider: cachev1alpha1.CacheBackendRemoteStorageProviderLMCacheServer, + Ownership: cachev1alpha1.CacheBackendRemoteStorageOwnershipManaged, + LMCacheServer: &cachev1alpha1.LMCacheServerRemoteStorageSpec{}, + }, Integration: &cachev1alpha1.CacheBackendIntegrationSpec{ - Engine: "vllm", - Role: cachev1alpha1.CacheBackendIntegrationRoleReadWrite, + Role: cachev1alpha1.CacheBackendIntegrationRoleReadWrite, }, EngineSelector: &cachev1alpha1.CacheBackendEngineSelector{MatchLabels: selector}, }, @@ -340,7 +346,8 @@ func runPodWebhookAndCaptureInjectedBy(t *testing.T, namespace string, t.Fatalf("cachev1alpha1.AddToScheme: %v", err) } c := fake.NewClientBuilder().WithScheme(scheme).WithObjects(cb1, cb2).Build() - h := &podwebhook.EngineInjector{Reader: c, Log: logr.Discard()} + registries := builtinadapters.New() + h := &podwebhook.EngineInjector{Reader: c, Registry: registries.Runtime, Log: logr.Discard()} pod := &corev1.Pod{ ObjectMeta: metav1.ObjectMeta{Name: "engine-a", Labels: podLabels}, diff --git a/internal/controller/integration_test.go b/internal/controller/integration_test.go index 4cd4dce8..52db5f10 100644 --- a/internal/controller/integration_test.go +++ b/internal/controller/integration_test.go @@ -225,9 +225,9 @@ func TestIntegrationCacheBackendReconcile(t *testing.T) { }) t.Run("MooncakeMasterWorkloadShape", func(t *testing.T) { - // Mooncake reconcile contract against a real apiserver: type=Mooncake - // reconciles into a mooncake_master Deployment + Service via the - // Mooncake adapter, and status.endpoint resolves the master's RPC port. + // Mooncake provider contract against a real apiserver: the canonical + // remote binding reconciles into a mooncake_master Deployment + Service, + // and status.endpoint resolves the master's RPC port. ns := freshNS(t, k8s) cb := mooncakeBackend("cache", ns) if err := k8s.Create(ctx, cb); err != nil { @@ -405,7 +405,7 @@ func TestIntegrationCacheBackendReconcile(t *testing.T) { t.Run("ServerImageOverrideAndUpdate", func(t *testing.T) { ns := freshNS(t, k8s) cb := lmcacheBackend("cache", ns) - cb.Spec.BackendConfig = map[string]string{"serverImage": "example.com/lmcache-server:v1"} + cb.Spec.RemoteStorage.LMCacheServer.Image = "example.com/lmcache-server:v1" if err := k8s.Create(ctx, cb); err != nil { t.Fatalf("create: %v", err) } @@ -415,7 +415,7 @@ func TestIntegrationCacheBackendReconcile(t *testing.T) { } live := getBackend(t, r, "cache", ns) - live.Spec.BackendConfig["serverImage"] = "example.com/lmcache-server:v2" + live.Spec.RemoteStorage.LMCacheServer.Image = "example.com/lmcache-server:v2" if err := k8s.Update(ctx, live); err != nil { t.Fatalf("update image: %v", err) } @@ -584,8 +584,9 @@ func TestIntegrationCacheBackendReconcile(t *testing.T) { } live := getBackend(t, r, "cache", ns) - live.Spec.Type = cachev1alpha1.CacheBackendTypeExternal - live.Spec.Endpoint = "external.example.svc:8080" + live.Spec.Runtime = cachev1alpha1.CacheBackendRuntimeVLLM + live.Spec.Type = cachev1alpha1.CacheBackendTypeLMCache + live.Spec.RemoteStorage = externalLMCacheStorage("external.example.svc:8080") if err := k8s.Update(ctx, live); err != nil { t.Fatalf("switch to external: %v", err) } @@ -600,8 +601,8 @@ func TestIntegrationCacheBackendReconcile(t *testing.T) { } // After the switch to External the controller publishes // Ready=True with reason ExternalEndpointAccepted — admission - // acceptance of spec.endpoint is the only readiness signal we - // have without provisioning a Service to probe. + // acceptance of spec.remoteStorage.endpoint is the only readiness + // signal we have without provisioning a Service to probe. ready := findCondition(cb.Status.Conditions, conditionTypeReady) if ready == nil { t.Fatalf("Ready condition missing after switch to External; conditions = %v", cb.Status.Conditions) @@ -612,10 +613,10 @@ func TestIntegrationCacheBackendReconcile(t *testing.T) { }) t.Run("ExternalCreateProducesNoWorkloadAndReady", func(t *testing.T) { - // A CacheBackend{type: External} reconciled against a real + // A CacheBackend with externally owned remote storage reconciled against a real // apiserver must (a) leave the CR's namespace free of any // controller-rendered Deployment or Service, (b) mirror - // spec.endpoint into status.endpoint verbatim, and (c) publish + // spec.remoteStorage.endpoint into status.endpoint verbatim, and (c) publish // Ready=True with reason ExternalEndpointAccepted so downstream // consumers (the future readiness gate, the indexParticipation // poller) treat the CR as usable. @@ -623,8 +624,9 @@ func TestIntegrationCacheBackendReconcile(t *testing.T) { cb := &cachev1alpha1.CacheBackend{ ObjectMeta: metav1.ObjectMeta{Name: "ext-fresh", Namespace: ns}, Spec: cachev1alpha1.CacheBackendSpec{ - Type: cachev1alpha1.CacheBackendTypeExternal, - Endpoint: "lm://my-cache.example:8200", + Runtime: cachev1alpha1.CacheBackendRuntimeVLLM, + Type: cachev1alpha1.CacheBackendTypeLMCache, + RemoteStorage: externalLMCacheStorage("lm://my-cache.example:8200"), }, } if err := k8s.Create(ctx, cb); err != nil { @@ -661,7 +663,11 @@ func TestIntegrationCacheBackendReconcile(t *testing.T) { ns := freshNS(t, k8s) cb := &cachev1alpha1.CacheBackend{ ObjectMeta: metav1.ObjectMeta{Name: "ext", Namespace: ns}, - Spec: cachev1alpha1.CacheBackendSpec{Type: cachev1alpha1.CacheBackendTypeExternal, Endpoint: "ext.example.svc:8080"}, + Spec: cachev1alpha1.CacheBackendSpec{ + Runtime: cachev1alpha1.CacheBackendRuntimeVLLM, + Type: cachev1alpha1.CacheBackendTypeLMCache, + RemoteStorage: externalLMCacheStorage("ext.example.svc:8080"), + }, } if err := k8s.Create(ctx, cb); err != nil { t.Fatalf("create: %v", err) @@ -676,14 +682,17 @@ func TestIntegrationCacheBackendReconcile(t *testing.T) { } }) - t.Run("UnmanagedTypeNoWorkload", func(t *testing.T) { + t.Run("UnsupportedPairNoWorkload", func(t *testing.T) { ns := freshNS(t, k8s) - // AIBrix has no registered adapter → unmanaged path. (Mooncake now - // has an adapter and reconciles managed — see the Managed Mooncake - // integration subtest.) + // Both values satisfy the CRD enums, but the built-in registry has no + // vLLM+SGLangHiCache adapter. This exercises the reconciler's unmanaged + // defense-in-depth path when the validating webhook is bypassed. cb := &cachev1alpha1.CacheBackend{ ObjectMeta: metav1.ObjectMeta{Name: "mc", Namespace: ns}, - Spec: cachev1alpha1.CacheBackendSpec{Type: cachev1alpha1.CacheBackendTypeAIBrix}, + Spec: cachev1alpha1.CacheBackendSpec{ + Runtime: cachev1alpha1.CacheBackendRuntimeVLLM, + Type: cachev1alpha1.CacheBackendTypeSGLangHiCache, + }, } if err := k8s.Create(ctx, cb); err != nil { t.Fatalf("create: %v", err) @@ -749,11 +758,10 @@ func TestIntegrationCacheBackendReconcile(t *testing.T) { } }) - t.Run("EngineNameCaseInsensitiveRouting", func(t *testing.T) { + t.Run("CanonicalRuntimeRouting", func(t *testing.T) { ns := freshNS(t, k8s) - // Upper-case engine name still routes to the vllm adapter. up := lmcacheBackend("up", ns) - up.Spec.Integration = &cachev1alpha1.CacheBackendIntegrationSpec{Engine: "VLLM"} + up.Spec.Runtime = cachev1alpha1.CacheBackendRuntimeVLLM if err := k8s.Create(ctx, up); err != nil { t.Fatalf("create VLLM: %v", err) } @@ -762,12 +770,17 @@ func TestIntegrationCacheBackendReconcile(t *testing.T) { t.Fatalf("VLLM (uppercase) should match the vllm adapter and produce a Deployment: %v", err) } - // sglang now has a shipping adapter (the SGLang+LMCache adapter is in - // the reconciler's nil-registry fallback), so a (sglang, LMCache) - // backend is managed the same way vLLM is — it renders the standalone - // lmcache-server Deployment. + // SGLang+LMCache is a shipping adapter. Pair it with managed Redis, + // the remote-storage protocol that the adapter accepts, and verify the + // composed registries render its managed storage Deployment. sg := lmcacheBackend("sg", ns) - sg.Spec.Integration = &cachev1alpha1.CacheBackendIntegrationSpec{Engine: "sglang"} + sg.Spec.Runtime = cachev1alpha1.CacheBackendRuntimeSGLang + sg.Spec.RemoteStorage = &cachev1alpha1.CacheBackendRemoteStorageSpec{ + Provider: cachev1alpha1.CacheBackendRemoteStorageProviderRedis, + Ownership: cachev1alpha1.CacheBackendRemoteStorageOwnershipManaged, + Redis: &cachev1alpha1.RedisRemoteStorageSpec{}, + } + sg.Spec.Integration = &cachev1alpha1.CacheBackendIntegrationSpec{} if err := k8s.Create(ctx, sg); err != nil { t.Fatalf("create sglang: %v", err) } @@ -776,16 +789,6 @@ func TestIntegrationCacheBackendReconcile(t *testing.T) { t.Fatalf("sglang now has a shipping adapter; expected a managed Deployment: %v", err) } - // An engine with no registered adapter still falls into the unmanaged path. - unk := lmcacheBackend("unk", ns) - unk.Spec.Integration = &cachev1alpha1.CacheBackendIntegrationSpec{Engine: "no-such-engine"} - if err := k8s.Create(ctx, unk); err != nil { - t.Fatalf("create unknown-engine: %v", err) - } - reconcile(t, r, "unk", ns) - if _, err := getOptionalDeployment(t, r, "unk", ns); err == nil { - t.Fatalf("unknown engine has no adapter; expected no Deployment (unmanaged path)") - } }) t.Run("MissingObjectIsNoError", func(t *testing.T) { @@ -840,7 +843,8 @@ func TestIntegrationEnginePodEvents(t *testing.T) { cb := &cachev1alpha1.CacheBackend{ ObjectMeta: metav1.ObjectMeta{Name: "primary", Namespace: ns}, Spec: cachev1alpha1.CacheBackendSpec{ - Type: cachev1alpha1.CacheBackendTypeLMCache, + Runtime: cachev1alpha1.CacheBackendRuntimeVLLM, + Type: cachev1alpha1.CacheBackendTypeLMCache, }, } if err := k8s.Create(context.Background(), cb); err != nil { @@ -983,13 +987,13 @@ func TestIntegrationCacheBackendMatchedEnginePodsRequeueCadence(t *testing.T) { // without a Pod watch. const steadyRequeueInterval = 30 * time.Second const churnRequeueInterval = 250 * time.Millisecond - if err := (&CacheBackendReconciler{ + if err := setupTestCacheBackendReconciler(mgr, &CacheBackendReconciler{ Client: mgr.GetClient(), Scheme: mgr.GetScheme(), Log: logr.Discard(), MatchedEnginePodsRequeueInterval: steadyRequeueInterval, MatchedEnginePodsChurnRequeueInterval: churnRequeueInterval, - }).SetupWithManager(mgr); err != nil { + }); err != nil { t.Fatalf("setup with manager: %v", err) } @@ -1089,11 +1093,11 @@ func TestIntegrationCacheBackendEngineSelectorUnmatchedDiagnostics(t *testing.T) if err != nil { t.Fatalf("new manager: %v", err) } - if err := (&CacheBackendReconciler{ + if err := setupTestCacheBackendReconciler(mgr, &CacheBackendReconciler{ Client: mgr.GetClient(), Scheme: mgr.GetScheme(), Log: logr.Discard(), - }).SetupWithManager(mgr); err != nil { + }); err != nil { t.Fatalf("setup with manager: %v", err) } @@ -1263,11 +1267,11 @@ func TestIntegrationCacheBackendWatch(t *testing.T) { } }, } - if err := (&CacheBackendReconciler{ + if err := setupTestCacheBackendReconciler(mgr, &CacheBackendReconciler{ Client: observedClient, Scheme: mgr.GetScheme(), Log: logr.Discard(), - }).SetupWithManager(mgr); err != nil { + }); err != nil { t.Fatalf("setup with manager: %v", err) } @@ -1402,10 +1406,10 @@ func TestIntegrationCacheBackendWatch(t *testing.T) { t.Fatalf("get CacheBackend before drain update: %v", err) } beforeGeneration := live.Generation - if live.Spec.BackendConfig == nil { - live.Spec.BackendConfig = map[string]string{} + if live.Spec.Observation == nil { + live.Spec.Observation = &cachev1alpha1.CacheBackendObservationSpec{} } - live.Spec.BackendConfig["testDrain"] = time.Now().Format(time.RFC3339Nano) + live.Spec.Observation.ModelID = "test-drain-" + time.Now().Format(time.RFC3339Nano) if err := k8s.Update(context.Background(), &live); err != nil { t.Fatalf("update CacheBackend to drain initial queue: %v", err) } @@ -1451,14 +1455,15 @@ func TestIntegrationCacheIndexPollerProjectsParticipation(t *testing.T) { ns := freshNS(t, k8s) // Seed two CacheBackends with EngineSelectors plus an engine pod each. - // External type keeps the CacheBackend reconciler out of the picture — + // External ownership avoids managed child provisioning in this fixture — // we are testing the poller's Status().Patch in isolation. mkBackend := func(name string, selector map[string]string) *cachev1alpha1.CacheBackend { return &cachev1alpha1.CacheBackend{ ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: ns}, Spec: cachev1alpha1.CacheBackendSpec{ - Type: cachev1alpha1.CacheBackendTypeExternal, - Endpoint: "external.example:6379", + Runtime: cachev1alpha1.CacheBackendRuntimeVLLM, + Type: cachev1alpha1.CacheBackendTypeLMCache, + RemoteStorage: externalLMCacheStorage("external.example:6379"), EngineSelector: &cachev1alpha1.CacheBackendEngineSelector{MatchLabels: selector}, }, } @@ -1585,14 +1590,15 @@ func TestIntegrationCacheBackendPrinterColumnsRenderParticipation(t *testing.T) ns := freshNS(t, k8s) // Two backends: one with positive participation, one drained-but-quiet. - // External type keeps the CacheBackend reconciler out of the picture so + // External ownership avoids managed child provisioning in this fixture so // we are testing the printer-column projection from status, not the // reconciler. active := &cachev1alpha1.CacheBackend{ ObjectMeta: metav1.ObjectMeta{Name: "backend-a", Namespace: ns}, Spec: cachev1alpha1.CacheBackendSpec{ - Type: cachev1alpha1.CacheBackendTypeExternal, - Endpoint: "lm://cache-svc:6379", + Runtime: cachev1alpha1.CacheBackendRuntimeVLLM, + Type: cachev1alpha1.CacheBackendTypeLMCache, + RemoteStorage: externalLMCacheStorage("lm://cache-svc:6379"), }, } if err := k8s.Create(ctx, active); err != nil { @@ -1601,8 +1607,9 @@ func TestIntegrationCacheBackendPrinterColumnsRenderParticipation(t *testing.T) quiet := &cachev1alpha1.CacheBackend{ ObjectMeta: metav1.ObjectMeta{Name: "backend-b", Namespace: ns}, Spec: cachev1alpha1.CacheBackendSpec{ - Type: cachev1alpha1.CacheBackendTypeExternal, - Endpoint: "lm://cache-svc:6379", + Runtime: cachev1alpha1.CacheBackendRuntimeVLLM, + Type: cachev1alpha1.CacheBackendTypeLMCache, + RemoteStorage: externalLMCacheStorage("lm://cache-svc:6379"), }, } if err := k8s.Create(ctx, quiet); err != nil { @@ -1734,11 +1741,11 @@ func TestIntegrationCacheBackendEvents(t *testing.T) { if err != nil { t.Fatalf("new manager: %v", err) } - if err := (&CacheBackendReconciler{ + if err := setupTestCacheBackendReconciler(mgr, &CacheBackendReconciler{ Client: mgr.GetClient(), Scheme: mgr.GetScheme(), Log: logr.Discard(), - }).SetupWithManager(mgr); err != nil { + }); err != nil { t.Fatalf("setup with manager: %v", err) } diff --git a/internal/webhook/pod/doc.go b/internal/webhook/pod/doc.go index 350336cf..5809236a 100644 --- a/internal/webhook/pod/doc.go +++ b/internal/webhook/pod/doc.go @@ -1,21 +1,16 @@ // Package pod is the controller-owned mutating admission webhook that auto- // wires user-provided inference engine pods to a matching cache backend — -// either a managed backend the controller provisions (LMCache today) or an -// External backend whose lifecycle the operator owns. +// either a managed provider or an externally owned remote binding. // // On every Pod admission the handler: // 1. lists CacheBackends in the pod's namespace; // 2. picks the first whose Spec.EngineSelector.MatchLabels match the pod; // 3. resolves a runtime adapter from the controller's runtime.Registry; -// 4. resolves the cache endpoint type-scoped (see [effectiveEndpoint]): -// trimmed Spec.Endpoint for External CRs (authoritative; preferred -// over Status.Endpoint so a pod admitting between an operator -// spec.endpoint edit and the reconciler's mirror is wired to the -// fresh address, not the stale one), Status.Endpoint for managed -// types (the reconciler builds it from the live Service; spec.endpoint -// is admission-rejected on managed types). Endpoint-free adapters such +// 4. resolves the cache endpoint from Spec.RemoteStorage.Endpoint for +// externally owned storage or Status.Endpoint for managed providers. +// Endpoint-free adapters such // as native SGLang HiCache bypass this gate; and -// 5. calls adapter.InjectEngineConfig(pod.Spec, endpoint, cache) to merge +// 5. calls adapter.InjectEngineConfig(pod.Spec, binding, cache) to merge // the cache-server endpoint + connector env/args into the pod spec. // // The webhook fails open: any error (no matching CacheBackend, no usable diff --git a/internal/webhook/pod/envtest_integration_test.go b/internal/webhook/pod/envtest_integration_test.go index a7f3771c..a425d81b 100644 --- a/internal/webhook/pod/envtest_integration_test.go +++ b/internal/webhook/pod/envtest_integration_test.go @@ -96,7 +96,7 @@ func TestWebhookOnEnvtest_EndToEnd(t *testing.T) { mgr.GetWebhookServer().Register(WebhookPath, &webhook.Admission{ Handler: &EngineInjector{ Reader: mgr.GetAPIReader(), - Registry: adapterruntime.NewCoreRegistry( + Registry: newVLLMRegistry( adapterruntime.WithSubscriberImage(adapterruntime.DefaultSubscriberImage), ), }, @@ -133,18 +133,23 @@ func TestWebhookOnEnvtest_EndToEnd(t *testing.T) { cb := &cachev1alpha1.CacheBackend{ ObjectMeta: metav1.ObjectMeta{Name: "envtest-cb", Namespace: ns}, Spec: cachev1alpha1.CacheBackendSpec{ - Type: cachev1alpha1.CacheBackendTypeLMCache, + Runtime: cachev1alpha1.CacheBackendRuntimeVLLM, + Type: cachev1alpha1.CacheBackendTypeLMCache, + RemoteStorage: &cachev1alpha1.CacheBackendRemoteStorageSpec{ + Provider: cachev1alpha1.CacheBackendRemoteStorageProviderLMCacheServer, + Ownership: cachev1alpha1.CacheBackendRemoteStorageOwnershipManaged, + LMCacheServer: &cachev1alpha1.LMCacheServerRemoteStorageSpec{}, + }, Integration: &cachev1alpha1.CacheBackendIntegrationSpec{ - Engine: "vllm", - Role: cachev1alpha1.CacheBackendIntegrationRoleReadWrite, + Role: cachev1alpha1.CacheBackendIntegrationRoleReadWrite, }, EngineSelector: &cachev1alpha1.CacheBackendEngineSelector{ MatchLabels: map[string]string{"app": "vllm-test"}, }, - // backendConfig.model is the source of the + // observation.modelID is the source of the // subscriber sidecar's --model-id flag. Set it here so // the auto-attach assertion below has something to match. - BackendConfig: map[string]string{"model": modelID}, + Observation: &cachev1alpha1.CacheBackendObservationSpec{ModelID: modelID}, }, } if err := mgr.GetClient().Create(ctx, cb); err != nil { diff --git a/internal/webhook/pod/podinjector.go b/internal/webhook/pod/podinjector.go index e1756fee..4123cae6 100644 --- a/internal/webhook/pod/podinjector.go +++ b/internal/webhook/pod/podinjector.go @@ -15,7 +15,6 @@ import ( "sigs.k8s.io/controller-runtime/pkg/webhook/admission" cachev1alpha1 "github.com/cachebox-project/inference-cache/api/v1alpha1" - builtinadapters "github.com/cachebox-project/inference-cache/internal/adapters/builtin" "github.com/cachebox-project/inference-cache/internal/enginebinding" backendadapter "github.com/cachebox-project/inference-cache/pkg/adapters/backend" adapterruntime "github.com/cachebox-project/inference-cache/pkg/adapters/runtime" @@ -97,10 +96,8 @@ type EngineInjector struct { Reader client.Reader // Registry resolves the runtime adapter for a (runtime, backend) pair. - // nil falls back to the complete internal/adapters/builtin composition. - // Mirrors the production cmd/controller wiring so a bare `EngineInjector{}` - // doesn't silently fail-open on External CRs that the running webhook - // would have wired. + // The composition root injects the complete shipping registry. A nil value + // is treated as a webhook misconfiguration and fails open. Registry *adapterruntime.Registry // Log is the handler's logger. nil falls back to logf.FromContext at @@ -161,7 +158,8 @@ func (h *EngineInjector) Handle(ctx context.Context, req admission.Request) admi runtimeID := adapterruntime.ResolveRuntimeID(cache) registry := h.Registry if registry == nil { - registry = builtinadapters.New().Runtime + log.Error(fmt.Errorf("runtime adapter registry is not configured"), "fail-open: webhook is not configured") + return failOpen(req, &pod, "runtime adapter registry is not configured (fail-open)") } adapter, err := registry.Select(runtimeID, cache) if err != nil { @@ -179,25 +177,30 @@ func (h *EngineInjector) Handle(ctx context.Context, req admission.Request) admi return failOpen(req, &pod, fmt.Sprintf("unsupported remote-storage provider (fail-open): %v", protocolErr)) } binding := backendadapter.BindingFor(storage, protocol, endpoint) + if !adapter.SupportsBinding(binding) { + log.V(1).Info("fail-open: runtime adapter rejected remote-storage binding", + "runtime", string(runtimeID), "protocol", string(protocol)) + return failOpen(req, &pod, fmt.Sprintf("adapter does not support remote-storage protocol %q (fail-open)", protocol)) + } // Events-only (tier-1 routing) backends provision no server, so they publish // no endpoint — and they wire no KV connector, so they need none. The // endpoint gate exists ONLY because the connector requires a dial target // (an empty/malformed LMCACHE_REMOTE_URL crashes the engine at startup); an // events-only pod injects only the observation sidecar (InjectEngineConfig // is a no-op in this mode), so bypass the gate and inject without one. - // Engine-local adapters such as native SGLang HiCache also bypass this - // gate through the optional EndpointRequirement capability. - if endpoint == "" && !cache.Spec.IsEventsOnly() && adapterruntime.AdapterRequiresEndpointFor(adapter, binding) { - // The endpoint source is type-scoped (see effectiveEndpoint). + // Engine-local adapters such as native SGLang HiCache have a nil binding and + // therefore bypass this network-endpoint gate. + if binding != nil && binding.Endpoint == "" && !cache.Spec.IsEventsOnly() { + // The endpoint source is ownership-scoped (see effectiveEndpoint). // Three reasons we can land here: // - managed CR: reconciler hasn't published status.endpoint // yet (steady-state during initial rollout). - // - External CR: its spec endpoint is empty (admission rejects - // this on fresh CRs; only reachable from a pre-existing - // stored value). - // - External CR: its endpoint fails the selected provider's - // shared shape check (also pre-existing-only; current admission - // rejects malformed values). effectiveEndpoint deliberately + // - externally owned CR: spec.remoteStorage.endpoint is empty + // (current admission rejects this; reachable only for objects that + // bypassed admission). + // - externally owned CR: its endpoint fails the selected provider's + // shared shape check (current admission rejects malformed values). + // effectiveEndpoint deliberately // returns "" for this case so the engine pod admits // un-wired rather than receiving an endpoint its connector // refuses at startup. @@ -211,9 +214,6 @@ func (h *EngineInjector) Handle(ctx context.Context, req admission.Request) admi extra := "" if storage != nil && storage.Ownership == cachev1alpha1.CacheBackendRemoteStorageOwnershipExternal { missingField = "spec.remoteStorage.endpoint" - if !cache.Spec.UsesCanonicalCacheHierarchy() { - missingField = "spec.endpoint" - } if err := adapterruntime.ValidateExternalEndpoint(storage.Provider, storage.Endpoint); err != nil { extra = ": " + err.Error() } @@ -259,14 +259,14 @@ func (h *EngineInjector) Handle(ctx context.Context, req admission.Request) admi // model's KV-cache manager is not disabled by a connector it cannot load. // Skip InjectEngineConfig here, at the webhook, rather than relying on each // adapter's own no-op: the connector no-op currently lives ONLY in the - // vLLM+LMCache adapter, so an admission-bypassed spec.type=External + - // mode=EventsOnly object would otherwise select the External adapter and - // inject the LMCache connector — violating the events-only "no connector" + // vLLM+LMCache adapter, so an admission-bypassed external remote-storage + + // mode=EventsOnly object could otherwise inject the LMCache connector — + // violating the events-only "no connector" // contract. Gating here makes the no-connector guarantee adapter-independent. // The observation-sidecar append and the wired/injected-by logic below stay // as-is (events-only's only wiring is the subscriber sidecar). if !cache.Spec.IsEventsOnly() { - if err := adapterruntime.InjectEngineConfigWithBinding(adapter, &mutated.Spec, binding, cache); err != nil { + if err := adapter.InjectEngineConfig(&mutated.Spec, binding, cache); err != nil { log.V(1).Info("fail-open: adapter rejected pod", "runtime", string(runtimeID), "error", err.Error()) return failOpen(req, &pod, fmt.Sprintf("adapter rejected pod (fail-open): %v", err)) @@ -382,7 +382,7 @@ func (h *EngineInjector) Handle(ctx context.Context, req admission.Request) admi // pod. For Offload the connector is always injected by InjectEngineConfig, so // the pod is always wired. For events-only InjectEngineConfig is a no-op, so // the ONLY wiring the webhook performs is APPENDING the observation sidecar — - // which is skipped when the subscriber image / backendConfig.model is unset + // which is skipped when the subscriber image / observation.modelID is unset // (nothing injected) OR when a same-named container already exists (operator- // authored, unverified). In those cases the webhook added/verified no wiring, // so stamping injected-by/injected-by-uid would trip the downstream @@ -552,25 +552,24 @@ func skipInjection(req admission.Request, pod *corev1.Pod) admission.Response { } // effectiveEndpoint returns the address the engine pod should be wired -// to for the given CacheBackend. The source is type-scoped: +// to for the given CacheBackend. The source is ownership-scoped: // -// - External: spec.endpoint is authoritative — the operator owns it, +// - External ownership: spec.remoteStorage.endpoint is authoritative — the operator owns it, // admission validates it, status.endpoint is just a reconciler // mirror that may briefly lag during an update. If a new pod -// admits between an operator's spec.endpoint update and the +// admits between an operator's spec.remoteStorage.endpoint update and the // status patch, status would still hold the OLD value and the // pod would boot wired to the stale address; pod admission is -// CREATE-only so that bad wiring is permanent. Preferring -// trimmed spec.endpoint over status here avoids that race and is +// CREATE-only so that bad wiring is permanent. Preferring the trimmed +// remoteStorage endpoint over status here avoids that race and is // consistent with admission's view of the truth. -// - Managed types (LMCache, Mooncake): status.endpoint is the only +// - Managed storage: status.endpoint is the only // source — the reconciler builds it from the live Service it -// provisions, and spec.endpoint is admission-rejected for these -// types (see rejectEndpointOnNonExternal), so there's nothing -// else to fall back on. The webhook must wait for status. -// - Engine-local types (SGLangHiCache): no endpoint is required. The -// selected adapter's EndpointRequirement capability bypasses the gate -// before this empty result is consumed. +// provisions, and spec.remoteStorage.endpoint is admission-rejected for +// managed ownership, so there's nothing else to fall back on. The webhook +// must wait for status. +// - Engine-local caches: no endpoint is required. They carry a nil binding, +// which bypasses the endpoint gate before this empty result is consumed. // // Returns "" when no endpoint is currently usable; callers fail-open. // @@ -584,7 +583,7 @@ func skipInjection(req admission.Request, pod *corev1.Pod) admission.Response { // race against an old controller build can't leak whitespace to the // engine wire. // -// For External CRs with an empty/whitespace spec.endpoint there is NO +// For external storage with an empty/whitespace spec.remoteStorage.endpoint there is NO // fallback to status. The reconciler treats that state as // Ready=False/ExternalEndpointMissing — falling back here would wire // new pods to a stale status the reconciler considers unusable, which @@ -597,8 +596,9 @@ func effectiveEndpoint(cache *cachev1alpha1.CacheBackend) string { } if storage := cache.Spec.EffectiveRemoteStorage(); storage != nil && storage.Ownership == cachev1alpha1.CacheBackendRemoteStorageOwnershipExternal { - // For External, re-apply the provider-specific admission-time shape - // check on the stored spec endpoint. The validating webhook already + // For external ownership, re-apply the provider-specific admission-time + // shape check on the stored spec.remoteStorage.endpoint. The validating + // webhook already // rejects malformed values at write time, but a pre-existing // CR in etcd from before the shape rule shipped (or stored // when an earlier, laxer rule set was in effect) can still diff --git a/internal/webhook/pod/podinjector_test.go b/internal/webhook/pod/podinjector_test.go index cf8f9bdd..8b82e160 100644 --- a/internal/webhook/pod/podinjector_test.go +++ b/internal/webhook/pod/podinjector_test.go @@ -26,11 +26,26 @@ import ( "sigs.k8s.io/controller-runtime/pkg/webhook/admission" cachev1alpha1 "github.com/cachebox-project/inference-cache/api/v1alpha1" + builtinadapters "github.com/cachebox-project/inference-cache/internal/adapters/builtin" + builtinruntime "github.com/cachebox-project/inference-cache/internal/adapters/builtin/runtime" + backendadapter "github.com/cachebox-project/inference-cache/pkg/adapters/backend" adapterruntime "github.com/cachebox-project/inference-cache/pkg/adapters/runtime" - externaladapter "github.com/cachebox-project/inference-cache/pkg/adapters/runtime/external" - sglangadapter "github.com/cachebox-project/inference-cache/pkg/adapters/runtime/sglang" ) +func newVLLMRegistry(opts ...adapterruntime.Option) *adapterruntime.Registry { + registry := adapterruntime.NewRegistry() + registry.Register(builtinruntime.NewVLLMLMCacheAdapter(opts...)) + return registry +} + +func externalLMCacheStorage(endpoint string) *cachev1alpha1.CacheBackendRemoteStorageSpec { + return &cachev1alpha1.CacheBackendRemoteStorageSpec{ + Provider: cachev1alpha1.CacheBackendRemoteStorageProviderLMCacheServer, + Ownership: cachev1alpha1.CacheBackendRemoteStorageOwnershipExternal, + Endpoint: endpoint, + } +} + // newScheme returns a scheme with corev1 + the CRD types registered so a // fake client can list CacheBackends and the handler can json-unmarshal Pods. func newScheme(t *testing.T) *runtime.Scheme { @@ -160,10 +175,15 @@ func readyCacheBackend(name, namespace string, selector map[string]string) *cach UID: types.UID("cb-" + namespace + "-" + name + "-uid"), }, Spec: cachev1alpha1.CacheBackendSpec{ - Type: cachev1alpha1.CacheBackendTypeLMCache, + Runtime: cachev1alpha1.CacheBackendRuntimeVLLM, + Type: cachev1alpha1.CacheBackendTypeLMCache, + RemoteStorage: &cachev1alpha1.CacheBackendRemoteStorageSpec{ + Provider: cachev1alpha1.CacheBackendRemoteStorageProviderLMCacheServer, + Ownership: cachev1alpha1.CacheBackendRemoteStorageOwnershipManaged, + LMCacheServer: &cachev1alpha1.LMCacheServerRemoteStorageSpec{}, + }, Integration: &cachev1alpha1.CacheBackendIntegrationSpec{ - Engine: "vllm", - Role: cachev1alpha1.CacheBackendIntegrationRoleReadWrite, + Role: cachev1alpha1.CacheBackendIntegrationRoleReadWrite, }, EngineSelector: &cachev1alpha1.CacheBackendEngineSelector{MatchLabels: selector}, }, @@ -178,8 +198,9 @@ func newHandler(t *testing.T, objs ...client.Object) *EngineInjector { s := newScheme(t) c := fake.NewClientBuilder().WithScheme(s).WithObjects(objs...).Build() return &EngineInjector{ - Reader: c, - Log: logr.Discard(), + Reader: c, + Registry: builtinadapters.New().Runtime, + Log: logr.Discard(), } } @@ -192,7 +213,7 @@ func newHandlerWithSubscriber(t *testing.T, objs ...client.Object) *EngineInject t.Helper() s := newScheme(t) c := fake.NewClientBuilder().WithScheme(s).WithObjects(objs...).Build() - reg := adapterruntime.NewCoreRegistry( + reg := newVLLMRegistry( adapterruntime.WithSubscriberImage(adapterruntime.DefaultSubscriberImage), ) return &EngineInjector{ @@ -247,8 +268,14 @@ func TestHandle_MatchAndInject_SGLang(t *testing.T) { // and the pod would boot unwired. const ns = "engines" cb := readyCacheBackend("sg-primary", ns, map[string]string{"app": "sglang"}) - cb.Spec.Integration.Engine = "sglang" // override the readyCacheBackend vLLM default - h := newHandler(t, cb) // nil registry uses the complete built-in composition + cb.Spec.Runtime = cachev1alpha1.CacheBackendRuntimeSGLang + cb.Spec.Runtime = cachev1alpha1.CacheBackendRuntimeSGLang // override the readyCacheBackend vLLM default + cb.Spec.RemoteStorage = &cachev1alpha1.CacheBackendRemoteStorageSpec{ + Provider: cachev1alpha1.CacheBackendRemoteStorageProviderRedis, + Ownership: cachev1alpha1.CacheBackendRemoteStorageOwnershipManaged, + Redis: &cachev1alpha1.RedisRemoteStorageSpec{}, + } + h := newHandler(t, cb) // helper explicitly injects the complete built-in composition pod := sglangEnginePod("sg-engine-a", map[string]string{"app": "sglang"}) req := newRequest(t, pod, ns) @@ -301,8 +328,10 @@ func TestHandle_MatchAndInject_SGLang(t *testing.T) { func TestHandle_MatchAndInject_SGLangHiCacheWithoutEndpoint(t *testing.T) { const ns = "engines" cb := readyCacheBackend("hicache", ns, map[string]string{"app": "sglang"}) + cb.Spec.Runtime = cachev1alpha1.CacheBackendRuntimeSGLang cb.Spec.Type = cachev1alpha1.CacheBackendTypeSGLangHiCache - cb.Spec.Integration.Engine = "sglang" + cb.Spec.RemoteStorage = nil + cb.Spec.Runtime = cachev1alpha1.CacheBackendRuntimeSGLang cb.Spec.HiCache = &cachev1alpha1.SGLangHiCacheSpec{ Ratio: "2.0", WritePolicy: cachev1alpha1.SGLangHiCacheWriteThrough, @@ -344,7 +373,7 @@ func TestHandle_CanonicalSGLangHiCacheWithRemoteStorageFailsOpen(t *testing.T) { cb := readyCacheBackend("hicache-remote", ns, map[string]string{"app": "sglang"}) cb.Spec.Runtime = cachev1alpha1.CacheBackendRuntimeSGLang cb.Spec.Type = cachev1alpha1.CacheBackendTypeSGLangHiCache - cb.Spec.Integration.Engine = "" + cb.Spec.Runtime = "" cb.Spec.HiCache = &cachev1alpha1.SGLangHiCacheSpec{Ratio: "2"} cb.Spec.RemoteStorage = &cachev1alpha1.CacheBackendRemoteStorageSpec{ Provider: cachev1alpha1.CacheBackendRemoteStorageProviderRedis, @@ -366,8 +395,10 @@ func TestHandle_CanonicalSGLangHiCacheWithRemoteStorageFailsOpen(t *testing.T) { func TestHandle_SGLangHiCacheConflictFailsOpenWithoutPartialInjection(t *testing.T) { const ns = "engines" cb := readyCacheBackend("hicache", ns, map[string]string{"app": "sglang"}) + cb.Spec.Runtime = cachev1alpha1.CacheBackendRuntimeSGLang cb.Spec.Type = cachev1alpha1.CacheBackendTypeSGLangHiCache - cb.Spec.Integration.Engine = "sglang" + cb.Spec.RemoteStorage = nil + cb.Spec.Runtime = cachev1alpha1.CacheBackendRuntimeSGLang cb.Spec.HiCache = &cachev1alpha1.SGLangHiCacheSpec{ Ratio: "2", WritePolicy: cachev1alpha1.SGLangHiCacheWriteThrough, @@ -389,7 +420,7 @@ func TestHandle_SGLangHiCacheConflictFailsOpenWithoutPartialInjection(t *testing func TestHandle_MooncakeBackend_InjectsMooncakeStoreEndpoint(t *testing.T) { // End-to-end pod-webhook path for a managed Mooncake backend: the handler // lists the CacheBackend, the built-in shipping registry selects the - // vLLM+Mooncake adapter, and the engine container comes out wired to the + // vLLM+LMCache adapter with a Mooncake binding, and the engine container is wired to the // Mooncake master via the LMCache connector with the mooncakestore:// // scheme (the lm:// analog) — plus the kvevent-subscriber sidecar. This is // the advertised integration path; the adapter-level tests don't exercise @@ -402,15 +433,19 @@ func TestHandle_MooncakeBackend_InjectsMooncakeStoreEndpoint(t *testing.T) { UID: types.UID("cb-mc-uid"), }, Spec: cachev1alpha1.CacheBackendSpec{ - Type: cachev1alpha1.CacheBackendTypeMooncake, + Runtime: cachev1alpha1.CacheBackendRuntimeVLLM, + Type: cachev1alpha1.CacheBackendTypeLMCache, + RemoteStorage: &cachev1alpha1.CacheBackendRemoteStorageSpec{ + Provider: cachev1alpha1.CacheBackendRemoteStorageProviderMooncake, + Ownership: cachev1alpha1.CacheBackendRemoteStorageOwnershipManaged, + }, Integration: &cachev1alpha1.CacheBackendIntegrationSpec{ - Engine: "vllm", - Role: cachev1alpha1.CacheBackendIntegrationRoleReadWrite, + Role: cachev1alpha1.CacheBackendIntegrationRoleReadWrite, }, EngineSelector: &cachev1alpha1.CacheBackendEngineSelector{ MatchLabels: map[string]string{"app": "vllm"}, }, - BackendConfig: map[string]string{"model": "Qwen/Qwen2.5-0.5B-Instruct"}, + Observation: &cachev1alpha1.CacheBackendObservationSpec{ModelID: "Qwen/Qwen2.5-0.5B-Instruct"}, }, // Mooncake status.endpoint is the master's RPC host:port (the // reconciler publishes the Service's first port, 50051). @@ -449,7 +484,7 @@ func TestHandle_MooncakeBackend_InjectsMooncakeStoreEndpoint(t *testing.T) { t.Fatalf("subscriber must tag events hash-scheme=vllm; args = %v", sub.Args) } if !argPresent(sub.Args, "--model-id=Qwen/Qwen2.5-0.5B-Instruct") { - t.Fatalf("subscriber --model-id derived from backendConfig.model missing; args = %v", sub.Args) + t.Fatalf("subscriber --model-id derived from observation.modelID missing; args = %v", sub.Args) } } @@ -594,10 +629,10 @@ func TestHandle_LMCacheBackend_NeverMovesEnginePodOntoHostNetwork(t *testing.T) cb := &cachev1alpha1.CacheBackend{ ObjectMeta: metav1.ObjectMeta{Name: "lm", Namespace: ns, UID: types.UID("cb-lm-uid")}, Spec: cachev1alpha1.CacheBackendSpec{ - Type: cachev1alpha1.CacheBackendTypeLMCache, + Runtime: cachev1alpha1.CacheBackendRuntimeVLLM, + Type: cachev1alpha1.CacheBackendTypeLMCache, Integration: &cachev1alpha1.CacheBackendIntegrationSpec{ - Engine: "vllm", - Role: cachev1alpha1.CacheBackendIntegrationRoleReadWrite, + Role: cachev1alpha1.CacheBackendIntegrationRoleReadWrite, }, EngineSelector: &cachev1alpha1.CacheBackendEngineSelector{ MatchLabels: map[string]string{"app": "vllm"}, @@ -631,7 +666,7 @@ func TestHandle_AppendsObservationSidecar(t *testing.T) { // wiring. const ns = "engines" cb := readyCacheBackend("primary", ns, map[string]string{"app": "vllm"}) - cb.Spec.BackendConfig = map[string]string{"model": "Qwen/Qwen2.5-0.5B-Instruct"} + cb.Spec.Observation = &cachev1alpha1.CacheBackendObservationSpec{ModelID: "Qwen/Qwen2.5-0.5B-Instruct"} h := newHandlerWithSubscriber(t, cb) pod := vllmEnginePod("engine-a", map[string]string{"app": "vllm"}) req := newRequest(t, pod, ns) @@ -649,7 +684,7 @@ func TestHandle_AppendsObservationSidecar(t *testing.T) { t.Fatalf("subscriber sidecar missing; containers = %v", containerNames(mutated)) } if !argPresent(sub.Args, "--model-id=Qwen/Qwen2.5-0.5B-Instruct") { - t.Fatalf("--model-id derived from cb.spec.backendConfig.model missing; args = %v", sub.Args) + t.Fatalf("--model-id derived from cb.spec.observation.modelID missing; args = %v", sub.Args) } if !argPresent(sub.Args, "--replica-id=$(POD_NAME)") { t.Fatalf("--replica-id MUST use downward-API POD_NAME; args = %v", sub.Args) @@ -667,18 +702,23 @@ func TestHandle_AppendsObservationSidecar_SGLang(t *testing.T) { // SGLang engine pod must get the kvevent-subscriber sidecar tagged // --hash-scheme=sglang (the load-bearing scheme tag) + --ignore-block-removed // (LMCache is an L2 tier). Builds the relevant adapters with the subscriber - // image option used by cmd/controller because the no-arg nil fallback + // image option used by cmd/controller because the no-option registry // renders no sidecar (auto-attach opt-in). const ns = "engines" cb := readyCacheBackend("sg-primary", ns, map[string]string{"app": "sglang"}) - cb.Spec.Integration.Engine = "sglang" - cb.Spec.BackendConfig = map[string]string{"model": "Qwen/Qwen2.5-0.5B-Instruct"} + cb.Spec.Runtime = cachev1alpha1.CacheBackendRuntimeSGLang + cb.Spec.Runtime = cachev1alpha1.CacheBackendRuntimeSGLang + cb.Spec.RemoteStorage = &cachev1alpha1.CacheBackendRemoteStorageSpec{ + Provider: cachev1alpha1.CacheBackendRemoteStorageProviderRedis, + Ownership: cachev1alpha1.CacheBackendRemoteStorageOwnershipManaged, + Redis: &cachev1alpha1.RedisRemoteStorageSpec{}, + } + cb.Spec.Observation = &cachev1alpha1.CacheBackendObservationSpec{ModelID: "Qwen/Qwen2.5-0.5B-Instruct"} s := newScheme(t) c := fake.NewClientBuilder().WithScheme(s).WithObjects(cb).Build() - reg := adapterruntime.NewCoreRegistry(adapterruntime.WithSubscriberImage(adapterruntime.DefaultSubscriberImage)) - reg.Register(externaladapter.NewAdapter()) - reg.Register(sglangadapter.NewAdapter(adapterruntime.WithSubscriberImage(adapterruntime.DefaultSubscriberImage))) + reg := newVLLMRegistry(adapterruntime.WithSubscriberImage(adapterruntime.DefaultSubscriberImage)) + reg.Register(builtinruntime.NewSGLangLMCacheAdapter(adapterruntime.WithSubscriberImage(adapterruntime.DefaultSubscriberImage))) h := &EngineInjector{Reader: c, Registry: reg, Log: logr.Discard()} pod := sglangEnginePod("sg-engine-a", map[string]string{"app": "sglang"}) @@ -700,7 +740,7 @@ func TestHandle_AppendsObservationSidecar_SGLang(t *testing.T) { t.Fatalf("SGLang subscriber MUST tag --hash-scheme=sglang; args = %v", sub.Args) } if !argPresent(sub.Args, "--model-id=Qwen/Qwen2.5-0.5B-Instruct") { - t.Fatalf("--model-id derived from cb.spec.backendConfig.model missing; args = %v", sub.Args) + t.Fatalf("--model-id derived from cb.spec.observation.modelID missing; args = %v", sub.Args) } if !argPresent(sub.Args, "--ignore-block-removed=true") { t.Fatalf("SGLang+LMCache subscriber MUST set --ignore-block-removed=true (L2 tier); args = %v", sub.Args) @@ -712,7 +752,7 @@ func TestHandle_AppendsObservationSidecar_SGLang(t *testing.T) { func TestHandle_SidecarAppendIsIdempotent(t *testing.T) { const ns = "engines" cb := readyCacheBackend("primary", ns, map[string]string{"app": "vllm"}) - cb.Spec.BackendConfig = map[string]string{"model": "MyOrg/MyModel"} + cb.Spec.Observation = &cachev1alpha1.CacheBackendObservationSpec{ModelID: "MyOrg/MyModel"} h := newHandlerWithSubscriber(t, cb) pod := vllmEnginePod("engine-a", map[string]string{"app": "vllm"}) @@ -744,14 +784,14 @@ func eventsOnlyCacheBackend(name, namespace string, selector map[string]string) UID: types.UID("cb-" + namespace + "-" + name + "-uid"), }, Spec: cachev1alpha1.CacheBackendSpec{ - Type: cachev1alpha1.CacheBackendTypeLMCache, + Runtime: cachev1alpha1.CacheBackendRuntimeVLLM, + Type: cachev1alpha1.CacheBackendTypeLMCache, Integration: &cachev1alpha1.CacheBackendIntegrationSpec{ - Engine: "vllm", - Mode: cachev1alpha1.CacheBackendIntegrationModeEventsOnly, - Role: cachev1alpha1.CacheBackendIntegrationRoleReadWrite, + Mode: cachev1alpha1.CacheBackendIntegrationModeEventsOnly, + Role: cachev1alpha1.CacheBackendIntegrationRoleReadWrite, }, EngineSelector: &cachev1alpha1.CacheBackendEngineSelector{MatchLabels: selector}, - BackendConfig: map[string]string{"model": "Qwen/Qwen2.5-0.5B-Instruct"}, + Observation: &cachev1alpha1.CacheBackendObservationSpec{ModelID: "Qwen/Qwen2.5-0.5B-Instruct"}, }, // No Status.Endpoint — events-only provisions no server, so the // reconciler leaves it empty. The webhook MUST inject anyway. @@ -792,7 +832,7 @@ func TestHandle_EventsOnly_EmptyEndpoint_InjectsSubscriberWithoutConnector(t *te t.Fatalf("subscriber sidecar missing; containers = %v", containerNames(mutated)) } if !argPresent(sub.Args, "--model-id=Qwen/Qwen2.5-0.5B-Instruct") { - t.Fatalf("--model-id derived from cb.spec.backendConfig.model missing; args = %v", sub.Args) + t.Fatalf("--model-id derived from cb.spec.observation.modelID missing; args = %v", sub.Args) } // The injected-by + injected-by-uid annotations are stamped — proving the @@ -836,7 +876,7 @@ func TestHandle_OffloadManagedBackend_EmptyEndpoint_FailsOpen(t *testing.T) { // "inject on empty endpoint". const ns = "engines" cb := readyCacheBackend("primary", ns, map[string]string{"app": "vllm"}) - cb.Spec.BackendConfig = map[string]string{"model": "Qwen/Qwen2.5-0.5B-Instruct"} + cb.Spec.Observation = &cachev1alpha1.CacheBackendObservationSpec{ModelID: "Qwen/Qwen2.5-0.5B-Instruct"} cb.Status.Endpoint = "" // Offload mode, reconciler hasn't published yet. h := newHandlerWithSubscriber(t, cb) pod := vllmEnginePod("engine-a", map[string]string{"app": "vllm"}) @@ -1046,11 +1086,11 @@ func TestHandle_EventsOnly_EngineOverrides_DoNotTouchEngineContainer(t *testing. } func TestHandle_ExternalBackend_InjectsOperatorEndpoint(t *testing.T) { - // A pod that matches an External CR's engine selector must come out + // A pod that matches an externally owned CR's engine selector must come out // of admission wired to the operator-supplied endpoint via the // LMCache engine wire format — the controller doesn't render a // Service for the cache, so the only source of truth for the - // address is spec.endpoint (mirrored to status.endpoint by + // address is spec.remoteStorage.endpoint (mirrored to status.endpoint by // reconcileExternal). const ( ns = "engines" @@ -1059,11 +1099,11 @@ func TestHandle_ExternalBackend_InjectsOperatorEndpoint(t *testing.T) { cb := &cachev1alpha1.CacheBackend{ ObjectMeta: metav1.ObjectMeta{Name: "ext", Namespace: ns}, Spec: cachev1alpha1.CacheBackendSpec{ - Type: cachev1alpha1.CacheBackendTypeExternal, - Endpoint: endpoint, + Runtime: cachev1alpha1.CacheBackendRuntimeVLLM, + Type: cachev1alpha1.CacheBackendTypeLMCache, + RemoteStorage: externalLMCacheStorage(endpoint), Integration: &cachev1alpha1.CacheBackendIntegrationSpec{ - Engine: "vllm", - Role: cachev1alpha1.CacheBackendIntegrationRoleReadWrite, + Role: cachev1alpha1.CacheBackendIntegrationRoleReadWrite, }, EngineSelector: &cachev1alpha1.CacheBackendEngineSelector{ MatchLabels: map[string]string{"app": "vllm"}, @@ -1074,12 +1114,7 @@ func TestHandle_ExternalBackend_InjectsOperatorEndpoint(t *testing.T) { s := newScheme(t) c := fake.NewClientBuilder().WithScheme(s).WithObjects(cb).Build() - // Build the relevant subset of the built-in composition: the core registry - // plus External. Without External in the registry the - // webhook would fail-open with "no adapter" and leave the engine - // unwired — that's the very gap the External adapter closes. - reg := adapterruntime.NewCoreRegistry() - reg.Register(externaladapter.NewAdapter()) + reg := newVLLMRegistry() h := &EngineInjector{Reader: c, Registry: reg, Log: logr.Discard()} pod := vllmEnginePod("engine-a", map[string]string{"app": "vllm"}) @@ -1101,8 +1136,8 @@ func TestHandle_ExternalBackend_InjectsOperatorEndpoint(t *testing.T) { if !containsArgPairLocal(mutated.Spec.Containers[0].Args, "--model", "Qwen/Qwen2.5-0.5B-Instruct") { t.Fatalf("user --model arg was lost; args = %v", mutated.Spec.Containers[0].Args) } - // External path attaches no observation sidecar — the controller has - // no observability seam into an operator-managed cache. + // The external-ownership path attaches no observation sidecar — the + // controller has no observability seam into an operator-managed cache. if c := findContainer(mutated, adapterruntime.SubscriberContainerName); c != nil { t.Fatalf("External backend must NOT get a subscriber sidecar; found %+v", c) } @@ -1113,8 +1148,8 @@ func TestHandle_ExternalBackend_InjectsOperatorEndpoint(t *testing.T) { } func TestHandle_ExternalBackend_InvalidSpecEndpoint_FailsOpen(t *testing.T) { - // A pre-existing External CR carrying a malformed spec.endpoint - // (stored before the shape rule shipped) must not be wired — + // An externally owned CR carrying a malformed spec.remoteStorage.endpoint + // must not be wired — // injecting LMCACHE_REMOTE_URL=lm://https://... or lm://2001:db8::1 // would crash the engine at startup. effectiveEndpoint applies the // same shape check the admission webhook uses and returns "" for @@ -1127,17 +1162,20 @@ func TestHandle_ExternalBackend_InvalidSpecEndpoint_FailsOpen(t *testing.T) { }{ {"bad-scheme", "https://cache.example.com:443/api"}, {"portless-host", "cache.example.com"}, + {"non-numeric-port", "cache.example.com:not-a-port"}, + {"zero-port", "cache.example.com:0"}, + {"out-of-range-port", "cache.example.com:70000"}, {"embedded-whitespace", "cache example:8200"}, } { t.Run(tc.name, func(t *testing.T) { cb := &cachev1alpha1.CacheBackend{ ObjectMeta: metav1.ObjectMeta{Name: "ext-bad", Namespace: ns}, Spec: cachev1alpha1.CacheBackendSpec{ - Type: cachev1alpha1.CacheBackendTypeExternal, - Endpoint: tc.endpoint, + Runtime: cachev1alpha1.CacheBackendRuntimeVLLM, + Type: cachev1alpha1.CacheBackendTypeLMCache, + RemoteStorage: externalLMCacheStorage(tc.endpoint), Integration: &cachev1alpha1.CacheBackendIntegrationSpec{ - Engine: "vllm", - Role: cachev1alpha1.CacheBackendIntegrationRoleReadWrite, + Role: cachev1alpha1.CacheBackendIntegrationRoleReadWrite, }, EngineSelector: &cachev1alpha1.CacheBackendEngineSelector{ MatchLabels: map[string]string{"app": "vllm"}, @@ -1146,8 +1184,7 @@ func TestHandle_ExternalBackend_InvalidSpecEndpoint_FailsOpen(t *testing.T) { } s := newScheme(t) c := fake.NewClientBuilder().WithScheme(s).WithObjects(cb).Build() - reg := adapterruntime.NewCoreRegistry() - reg.Register(externaladapter.NewAdapter()) + reg := newVLLMRegistry() h := &EngineInjector{Reader: c, Registry: reg, Log: logr.Discard()} pod := vllmEnginePod("engine", map[string]string{"app": "vllm"}) @@ -1160,9 +1197,9 @@ func TestHandle_ExternalBackend_InvalidSpecEndpoint_FailsOpen(t *testing.T) { if len(resp.Patches) != 0 { t.Fatalf("expected no patches on invalid endpoint; got %d: %v", len(resp.Patches), resp.Patches) } - // Response message must name spec.endpoint, not status.endpoint. - if msg := resp.Result.Message; !strings.Contains(msg, "spec.endpoint") { - t.Fatalf("fail-open reason should mention spec.endpoint for External; got %q", msg) + // Response message must name the canonical spec field, not status.endpoint. + if msg := resp.Result.Message; !strings.Contains(msg, "spec.remoteStorage.endpoint") { + t.Fatalf("fail-open reason should mention spec.remoteStorage.endpoint for External; got %q", msg) } }) } @@ -1194,14 +1231,14 @@ func TestEffectiveEndpointCanonicalExternalUsesProviderProtocol(t *testing.T) { func TestHandle_ExternalBackend_StatusEmpty_UsesSpecDirectly(t *testing.T) { // Pod admission is CREATE-only — if an engine pod admits before the - // controller has mirrored spec.endpoint into status.endpoint, the - // webhook would fail-open and leave the pod unwired *forever* (no - // re-admission on subsequent status updates). For External CRs the - // webhook sources the endpoint from spec.endpoint directly (NOT - // "falling back" — effectiveEndpoint type-scopes the source so - // External never reads status.endpoint, preventing wiring against a + // controller has mirrored spec.remoteStorage.endpoint into status.endpoint, + // the webhook would fail-open and leave the pod unwired *forever* (no + // re-admission on subsequent status updates). For externally owned CRs the + // webhook sources the endpoint from spec.remoteStorage.endpoint directly + // (NOT "falling back" — effectiveEndpoint ownership-scopes the source so + // external ownership never reads status.endpoint, preventing wiring against a // stale mirror during an endpoint update). Without this, applying - // the External CacheBackend and the engine Deployment in the same + // the externally owned CacheBackend and the engine Deployment in the same // kubectl apply silently produces unwired engine pods. const ( ns = "engines" @@ -1210,11 +1247,11 @@ func TestHandle_ExternalBackend_StatusEmpty_UsesSpecDirectly(t *testing.T) { cb := &cachev1alpha1.CacheBackend{ ObjectMeta: metav1.ObjectMeta{Name: "ext", Namespace: ns}, Spec: cachev1alpha1.CacheBackendSpec{ - Type: cachev1alpha1.CacheBackendTypeExternal, - Endpoint: endpoint, + Runtime: cachev1alpha1.CacheBackendRuntimeVLLM, + Type: cachev1alpha1.CacheBackendTypeLMCache, + RemoteStorage: externalLMCacheStorage(endpoint), Integration: &cachev1alpha1.CacheBackendIntegrationSpec{ - Engine: "vllm", - Role: cachev1alpha1.CacheBackendIntegrationRoleReadWrite, + Role: cachev1alpha1.CacheBackendIntegrationRoleReadWrite, }, EngineSelector: &cachev1alpha1.CacheBackendEngineSelector{ MatchLabels: map[string]string{"app": "vllm"}, @@ -1226,8 +1263,7 @@ func TestHandle_ExternalBackend_StatusEmpty_UsesSpecDirectly(t *testing.T) { s := newScheme(t) c := fake.NewClientBuilder().WithScheme(s).WithObjects(cb).Build() - reg := adapterruntime.NewCoreRegistry() - reg.Register(externaladapter.NewAdapter()) + reg := newVLLMRegistry() h := &EngineInjector{Reader: c, Registry: reg, Log: logr.Discard()} pod := vllmEnginePod("engine-race", map[string]string{"app": "vllm"}) @@ -1242,9 +1278,10 @@ func TestHandle_ExternalBackend_StatusEmpty_UsesSpecDirectly(t *testing.T) { } func TestHandle_ExternalBackend_PrefersSpecOverStaleStatus(t *testing.T) { - // When the operator updates spec.endpoint for an External CR but a - // new engine pod admits before the reconciler patches status, the - // pod must be wired to the NEW spec.endpoint — not the stale + // When the operator updates spec.remoteStorage.endpoint for an externally + // owned CR but a new engine pod admits before the reconciler patches status, + // the + // pod must be wired to the NEW spec.remoteStorage.endpoint — not the stale // status.endpoint. Pod admission is CREATE-only, so a pod wired to // the old address on admission stays misrouted forever. const ( @@ -1255,11 +1292,11 @@ func TestHandle_ExternalBackend_PrefersSpecOverStaleStatus(t *testing.T) { cb := &cachev1alpha1.CacheBackend{ ObjectMeta: metav1.ObjectMeta{Name: "ext", Namespace: ns}, Spec: cachev1alpha1.CacheBackendSpec{ - Type: cachev1alpha1.CacheBackendTypeExternal, - Endpoint: freshSpec, + Runtime: cachev1alpha1.CacheBackendRuntimeVLLM, + Type: cachev1alpha1.CacheBackendTypeLMCache, + RemoteStorage: externalLMCacheStorage(freshSpec), Integration: &cachev1alpha1.CacheBackendIntegrationSpec{ - Engine: "vllm", - Role: cachev1alpha1.CacheBackendIntegrationRoleReadWrite, + Role: cachev1alpha1.CacheBackendIntegrationRoleReadWrite, }, EngineSelector: &cachev1alpha1.CacheBackendEngineSelector{ MatchLabels: map[string]string{"app": "vllm"}, @@ -1270,8 +1307,7 @@ func TestHandle_ExternalBackend_PrefersSpecOverStaleStatus(t *testing.T) { s := newScheme(t) c := fake.NewClientBuilder().WithScheme(s).WithObjects(cb).Build() - reg := adapterruntime.NewCoreRegistry() - reg.Register(externaladapter.NewAdapter()) + reg := newVLLMRegistry() h := &EngineInjector{Reader: c, Registry: reg, Log: logr.Discard()} pod := vllmEnginePod("engine-stale", map[string]string{"app": "vllm"}) @@ -1282,11 +1318,11 @@ func TestHandle_ExternalBackend_PrefersSpecOverStaleStatus(t *testing.T) { t.Fatalf("expected Allowed, got %+v", resp.Result) } mutated := applyPatches(t, req.Object.Raw, resp) - // Must use spec.endpoint, NOT the stale status.endpoint. + // Must use spec.remoteStorage.endpoint, NOT the stale status.endpoint. mustHaveEnv(t, mutated, adapterruntime.EnvLMCacheRemoteURL, "lm://"+freshSpec) for _, e := range mutated.Spec.Containers[0].Env { if e.Name == adapterruntime.EnvLMCacheRemoteURL && e.Value == "lm://"+staleStatus { - t.Fatalf("pod wired to stale status.endpoint %q; should be spec.endpoint %q", staleStatus, freshSpec) + t.Fatalf("pod wired to stale status.endpoint %q; should be spec.remoteStorage.endpoint %q", staleStatus, freshSpec) } } } @@ -1305,11 +1341,11 @@ func TestHandle_ExternalBackend_UpperCaseSchemeNormalised(t *testing.T) { cb := &cachev1alpha1.CacheBackend{ ObjectMeta: metav1.ObjectMeta{Name: "ext-up", Namespace: ns}, Spec: cachev1alpha1.CacheBackendSpec{ - Type: cachev1alpha1.CacheBackendTypeExternal, - Endpoint: operatorTyped, + Runtime: cachev1alpha1.CacheBackendRuntimeVLLM, + Type: cachev1alpha1.CacheBackendTypeLMCache, + RemoteStorage: externalLMCacheStorage(operatorTyped), Integration: &cachev1alpha1.CacheBackendIntegrationSpec{ - Engine: "vllm", - Role: cachev1alpha1.CacheBackendIntegrationRoleReadWrite, + Role: cachev1alpha1.CacheBackendIntegrationRoleReadWrite, }, EngineSelector: &cachev1alpha1.CacheBackendEngineSelector{ MatchLabels: map[string]string{"app": "vllm"}, @@ -1319,8 +1355,7 @@ func TestHandle_ExternalBackend_UpperCaseSchemeNormalised(t *testing.T) { s := newScheme(t) c := fake.NewClientBuilder().WithScheme(s).WithObjects(cb).Build() - reg := adapterruntime.NewCoreRegistry() - reg.Register(externaladapter.NewAdapter()) + reg := newVLLMRegistry() h := &EngineInjector{Reader: c, Registry: reg, Log: logr.Discard()} pod := vllmEnginePod("engine-up", map[string]string{"app": "vllm"}) @@ -1342,16 +1377,15 @@ func TestHandle_WhitespaceStatusEndpointFailsOpen(t *testing.T) { // missing rather than injecting `LMCACHE_REMOTE_URL=lm:// ` which // the engine connector would reject at runtime. The defensive trim // applies to whichever field effectiveEndpoint reads for the CR's - // type — spec.endpoint for External (which never reads status), and - // status.endpoint for managed. + // ownership — spec.remoteStorage.endpoint for external ownership (which + // never reads status), and status.endpoint for managed ownership. const ns = "engines" cb := &cachev1alpha1.CacheBackend{ ObjectMeta: metav1.ObjectMeta{Name: "managed-ws", Namespace: ns}, Spec: cachev1alpha1.CacheBackendSpec{ Type: cachev1alpha1.CacheBackendTypeLMCache, Integration: &cachev1alpha1.CacheBackendIntegrationSpec{ - Engine: "vllm", - Role: cachev1alpha1.CacheBackendIntegrationRoleReadWrite, + Role: cachev1alpha1.CacheBackendIntegrationRoleReadWrite, }, EngineSelector: &cachev1alpha1.CacheBackendEngineSelector{ MatchLabels: map[string]string{"app": "vllm"}, @@ -1377,10 +1411,10 @@ func TestHandle_WhitespaceStatusEndpointFailsOpen(t *testing.T) { } func TestHandle_ManagedBackend_StatusEmpty_FailsOpen(t *testing.T) { - // Counterpart to the External fallback: managed backends MUST wait + // Counterpart to the external-ownership path: managed backends MUST wait // for status.endpoint (the reconciler builds it from the rendered - // Service). spec.endpoint is admission-rejected on managed types, - // so there's nothing else to fall back on — the webhook must + // Service). spec.remoteStorage.endpoint is admission-rejected for managed + // ownership, so there's nothing else to fall back on — the webhook must // fail-open without injecting until status catches up. const ns = "engines" cb := &cachev1alpha1.CacheBackend{ @@ -1388,8 +1422,7 @@ func TestHandle_ManagedBackend_StatusEmpty_FailsOpen(t *testing.T) { Spec: cachev1alpha1.CacheBackendSpec{ Type: cachev1alpha1.CacheBackendTypeLMCache, Integration: &cachev1alpha1.CacheBackendIntegrationSpec{ - Engine: "vllm", - Role: cachev1alpha1.CacheBackendIntegrationRoleReadWrite, + Role: cachev1alpha1.CacheBackendIntegrationRoleReadWrite, }, EngineSelector: &cachev1alpha1.CacheBackendEngineSelector{ MatchLabels: map[string]string{"app": "vllm"}, @@ -1438,7 +1471,7 @@ func TestHandle_ExternalBackend_NoSidecar(t *testing.T) { // without appending a kvevent-subscriber container. const ns = "engines" cb := readyCacheBackend("primary", ns, map[string]string{"app": "vllm"}) - cb.Spec.Integration.Engine = string(adapterruntime.RuntimeReference) + cb.Spec.Runtime = cachev1alpha1.CacheBackendRuntime(adapterruntime.RuntimeReference) s := newScheme(t) c := fake.NewClientBuilder().WithScheme(s).WithObjects(cb).Build() reg := adapterruntime.NewRegistry() @@ -1465,8 +1498,8 @@ func TestHandle_SidecarOptInDefaultsToNoSidecar(t *testing.T) { // single-container and the cache is purely opt-in for now. const ns = "engines" cb := readyCacheBackend("primary", ns, map[string]string{"app": "vllm"}) - cb.Spec.BackendConfig = map[string]string{"model": "MyOrg/MyModel"} - h := newHandler(t, cb) // built-in nil fallback, with no subscriber image + cb.Spec.Observation = &cachev1alpha1.CacheBackendObservationSpec{ModelID: "MyOrg/MyModel"} + h := newHandler(t, cb) // built-in registry, with no subscriber image pod := vllmEnginePod("engine-a", map[string]string{"app": "vllm"}) req := newRequest(t, pod, ns) @@ -1484,7 +1517,7 @@ func TestHandle_SidecarOptInDefaultsToNoSidecar(t *testing.T) { func TestHandle_SidecarSkippedWithoutModel(t *testing.T) { const ns = "engines" cb := readyCacheBackend("primary", ns, map[string]string{"app": "vllm"}) - // Sidecar opt-in via the configured handler, but no backendConfig.model + // Sidecar opt-in via the configured handler, but no observation.modelID // — adapter returns (nil, nil) so the engine wiring still happens // while the sidecar append is skipped. h := newHandlerWithSubscriber(t, cb) @@ -1508,7 +1541,8 @@ func TestHandle_SidecarErrorIsFailOpen(t *testing.T) { // is an optimisation, never a serving dependency. const ns = "engines" cb := readyCacheBackend("primary", ns, map[string]string{"app": "vllm"}) - cb.Spec.Integration.Engine = "stub-fail" + cb.Spec.Runtime = "" + cb.Spec.Runtime = cachev1alpha1.CacheBackendRuntime("stub-fail") s := newScheme(t) c := fake.NewClientBuilder().WithScheme(s).WithObjects(cb).Build() reg := adapterruntime.NewRegistry() @@ -1531,7 +1565,7 @@ func TestHandle_SidecarErrorIsFailOpen(t *testing.T) { func TestHandle_PreExistingSidecar_NotDuplicated(t *testing.T) { const ns = "engines" cb := readyCacheBackend("primary", ns, map[string]string{"app": "vllm"}) - cb.Spec.BackendConfig = map[string]string{"model": "MyOrg/MyModel"} + cb.Spec.Observation = &cachev1alpha1.CacheBackendObservationSpec{ModelID: "MyOrg/MyModel"} h := newHandlerWithSubscriber(t, cb) pod := vllmEnginePod("engine-a", map[string]string{"app": "vllm"}) pod.Spec.Containers = append(pod.Spec.Containers, corev1.Container{ @@ -1569,7 +1603,9 @@ func (sidecarErrorAdapter) ResolveCacheServer(*cachev1alpha1.CacheBackend) (*cor return nil, nil, nil } -func (sidecarErrorAdapter) InjectEngineConfig(pod *corev1.PodSpec, _ string, _ *cachev1alpha1.CacheBackend) error { +func (sidecarErrorAdapter) SupportsBinding(*backendadapter.Binding) bool { return true } + +func (sidecarErrorAdapter) InjectEngineConfig(pod *corev1.PodSpec, _ *backendadapter.Binding, _ *cachev1alpha1.CacheBackend) error { if pod == nil || len(pod.Containers) == 0 { return errors.New("nope") } @@ -1577,7 +1613,7 @@ func (sidecarErrorAdapter) InjectEngineConfig(pod *corev1.PodSpec, _ string, _ * return nil } -func (sidecarErrorAdapter) InjectRouterConfig(*corev1.PodSpec, string, *cachev1alpha1.CacheBackend) error { +func (sidecarErrorAdapter) InjectRouterConfig(*corev1.PodSpec, *backendadapter.Binding, *cachev1alpha1.CacheBackend) error { return nil } @@ -1826,6 +1862,23 @@ func TestHandle_ListError_FailOpen(t *testing.T) { } } +func TestHandle_NilRegistry_FailsOpen(t *testing.T) { + const ns = "engines" + cb := readyCacheBackend("primary", ns, map[string]string{"app": "vllm"}) + s := newScheme(t) + c := fake.NewClientBuilder().WithScheme(s).WithObjects(cb).Build() + h := &EngineInjector{Reader: c, Log: logr.Discard()} + + resp := h.Handle(context.Background(), newRequest(t, + vllmEnginePod("engine-a", map[string]string{"app": "vllm"}), ns)) + if !resp.Allowed || len(resp.Patches) != 0 { + t.Fatalf("nil registry must fail open without patches: Allowed=%v patches=%d", resp.Allowed, len(resp.Patches)) + } + if resp.Result == nil || !strings.Contains(resp.Result.Message, "registry is not configured") { + t.Fatalf("response message = %v, want missing-registry diagnostic", resp.Result) + } +} + func TestHandle_AdapterError_FailOpen(t *testing.T) { const ns = "engines" cb := readyCacheBackend("primary", ns, map[string]string{"app": "vllm"}) @@ -1872,7 +1925,7 @@ func TestHandle_DecodeError_FailOpen(t *testing.T) { func TestHandle_NoBackendForRuntime_FailOpen(t *testing.T) { const ns = "engines" cb := readyCacheBackend("primary", ns, map[string]string{"app": "vllm"}) - cb.Spec.Type = cachev1alpha1.CacheBackendTypeAIBrix // no built-in adapter + cb.Spec.Type = cachev1alpha1.CacheBackendType("unsupported") // no built-in adapter h := newHandler(t, cb) pod := vllmEnginePod("engine-a", map[string]string{"app": "vllm"}) req := newRequest(t, pod, ns) @@ -1893,7 +1946,8 @@ func TestHandle_RegistryOverride_UsedInsteadOfDefault(t *testing.T) { // with the reference env on the container proves the override wins. const ns = "engines" cb := readyCacheBackend("primary", ns, map[string]string{"app": "vllm"}) - cb.Spec.Integration.Engine = string(adapterruntime.RuntimeReference) + cb.Spec.Runtime = "" + cb.Spec.Runtime = cachev1alpha1.CacheBackendRuntime(adapterruntime.RuntimeReference) s := newScheme(t) c := fake.NewClientBuilder().WithScheme(s).WithObjects(cb).Build() reg := adapterruntime.NewRegistry() @@ -2577,11 +2631,11 @@ func TestHandle_KernelCheckInitContainer_AppendedOnOffloadStrict(t *testing.T) { } // TestHandle_EventsOnlyExternal_NoConnectorWiring verifies that an -// admission-bypassed spec.type=External + mode=EventsOnly object gets NO KV +// admission-bypassed external-storage + mode=EventsOnly object gets NO KV // connector wiring. Admission's rejectEventsOnlyMisconfiguration rejects this // pair, so it can only reach the webhook via a stored / bypassed object — but if -// it does, events-only's "no connector" contract must win over spec.type. The -// External adapter's InjectEngineConfig would otherwise inject the LMCache +// it does, events-only's "no connector" contract must win over remote storage. The +// vLLM+LMCache adapter would otherwise inject the LMCache // connector; the webhook skips InjectEngineConfig for events-only regardless of // the selected adapter. func TestHandle_EventsOnlyExternal_NoConnectorWiring(t *testing.T) { @@ -2596,29 +2650,28 @@ func TestHandle_EventsOnlyExternal_NoConnectorWiring(t *testing.T) { UID: types.UID("cb-ext-eo-uid"), }, Spec: cachev1alpha1.CacheBackendSpec{ - Type: cachev1alpha1.CacheBackendTypeExternal, - Endpoint: endpoint, + Runtime: cachev1alpha1.CacheBackendRuntimeVLLM, + Type: cachev1alpha1.CacheBackendTypeLMCache, + RemoteStorage: externalLMCacheStorage(endpoint), Integration: &cachev1alpha1.CacheBackendIntegrationSpec{ - Engine: "vllm", - Mode: cachev1alpha1.CacheBackendIntegrationModeEventsOnly, - Role: cachev1alpha1.CacheBackendIntegrationRoleReadWrite, + Mode: cachev1alpha1.CacheBackendIntegrationModeEventsOnly, + Role: cachev1alpha1.CacheBackendIntegrationRoleReadWrite, }, EngineSelector: &cachev1alpha1.CacheBackendEngineSelector{ MatchLabels: map[string]string{"app": "vllm"}, }, - BackendConfig: map[string]string{"model": "Qwen/Qwen2.5-0.5B-Instruct"}, + Observation: &cachev1alpha1.CacheBackendObservationSpec{ModelID: "Qwen/Qwen2.5-0.5B-Instruct"}, }, Status: cachev1alpha1.CacheBackendStatus{Endpoint: endpoint}, } s := newScheme(t) c := fake.NewClientBuilder().WithScheme(s).WithObjects(cb).Build() - // Build the relevant subset of the built-in composition with External and - // the subscriber image configured, so the events-only sidecar path is live. - reg := adapterruntime.NewCoreRegistry( + // Configure the shipping vLLM adapter's subscriber image so the events-only + // sidecar path is live. + reg := newVLLMRegistry( adapterruntime.WithSubscriberImage(adapterruntime.DefaultSubscriberImage), ) - reg.Register(externaladapter.NewAdapter()) h := &EngineInjector{Reader: c, Registry: reg, Log: logr.Discard()} pod := vllmEnginePod("engine-a", map[string]string{"app": "vllm"}) @@ -2634,7 +2687,7 @@ func TestHandle_EventsOnlyExternal_NoConnectorWiring(t *testing.T) { if engine == nil { t.Fatalf("engine container missing; containers = %v", containerNames(mutated)) } - // No LMCACHE_* env (the External adapter would have set LMCACHE_REMOTE_URL). + // No LMCACHE_* env (the external remote binding would otherwise set LMCACHE_REMOTE_URL). for _, e := range engine.Env { if strings.HasPrefix(e.Name, "LMCACHE_") { t.Fatalf("events-only+External engine container must carry NO LMCACHE_* env; found %s=%q", e.Name, e.Value) @@ -2646,8 +2699,7 @@ func TestHandle_EventsOnlyExternal_NoConnectorWiring(t *testing.T) { t.Fatalf("events-only+External engine container must carry NO --kv-transfer-config; args = %v", engine.Args) } } - // No kernel-check init container either (External adapter has none, but assert - // the contract holds end-to-end). + // No kernel-check init container either; assert the contract end-to-end. for _, ic := range mutated.Spec.InitContainers { if ic.Name == adapterruntime.LMCacheKernelCheckContainerName { t.Fatalf("events-only+External pod must NOT get the kernel-check init container; init containers = %v", diff --git a/internal/webhook/v1alpha1/cachebackend_defaulter_envtest_test.go b/internal/webhook/v1alpha1/cachebackend_defaulter_envtest_test.go index b54d9582..989fbf83 100644 --- a/internal/webhook/v1alpha1/cachebackend_defaulter_envtest_test.go +++ b/internal/webhook/v1alpha1/cachebackend_defaulter_envtest_test.go @@ -7,7 +7,6 @@ import ( "testing" "time" - "k8s.io/apimachinery/pkg/api/resource" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" clientgoscheme "k8s.io/client-go/kubernetes/scheme" @@ -22,15 +21,14 @@ import ( // TestCacheBackendDefaulter_MinimumViableYAMLGetsFullyDefaulted is the // end-to-end pin for the defaulter-sweep operator-UX win: applying a -// CacheBackend with ONLY the three fields the substrate genuinely cannot -// guess (engineSelector, backendConfig.model, and a name) must produce a +// CacheBackend with the required runtime plus an engine selector and model ID +// must produce a // fully-defaulted CR with every Phase-1 default stamped — Type=LMCache, -// DeploymentKind=Deployment, Replicas=1, Integration.Engine=vllm, +// DeploymentKind=Deployment, Replicas=1, // Integration.Role=ReadWrite, Integration.Mode=Offload, -// Integration.FailOpen=true, and bounded legacy Resources. -// Integration.FirstEventTimeout=5m. The apiserver in the loop applies +// Integration.FailOpen=true, and Observation.FirstEventTimeout=5m. The apiserver in the loop applies // `+kubebuilder:default=` markers; the webhook materialises -// spec.integration solely to persist firstEventTimeout. +// spec.integration and spec.observation so their nested defaults persist. // // This test boots a real apiserver via envtest so the CRD-schema defaults // (which a raw-struct unit test cannot exercise) are part of the assertion. @@ -84,9 +82,8 @@ func TestCacheBackendDefaulter_MinimumViableYAMLGetsFullyDefaulted(t *testing.T) if err != nil { t.Fatalf("ctrl.NewManager: %v", err) } - // A nil registry uses defaultShippingRegistry, the same complete built-in - // composition as production cmd/controller wiring. - if err := SetupCacheBackendWebhookWithManager(mgr, nil); err != nil { + // Inject the same complete runtime set as the production composition root. + if err := SetupCacheBackendWebhookWithManager(mgr, defaultShippingRegistry()); err != nil { t.Fatalf("SetupCacheBackendWebhookWithManager: %v", err) } @@ -119,7 +116,7 @@ func TestCacheBackendDefaulter_MinimumViableYAMLGetsFullyDefaulted(t *testing.T) live := mgr.GetAPIReader() mkNamespace(t, ctx, k8s, "team-a") - // --- Minimum-viable CR: engineSelector + backendConfig.model only --- + // --- Minimum-viable CR: runtime + engineSelector + observation.modelID --- // // An apply with no Type, no DeploymentKind, no Replicas, no Integration // block, no Storage, no Autoscaling. Every other field must be stamped @@ -128,12 +125,11 @@ func TestCacheBackendDefaulter_MinimumViableYAMLGetsFullyDefaulted(t *testing.T) mvCR := &cachev1alpha1.CacheBackend{ ObjectMeta: metav1.ObjectMeta{Name: "minimum", Namespace: "team-a"}, Spec: cachev1alpha1.CacheBackendSpec{ + Runtime: cachev1alpha1.CacheBackendRuntimeVLLM, EngineSelector: &cachev1alpha1.CacheBackendEngineSelector{ MatchLabels: map[string]string{"app.kubernetes.io/name": "vllm"}, }, - BackendConfig: map[string]string{ - "model": "meta-llama/Meta-Llama-3-8B-Instruct", - }, + Observation: &cachev1alpha1.CacheBackendObservationSpec{ModelID: "meta-llama/Meta-Llama-3-8B-Instruct"}, }, } if err := k8s.Create(ctx, mvCR); err != nil { @@ -166,9 +162,6 @@ func TestCacheBackendDefaulter_MinimumViableYAMLGetsFullyDefaulted(t *testing.T) if got.Spec.Integration == nil { t.Fatalf("spec.integration was not materialised by the defaulter; got nil") } - if want := "vllm"; got.Spec.Integration.Engine != want { - t.Errorf("spec.integration.engine = %q, want %q (webhook default)", got.Spec.Integration.Engine, want) - } if want := cachev1alpha1.CacheBackendIntegrationRoleReadWrite; got.Spec.Integration.Role != want { t.Errorf("spec.integration.role = %q, want %q (kubebuilder default)", got.Spec.Integration.Role, want) } @@ -178,19 +171,10 @@ func TestCacheBackendDefaulter_MinimumViableYAMLGetsFullyDefaulted(t *testing.T) if got.Spec.Integration.FailOpen == nil || !*got.Spec.Integration.FailOpen { t.Errorf("spec.integration.failOpen = %v, want true (kubebuilder default)", got.Spec.Integration.FailOpen) } - if got.Spec.Integration.FirstEventTimeout == nil || - got.Spec.Integration.FirstEventTimeout.Duration != defaultFirstEventTimeout { - t.Errorf("spec.integration.firstEventTimeout = %v, want %s (defaulter-stamped)", - got.Spec.Integration.FirstEventTimeout, defaultFirstEventTimeout) - } - if got.Spec.Resources == nil { - t.Fatal("spec.resources was not materialised for the legacy resource") - } - if memory := got.Spec.Resources.Requests.Memory(); memory == nil || memory.Cmp(resource.MustParse("4Gi")) != 0 { - t.Errorf("spec.resources.requests.memory = %v, want 4Gi (webhook default)", memory) - } - if memory := got.Spec.Resources.Limits.Memory(); memory == nil || memory.Cmp(resource.MustParse("8Gi")) != 0 { - t.Errorf("spec.resources.limits.memory = %v, want 8Gi (webhook default)", memory) + if got.Spec.Observation == nil || got.Spec.Observation.FirstEventTimeout == nil || + got.Spec.Observation.FirstEventTimeout.Duration != defaultFirstEventTimeout { + t.Errorf("spec.observation.firstEventTimeout = %v, want %s (defaulter-stamped)", + got.Spec.Observation, defaultFirstEventTimeout) } // --- Canonical resources do not inherit legacy provider configuration --- @@ -214,14 +198,11 @@ func TestCacheBackendDefaulter_MinimumViableYAMLGetsFullyDefaulted(t *testing.T) if err := live.Get(ctx, client.ObjectKey{Name: "canonical-host-only", Namespace: "team-a"}, &canonical); err != nil { t.Fatalf("get back canonical host-only CR: %v", err) } - if canonical.Spec.Resources != nil { - t.Errorf("canonical spec.resources = %+v, want nil", canonical.Spec.Resources) - } if canonical.Spec.RemoteStorage != nil { t.Errorf("canonical spec.remoteStorage = %+v, want nil host-only hierarchy", canonical.Spec.RemoteStorage) } - if canonical.Spec.Integration == nil || canonical.Spec.Integration.Engine != "sglang" { - t.Errorf("canonical integration.engine = %v, want derived sglang compatibility value", canonical.Spec.Integration) + if canonical.Spec.Integration == nil { + t.Errorf("canonical integration = nil, want materialised defaults parent") } // --- Non-clobber pin: an explicit CR overrides every default --- @@ -234,18 +215,15 @@ func TestCacheBackendDefaulter_MinimumViableYAMLGetsFullyDefaulted(t *testing.T) explicitCR := &cachev1alpha1.CacheBackend{ ObjectMeta: metav1.ObjectMeta{Name: "explicit", Namespace: "team-a"}, Spec: cachev1alpha1.CacheBackendSpec{ - Type: cachev1alpha1.CacheBackendTypeExternal, + Runtime: cachev1alpha1.CacheBackendRuntimeSGLang, + Type: cachev1alpha1.CacheBackendTypeSGLangHiCache, + HiCache: &cachev1alpha1.SGLangHiCacheSpec{Ratio: "2"}, Replicas: i32p(5), - Endpoint: "team-a-cache.team-a.svc.cluster.local:9000", EngineSelector: &cachev1alpha1.CacheBackendEngineSelector{ - MatchLabels: map[string]string{"app.kubernetes.io/name": "vllm"}, - }, - BackendConfig: map[string]string{ - "model": "meta-llama/Meta-Llama-3-8B-Instruct", + MatchLabels: map[string]string{"app.kubernetes.io/name": "sglang"}, }, Integration: &cachev1alpha1.CacheBackendIntegrationSpec{ - Engine: "vllm", - Role: cachev1alpha1.CacheBackendIntegrationRoleReadOnly, + Role: cachev1alpha1.CacheBackendIntegrationRoleReadWrite, }, }, } @@ -257,15 +235,12 @@ func TestCacheBackendDefaulter_MinimumViableYAMLGetsFullyDefaulted(t *testing.T) if err := live.Get(ctx, client.ObjectKey{Name: "explicit", Namespace: "team-a"}, &explicit); err != nil { t.Fatalf("get back explicit CR: %v", err) } - if explicit.Spec.Type != cachev1alpha1.CacheBackendTypeExternal { - t.Errorf("operator type clobbered: got %q, want External", explicit.Spec.Type) + if explicit.Spec.Type != cachev1alpha1.CacheBackendTypeSGLangHiCache { + t.Errorf("operator type clobbered: got %q, want SGLangHiCache", explicit.Spec.Type) } if explicit.Spec.Replicas == nil || *explicit.Spec.Replicas != 5 { t.Errorf("operator replicas clobbered: got %v, want 5", explicit.Spec.Replicas) } - if explicit.Spec.Integration.Role != cachev1alpha1.CacheBackendIntegrationRoleReadOnly { - t.Errorf("operator integration.role clobbered: got %q, want ReadOnly", explicit.Spec.Integration.Role) - } // --- Autoscaling defaulter-computed minReplicas --- // @@ -278,11 +253,15 @@ func TestCacheBackendDefaulter_MinimumViableYAMLGetsFullyDefaulted(t *testing.T) hpaCR := &cachev1alpha1.CacheBackend{ ObjectMeta: metav1.ObjectMeta{Name: "hpa", Namespace: "team-a"}, Spec: cachev1alpha1.CacheBackendSpec{ + Runtime: cachev1alpha1.CacheBackendRuntimeVLLM, EngineSelector: &cachev1alpha1.CacheBackendEngineSelector{ MatchLabels: map[string]string{"app.kubernetes.io/name": "vllm"}, }, - BackendConfig: map[string]string{ - "model": "meta-llama/Meta-Llama-3-8B-Instruct", + Observation: &cachev1alpha1.CacheBackendObservationSpec{ModelID: "meta-llama/Meta-Llama-3-8B-Instruct"}, + RemoteStorage: &cachev1alpha1.CacheBackendRemoteStorageSpec{ + Provider: cachev1alpha1.CacheBackendRemoteStorageProviderLMCacheServer, + Ownership: cachev1alpha1.CacheBackendRemoteStorageOwnershipManaged, + LMCacheServer: &cachev1alpha1.LMCacheServerRemoteStorageSpec{}, }, Autoscaling: &cachev1alpha1.CacheBackendAutoscalingSpec{ MaxReplicas: 10, @@ -368,7 +347,7 @@ func TestDefaulter_AutoscalingMinReplicasNotRecomputedOnReplicasUpdate(t *testin if err != nil { t.Fatalf("ctrl.NewManager: %v", err) } - if err := SetupCacheBackendWebhookWithManager(mgr, nil); err != nil { + if err := SetupCacheBackendWebhookWithManager(mgr, defaultShippingRegistry()); err != nil { t.Fatalf("SetupCacheBackendWebhookWithManager: %v", err) } @@ -401,12 +380,16 @@ func TestDefaulter_AutoscalingMinReplicasNotRecomputedOnReplicasUpdate(t *testin cb := &cachev1alpha1.CacheBackend{ ObjectMeta: metav1.ObjectMeta{Name: "minfloor", Namespace: "team-a"}, Spec: cachev1alpha1.CacheBackendSpec{ + Runtime: cachev1alpha1.CacheBackendRuntimeVLLM, Replicas: i32p(3), EngineSelector: &cachev1alpha1.CacheBackendEngineSelector{ MatchLabels: map[string]string{"app.kubernetes.io/name": "vllm"}, }, - BackendConfig: map[string]string{ - "model": "meta-llama/Meta-Llama-3-8B-Instruct", + Observation: &cachev1alpha1.CacheBackendObservationSpec{ModelID: "meta-llama/Meta-Llama-3-8B-Instruct"}, + RemoteStorage: &cachev1alpha1.CacheBackendRemoteStorageSpec{ + Provider: cachev1alpha1.CacheBackendRemoteStorageProviderLMCacheServer, + Ownership: cachev1alpha1.CacheBackendRemoteStorageOwnershipManaged, + LMCacheServer: &cachev1alpha1.LMCacheServerRemoteStorageSpec{}, }, Autoscaling: &cachev1alpha1.CacheBackendAutoscalingSpec{ MaxReplicas: 10, diff --git a/internal/webhook/v1alpha1/cachebackend_webhook.go b/internal/webhook/v1alpha1/cachebackend_webhook.go index f06b6919..ef175d51 100644 --- a/internal/webhook/v1alpha1/cachebackend_webhook.go +++ b/internal/webhook/v1alpha1/cachebackend_webhook.go @@ -23,7 +23,6 @@ import ( "sigs.k8s.io/controller-runtime/pkg/webhook/admission" cachev1alpha1 "github.com/cachebox-project/inference-cache/api/v1alpha1" - builtinadapters "github.com/cachebox-project/inference-cache/internal/adapters/builtin" backendadapter "github.com/cachebox-project/inference-cache/pkg/adapters/backend" adapterruntime "github.com/cachebox-project/inference-cache/pkg/adapters/runtime" ) @@ -38,16 +37,11 @@ import ( // by the apiserver before this webhook runs. The webhook handles // context-sensitive defaults and defaults the schema cannot express: // -// - spec.integration.engine: derived from spec.runtime for canonical -// resources; defaults to vllm for legacy resources. -// - spec.resources: legacy resources only retain the historical bounded -// 4Gi request / 8Gi limit. Canonical resources leave this deprecated field -// absent and configure resources under spec.remoteStorage.. -// - spec.integration.firstEventTimeout: the CRD-schema default only fires -// when spec.integration is present in the submitted object; when the -// operator omits integration entirely the webhook materialises it here -// so the persisted CR carries the readiness-gate deadline rather than -// relying on the controller's runtime fallback. +// - spec.observation.firstEventTimeout: the CRD-schema default only fires +// when spec.observation is present in the submitted object; when the +// operator omits observation entirely the webhook materialises it here so +// the persisted CR carries the readiness-gate deadline rather than relying +// on the controller's runtime fallback. // - spec.autoscaling.minReplicas: cluster-context default computed from // spec.replicas at admission so the HPA's floor matches the operator's // baseline declaration rather than a hard-coded constant. @@ -56,11 +50,8 @@ import ( // is the index for the webhook-stamped defaults specifically. const ( // defaultFirstEventTimeout mirrors the +kubebuilder:default on - // spec.integration.firstEventTimeout. The CRD-schema default only applies - // when spec.integration is present in the submitted object; when the - // operator omits integration entirely the webhook materialises it here, so - // stamping the timeout too keeps the persisted CR consistent (rather than - // relying on the controller's runtime fallback). + // spec.observation.firstEventTimeout. The CRD-schema default only applies + // when spec.observation is present in the submitted object. defaultFirstEventTimeout = 5 * time.Minute ) @@ -71,15 +62,10 @@ const ( // markers and are stamped by the apiserver before this handler runs; // the webhook handles context-sensitive and schema-inexpressible defaults: // -// - Defaults spec.integration.engine from spec.runtime, or to vllm for -// legacy resources. -// - Defaults deprecated spec.resources only for legacy resources, preserving -// the historical bounded provider workload without polluting canonical -// resources with a field they do not own. -// - Materialises spec.integration solely to persist -// spec.integration.firstEventTimeout when the operator omits the -// integration block entirely (a CRD-schema default only applies when -// the parent object is present). +// - Materialises spec.integration so its nested schema defaults are applied. +// - Materialises spec.observation to persist +// spec.observation.firstEventTimeout when the operator omits the parent +// block entirely. // - Computes spec.autoscaling.minReplicas from spec.replicas when // autoscaling is opted into and minReplicas is left unset — the HPA // floor needs to follow the workload's baseline declaration, which is @@ -88,7 +74,7 @@ const ( // It does NOT stamp spec.integration.failOpen explicitly — once the // defaulter materialises spec.integration above, the apiserver applies // the `+kubebuilder:default=true` marker on the now-present failOpen -// field (alongside mode, engine, role, firstEventTimeout) before persisting, +// field (alongside mode and role) before persisting, // so an admitted CR with no integration block ends up with failOpen // populated in etcd. The read-time fallback in [IntegrationFailOpen] // covers callers that bypass the apiserver (raw-struct test invocation, @@ -111,25 +97,11 @@ type CacheBackendValidator struct { // [DefaultValidationRules] is used. Rules []ValidationRule - // Registry resolves the runtime adapter for a (runtime, backend) pair - // at admission time. A nil Registry falls back to - // [defaultShippingRegistry], which uses the same complete built-in - // composition as cmd/controller. The - // bare zero value (`&CacheBackendValidator{}`) therefore admits every - // (engine, backend) pair the running controller supports, including - // External and SGLang backends — so admission doesn't silently reject an - // otherwise-valid CR just because the caller forgot to pass a registry. + // Registry resolves the runtime adapter for a (runtime, backend) pair at + // admission time. The composition root must inject it. Registry *adapterruntime.Registry } -// defaultShippingRegistry returns a Registry with every adapter the -// production cmd/controller wiring installs. Centralised in -// internal/adapters/builtin so every nil fallback admits the same set the -// running controller does. -func defaultShippingRegistry() *adapterruntime.Registry { - return builtinadapters.New().Runtime -} - // ValidationRule is the seam plugged-in admission rules implement. It // inspects a single CacheBackend and returns one or more field-scoped // violations, or nil when the rule accepts the spec. Returning @@ -142,11 +114,7 @@ type ValidationRule func(cb *cachev1alpha1.CacheBackend) field.ErrorList // [CacheBackendValidator.Rules]) to extend admission; no other code in the // handler changes. var DefaultValidationRules = []ValidationRule{ - validateCanonicalCacheHierarchy, - validateCanonicalProviderResources, - requireEndpointForExternal, - rejectEndpointOnNonExternal, - rejectInvalidExternalEndpoint, + validateCacheHierarchy, rejectCrossNamespaceEndpointWithoutOptIn, requireExplicitMinReplicasOnScaleToZeroWithAutoscaling, rejectMooncakeMasterScaleOut, @@ -182,116 +150,37 @@ func rejectNonPositiveHostMemoryCapacity(cb *cachev1alpha1.CacheBackend) field.E } } -func validateCanonicalProviderResources(cb *cachev1alpha1.CacheBackend) field.ErrorList { +func selectedProviderResources(cb *cachev1alpha1.CacheBackend) (*corev1.ResourceRequirements, *field.Path) { if cb == nil || cb.Spec.RemoteStorage == nil { - return nil + return nil, nil } - var ( - resources *corev1.ResourceRequirements - path string - ) + storagePath := field.NewPath("spec", "remoteStorage") switch storage := cb.Spec.RemoteStorage; storage.Provider { case cachev1alpha1.CacheBackendRemoteStorageProviderRedis: if storage.Redis != nil { - resources = storage.Redis.Resources - path = "spec.remoteStorage.redis.resources" + return storage.Redis.Resources, storagePath.Child("redis", "resources") } case cachev1alpha1.CacheBackendRemoteStorageProviderLMCacheServer: if storage.LMCacheServer != nil { - resources = storage.LMCacheServer.Resources - path = "spec.remoteStorage.lmCacheServer.resources" + return storage.LMCacheServer.Resources, storagePath.Child("lmCacheServer", "resources") } case cachev1alpha1.CacheBackendRemoteStorageProviderMooncake: if storage.Mooncake != nil { - resources = storage.Mooncake.Resources - path = "spec.remoteStorage.mooncake.resources" - } - } - if resources == nil { - return nil - } - - // Reuse the mature ResourceRequirements validators by presenting the - // provider-owned block through their legacy input seam, then rewrite the - // reported field path back to the canonical owner. - probe := cb.DeepCopy() - probe.Spec.Resources = resources - rules := []ValidationRule{ - rejectResourceLimitsBelowRequests, - rejectRequestsOnlyForNonOvercommittableResources, - rejectResourceClaims, - rejectNegativeResourceQuantities, - rejectInvalidResourceNames, - rejectFractionalExtendedResources, - rejectMisalignedHugepageQuantities, - } - var errs field.ErrorList - for _, rule := range rules { - for _, err := range rule(probe) { - err.Field = strings.Replace(err.Field, "spec.resources", path, 1) - err.Detail = strings.ReplaceAll(err.Detail, "spec.resources", path) - errs = append(errs, err) + return storage.Mooncake.Resources, storagePath.Child("mooncake", "resources") } } - return errs + return nil, nil } -func validateCanonicalCacheHierarchy(cb *cachev1alpha1.CacheBackend) field.ErrorList { +func validateCacheHierarchy(cb *cachev1alpha1.CacheBackend) field.ErrorList { var errs field.ErrorList specPath := field.NewPath("spec") - canonical := cb.Spec.UsesCanonicalCacheHierarchy() - - if cb.Spec.Runtime != "" && cb.Spec.Integration != nil && cb.Spec.Integration.Engine != "" { - legacySpec := cachev1alpha1.CacheBackendSpec{ - Integration: &cachev1alpha1.CacheBackendIntegrationSpec{Engine: cb.Spec.Integration.Engine}, - } - legacy := legacySpec.EffectiveRuntime() - if legacy != cb.Spec.EffectiveRuntime() { - errs = append(errs, field.Invalid( - specPath.Child("integration", "engine"), cb.Spec.Integration.Engine, - "conflicts with spec.runtime; use spec.runtime as the runtime identity", - )) - } - } - if canonical { - switch cb.Spec.Type { - case cachev1alpha1.CacheBackendTypeMooncake, cachev1alpha1.CacheBackendTypeExternal: - errs = append(errs, field.Invalid( - specPath.Child("type"), cb.Spec.Type, - "the canonical API uses spec.type only for the engine-side cache; move provider and ownership to spec.remoteStorage", - )) - } - if len(cb.Spec.BackendConfig) > 0 { - errs = append(errs, field.Forbidden( - specPath.Child("backendConfig"), - "deprecated top-level configuration is not valid in the canonical API; use spec.lmCache, spec.remoteStorage., or spec.observation", - )) - } - if cb.Spec.Resources != nil { - errs = append(errs, field.Forbidden( - specPath.Child("resources"), - "deprecated top-level resources are not valid in the canonical API; use spec.remoteStorage..resources", - )) - } - if cb.Spec.RemoteStorage == nil && cb.Spec.Autoscaling != nil { - errs = append(errs, field.Forbidden( - specPath.Child("autoscaling"), - "canonical host-only backends omit spec.remoteStorage and provision no provider workload, so there is nothing to autoscale", - )) - } - } - - if !canonical && - cb.Spec.Observation != nil && - cb.Spec.Observation.ModelID != "" && - cb.Spec.BackendConfig["model"] != "" && - cb.Spec.Observation.ModelID != cb.Spec.BackendConfig["model"] { - errs = append(errs, field.Invalid( - specPath.Child("backendConfig").Key("model"), - cb.Spec.BackendConfig["model"], - "conflicts with spec.observation.modelID", + if cb.Spec.RemoteStorage == nil && cb.Spec.Autoscaling != nil { + errs = append(errs, field.Forbidden( + specPath.Child("autoscaling"), + "host-only backends omit spec.remoteStorage and provision no provider workload, so there is nothing to autoscale", )) } @@ -460,13 +349,9 @@ func validateSGLangHiCache(cb *cachev1alpha1.CacheBackend) field.ErrorList { } if adapterruntime.ResolveRuntimeID(cb) != adapterruntime.RuntimeSGLang { - value := "" - if cb.Spec.Integration != nil { - value = cb.Spec.Integration.Engine - } errs = append(errs, field.Invalid( - field.NewPath("spec", "integration", "engine"), value, - "SGLangHiCache requires integration.engine=sglang", + field.NewPath("spec", "runtime"), cb.Spec.Runtime, + "SGLangHiCache requires runtime=SGLang", )) } if cb.Spec.EngineSelector == nil || len(cb.Spec.EngineSelector.MatchLabels) == 0 { @@ -505,15 +390,6 @@ func validateSGLangHiCache(cb *cachev1alpha1.CacheBackend) field.ErrorList { )) } } - for key := range cb.Spec.BackendConfig { - if key != "model" { - errs = append(errs, field.NotSupported( - field.NewPath("spec", "backendConfig").Key(key), - key, - []string{"model"}, - )) - } - } return errs } @@ -669,13 +545,13 @@ func rejectInvalidKernelCheckAnnotation(cb *cachev1alpha1.CacheBackend) field.Er // // registry is the runtime-adapter [adapterruntime.Registry] the validator // consults for the (engine, backend) compatibility check AND for the -// engineOverrides reserved-args/env check; passing nil falls back to -// [defaultShippingRegistry] (the complete internal/adapters/builtin -// composition), mirroring cmd/controller's production wiring so a zero-value -// validator sees the same adapter set the running controller does. -// cmd/controller threads the same instance the reconciler + pod webhook -// receive so all three layers agree on what's supported. +// engineOverrides reserved-args/env check. cmd/controller threads the same +// non-nil instance the reconciler + pod webhook receive so all three layers +// agree on what's supported. func SetupCacheBackendWebhookWithManager(mgr ctrl.Manager, registry *adapterruntime.Registry) error { + if registry == nil { + return fmt.Errorf("runtime adapter registry is required") + } return ctrl.NewWebhookManagedBy(mgr, &cachev1alpha1.CacheBackend{}). WithDefaulter(&CacheBackendDefaulter{}). WithValidator(&CacheBackendValidator{Registry: registry}). @@ -687,16 +563,15 @@ func SetupCacheBackendWebhookWithManager(mgr ctrl.Manager, registry *adapterrunt // Default implements [admission.Defaulter]. It applies the defaults the // CRD-schema markers cannot express: // -// - Materialises spec.integration when omitted so spec.integration. -// firstEventTimeout carries the readiness-gate deadline (the -// `+kubebuilder:default` only fires when the parent object is present). -// - Stamps the historical bounded spec.resources default on legacy -// resources only; canonical resources use provider-owned resource blocks. +// - Materialises spec.integration when omitted so its nested schema defaults +// are applied. +// - Materialises spec.observation when omitted so +// spec.observation.firstEventTimeout carries the readiness-gate deadline. // - Computes spec.autoscaling.minReplicas from spec.replicas when // autoscaling is opted in and minReplicas is left unset. // // Every other Phase-1 default (spec.type=LMCache, deploymentKind=Deployment, -// replicas=1, integration.engine=vllm, integration.mode=Offload, +// replicas=1, integration.mode=Offload, // // integration.role=ReadWrite, // @@ -705,10 +580,9 @@ func SetupCacheBackendWebhookWithManager(mgr ctrl.Manager, registry *adapterrunt // integration.* markers only fire when spec.integration is already present // in the submitted object — when the operator omits the integration block // entirely the apiserver has nothing to apply nested defaults to, which is -// why the webhook materialises the parent below (and the read-time -// helpers in [adapterruntime.ResolveRuntimeID] / [enginewire.IntegrationRole] -// / [IntegrationFailOpen] provide the same effective default at read time -// for callers that don't go through admission). +// why the webhook materialises the parent below. Callers that bypass +// admission use the API's read-time mode and fail-open helpers and the +// built-in adapters' equivalent ReadWrite role fallback. // // A non-nil pointer or non-empty value is treated as an explicit operator // choice and left alone, preserving the established "defaulter never @@ -717,40 +591,14 @@ func (d *CacheBackendDefaulter) Default(ctx context.Context, cb *cachev1alpha1.C logf.FromContext(ctx).V(1).Info("defaulting CacheBackend", "namespace", cb.Namespace, "name", cb.Name) - canonical := cb.Spec.UsesCanonicalCacheHierarchy() - if !canonical && cb.Spec.Resources == nil { - cb.Spec.Resources = &corev1.ResourceRequirements{ - Requests: corev1.ResourceList{ - corev1.ResourceMemory: resource.MustParse("4Gi"), - }, - Limits: corev1.ResourceList{ - corev1.ResourceMemory: resource.MustParse("8Gi"), - }, - } - } if cb.Spec.Integration == nil { cb.Spec.Integration = &cachev1alpha1.CacheBackendIntegrationSpec{} } - if cb.Spec.Integration.Engine == "" { - if cb.Spec.Runtime != "" { - cb.Spec.Integration.Engine = strings.ToLower(string(cb.Spec.EffectiveRuntime())) - } else { - cb.Spec.Integration.Engine = "vllm" - } + if cb.Spec.Observation == nil { + cb.Spec.Observation = &cachev1alpha1.CacheBackendObservationSpec{} } - if canonical { - if cb.Spec.Observation == nil { - cb.Spec.Observation = &cachev1alpha1.CacheBackendObservationSpec{} - } - if cb.Spec.Observation.FirstEventTimeout == nil { - timeout := defaultFirstEventTimeout - if cb.Spec.Integration.FirstEventTimeout != nil { - timeout = cb.Spec.Integration.FirstEventTimeout.Duration - } - cb.Spec.Observation.FirstEventTimeout = &metav1.Duration{Duration: timeout} - } - } else if cb.Spec.Integration.FirstEventTimeout == nil { - cb.Spec.Integration.FirstEventTimeout = &metav1.Duration{Duration: defaultFirstEventTimeout} + if cb.Spec.Observation.FirstEventTimeout == nil { + cb.Spec.Observation.FirstEventTimeout = &metav1.Duration{Duration: defaultFirstEventTimeout} } // autoscaling.minReplicas defaults to spec.replicas when autoscaling is @@ -881,9 +729,8 @@ func usesMooncakeStorage(cb *cachev1alpha1.CacheBackend) bool { // This is the standard pattern for tightening admission rules on a // v1alpha1 CRD: create-time is strict; update-time only rejects fresh // violations so existing CRs aren't trapped. Without it, adding a new -// rule (e.g. rejectEndpointOnNonExternal) would break every existing CR -// that happens to violate it the moment an operator runs `kubectl -// annotate` on it. +// field-level rule would break every existing CR that happens to violate it +// the moment an operator runs `kubectl annotate` on it. func (v *CacheBackendValidator) ValidateUpdate(ctx context.Context, oldCB, newCB *cachev1alpha1.CacheBackend) (admission.Warnings, error) { logf.FromContext(ctx).V(1).Info("validating CacheBackend update", "namespace", newCB.Namespace, "name", newCB.Name, "type", newCB.Spec.Type) @@ -992,26 +839,15 @@ func filterIntroducedErrors(oldErrs, newErrs field.ErrorList) field.ErrorList { return out } -// checkRuntimeAdapter rejects a CacheBackend whose effective (engine, type) -// pair no installed runtime adapter supports. The effective engine is +// checkRuntimeAdapter rejects a CacheBackend whose (runtime, type) pair no +// installed runtime adapter supports. The runtime is // resolved through [adapterruntime.ResolveRuntimeID] — the same helper the // reconciler and pod-mutating webhook consult — so admission, reconcile, // and pod injection agree on which adapter the registry should pick. In -// particular, an unset engine defaults to vLLM here just as it does at -// reconcile, so a CR with an unsupported type (e.g. `type: AIBrix`) and no -// engine no longer slips past admission only to fail downstream. -// -// External backends flow through this check the same way managed types -// do: they have a real runtime adapter (vllm-only today, see -// pkg/adapters/runtime/external), and the pod-mutating webhook calls -// it to wire engine pods. A CR with `type: External, engine: sglang` -// would be admitted into a state the pod webhook can't realise — the -// engine pod would silently boot un-wired to the external cache — -// without this check. Admission reject is the right surface: the -// reconciler still short-circuits External via reconcileExternal before -// any adapter lookup, so the only consumer of the (engine, External) -// pair is the pod webhook, and admission rejecting upstream of it gives -// the operator a useful error instead of a silent miss. +// particular, an unset runtime remains empty and cannot match an adapter; +// persisted resources cannot reach that state because the CRD schema requires +// spec.runtime. Remote-storage provider and ownership do not affect runtime +// adapter selection; they are validated independently as bindings. // // The check is bypassed only when Spec.Type is empty: a CR that came // through admission carries `+kubebuilder:default=LMCache` stamped by @@ -1026,7 +862,10 @@ func (v *CacheBackendValidator) checkRuntimeAdapter(cb *cachev1alpha1.CacheBacke } registry := v.Registry if registry == nil { - registry = defaultShippingRegistry() + return field.ErrorList{field.InternalError( + field.NewPath("spec", "runtime"), + fmt.Errorf("runtime adapter registry is not configured"), + )} } runtimeID := adapterruntime.ResolveRuntimeID(cb) adapter, err := registry.Select(runtimeID, cb) @@ -1036,22 +875,13 @@ func (v *CacheBackendValidator) checkRuntimeAdapter(cb *cachev1alpha1.CacheBacke // future error class should surface as-is rather than be // rewritten as an unsupported-pair message. return field.ErrorList{ - field.InternalError(field.NewPath("spec", "integration", "engine"), err), + field.InternalError(field.NewPath("spec", "runtime"), err), } } - // Field path points at spec.integration.engine even when the user - // did not set it — the offending knob is "which runtime should we - // wire to this backend", which the resolver answered for them - // using the default. Reporting the resolved value (not "") in the - // message gives the user the literal pair to fix. - shownValue := "" - if cb.Spec.Integration != nil { - shownValue = cb.Spec.Integration.Engine - } return field.ErrorList{ field.Invalid( - field.NewPath("spec", "integration", "engine"), - shownValue, + field.NewPath("spec", "runtime"), + cb.Spec.Runtime, unsupportedPairMessage(runtimeID, cb.Spec.Type, registry), ), } @@ -1066,7 +896,7 @@ func (v *CacheBackendValidator) checkRuntimeAdapter(cb *cachev1alpha1.CacheBacke )} } binding := backendadapter.BindingFor(storage, protocol, "") - if err := adapterruntime.ValidateRemoteBinding(adapter, binding, cb); err != nil { + if !adapter.SupportsBinding(binding) { storagePath := field.NewPath("spec", "remoteStorage") var rejectedValue any if storage != nil { @@ -1076,8 +906,8 @@ func (v *CacheBackendValidator) checkRuntimeAdapter(cb *cachev1alpha1.CacheBacke return field.ErrorList{field.Invalid( storagePath, rejectedValue, - fmt.Sprintf("runtime %s with cache type %s does not accept this remote-storage binding: %v", - runtimeID, cb.Spec.EffectiveCacheType(), err), + fmt.Sprintf("runtime %s with cache type %s does not accept remote-storage protocol %q", + runtimeID, cb.Spec.EffectiveCacheType(), protocol), )} } return nil @@ -1114,18 +944,6 @@ func (v *CacheBackendValidator) checkEngineOverrides(cb *cachev1alpha1.CacheBack // avoid — so we reject the CR up front. errs = append(errs, checkEngineOverrideEnvShape(overrides.Env, basePath.Child("env"))...) - // External backends flow through this check the same way managed - // types do: the External adapter declares its own ReservedArgs / - // ReservedEnv (mirroring the managed-LMCache wire it shares), and - // the pod webhook calls the adapter for engine pods that match an - // External CR's spec.engineSelector. Suppressing - // `--kv-transfer-config` or overriding `LMCACHE_REMOTE_URL` on an - // External CR would silently un-wire the cache exactly the way it - // would on a managed CR — admission must catch it at write time, - // not let the engine crash later. The earlier in-place External - // skip was load-bearing only when External had no adapter; it - // became a backdoor the moment the adapter shipped. - // // Bypassed only for an empty spec.type — the structural rules // already reject that, and piling an "adapter for backend=\"\"" // cause on top would not help the user. @@ -1134,14 +952,10 @@ func (v *CacheBackendValidator) checkEngineOverrides(cb *cachev1alpha1.CacheBack } registry := v.Registry if registry == nil { - // Mirror checkRuntimeAdapter's fallback exactly: a nil-registry - // validator must see the same adapter set in BOTH checks, or - // External admits in checkRuntimeAdapter (via the External adapter - // in defaultShippingRegistry) and then silently skips its - // reserved-arg/env enforcement here. That would let an External - // CR suppress `--kv-transfer-config` or override - // `LMCACHE_REMOTE_URL` and un-wire the cache at the engine pod. - registry = defaultShippingRegistry() + return append(errs, field.InternalError( + basePath, + fmt.Errorf("runtime adapter registry is not configured"), + )) } runtimeID := adapterruntime.ResolveRuntimeID(cb) adapter, err := registry.Select(runtimeID, cb) @@ -1364,127 +1178,20 @@ func unsupportedPairMessage(engine adapterruntime.RuntimeID, backend cachev1alph ) } -// requireEndpointForExternal rejects an External backend that has no -// Endpoint set. An External backend is a pre-existing service the -// controller only mirrors to status — without an address there is nothing -// to mirror and the spec is structurally incomplete. -func requireEndpointForExternal(cb *cachev1alpha1.CacheBackend) field.ErrorList { - if cb.Spec.Type != cachev1alpha1.CacheBackendTypeExternal { - return nil - } - if strings.TrimSpace(cb.Spec.Endpoint) != "" { - return nil - } - return field.ErrorList{ - field.Required( - field.NewPath("spec", "endpoint"), - "CacheBackend with spec.type=External requires spec.endpoint to be set to the address of the pre-existing backend", - ), - } -} - -// rejectEndpointOnNonExternal rejects a non-External backend that carries a -// non-empty spec.endpoint. The field is meaningful only for the External -// passthrough adapter — for managed types the controller overwrites -// status.endpoint from the live Service it provisions, so a user-supplied -// spec.endpoint would be silently ignored. Hard-rejecting at admission -// makes the misconfiguration visible at write time instead of leaving the -// operator wondering why their endpoint never took effect. -// -// An empty spec.type is left to the External-required rule and CRD-level -// validation; piling a "remove spec.endpoint" cause on top of a missing- -// type rejection would not help the user. -func rejectEndpointOnNonExternal(cb *cachev1alpha1.CacheBackend) field.ErrorList { - if cb.Spec.Type == "" || cb.Spec.Type == cachev1alpha1.CacheBackendTypeExternal { - return nil - } - if strings.TrimSpace(cb.Spec.Endpoint) == "" { - return nil - } - // field.Invalid (not field.Forbidden) so the bad endpoint flows into - // the error's BadValue. ValidateUpdate's diff-vs-old logic keys on - // (Type, Field, BadValue, Detail); using Invalid lets it distinguish - // "operator edited the bad endpoint to a different bad endpoint" - // (newly-introduced violation, reject) from "operator left the same - // bad endpoint in place and changed only an unrelated field" (no - // fresh violation, allow). field.Forbidden has BadValue="forbidden" - // regardless of the actual value and would collapse the two cases. - return field.ErrorList{ - field.Invalid( - field.NewPath("spec", "endpoint"), - cb.Spec.Endpoint, - fmt.Sprintf("spec.endpoint is only valid when spec.type=External; got spec.type=%q with non-empty spec.endpoint. Managed backends learn their endpoint from the controller-rendered Service.", cb.Spec.Type), - ), - } -} - -// rejectInvalidExternalEndpoint rejects an External CacheBackend whose -// spec.endpoint fails the shared LMCache endpoint shape check — -// unsupported scheme, missing port, embedded whitespace, unbracketed -// IPv6, path/query/fragment components, or any other shape that would -// produce an LMCACHE_REMOTE_URL the engine connector refuses at startup. -// Catches the misconfiguration loudly at write time instead of leaving -// the operator to discover it from engine-pod crash logs. -// -// Allowed forms (see [adapterruntime.ValidateLMCacheEndpoint] for the -// full shape contract — admission, the C2 reconciler, and the pod -// webhook all call the same helper so the three layers agree): -// - bare `host:port` (the canonical shape — the helper adds the -// `lm://` scheme on injection) -// - `lm://host:port` (operators who prefer to be explicit) -// - bracketed IPv6 (`[::1]:8200`) -// -// Empty endpoint is left to [requireEndpointForExternal]; non-External -// types are left to [rejectEndpointOnNonExternal]. -// -// A future SGLang-shaped External adapter (different engine wire) will -// have its own shape rules; this rule narrows on `Type == External` -// only because the vLLM wire is the only one we ship today. -func rejectInvalidExternalEndpoint(cb *cachev1alpha1.CacheBackend) field.ErrorList { - if cb.Spec.Type != cachev1alpha1.CacheBackendTypeExternal { - return nil - } - if strings.TrimSpace(cb.Spec.Endpoint) == "" { - return nil // requireEndpointForExternal handles this - } - if err := adapterruntime.ValidateLMCacheEndpoint(cb.Spec.Endpoint); err != nil { - // Wrap the helper's plain error in a field-scoped Invalid so - // kubectl prints the field path alongside the message. The - // reconciler and pod webhook call the same helper and act on - // the raw error (degrade Ready, fail-open). - return field.ErrorList{ - field.Invalid( - field.NewPath("spec", "endpoint"), - cb.Spec.Endpoint, - "spec."+err.Error(), - ), - } - } - return nil -} - // rejectEventsOnlyMisconfiguration enforces the constraints of the events-only // (tier-1 routing) integration mode. EventsOnly provisions no backend server // and wires no KV connector, so server-shaped configuration is structurally // meaningless: // -// - spec.type must be LMCache (the default): LMCache's adapter supplies the -// kvevent-subscriber the routing tier needs, and its in-memory server is a -// no-op when no connector is wired. Every other type is contradictory — -// External wires an operator-run offload server the (absent) connector -// would dial, and a managed Mooncake backend stands up a mooncake_master -// store nothing would use. So LMCache is the ONLY supported events-only -// managed type. This must be checked explicitly now that a second managed -// adapter (vLLM, Mooncake) is registered: before it shipped, non-LMCache -// managed types were caught by the runtime-adapter check, but Mooncake now -// passes that check and would otherwise be admitted in events-only mode. +// - spec.type must be LMCache (the default), whose adapter supplies the +// kvevent-subscriber the routing tier needs. // - spec.remoteStorage requests an offload provider that the controller // deliberately removes in events-only mode. // - spec.autoscaling has no workload to scale — the controller deploys // nothing for an events-only backend. // -// spec.endpoint is already rejected on any non-External backend by -// rejectEndpointOnNonExternal, so it needs no events-only-specific check. +// spec.remoteStorage.endpoint is already forbidden for managed ownership by +// validateCacheHierarchy, so it needs no events-only-specific check. func rejectEventsOnlyMisconfiguration(cb *cachev1alpha1.CacheBackend) field.ErrorList { if !cb.Spec.IsEventsOnly() { return nil @@ -1494,20 +1201,12 @@ func rejectEventsOnlyMisconfiguration(cb *cachev1alpha1.CacheBackend) field.Erro case "", cachev1alpha1.CacheBackendTypeLMCache: // LMCache is the supported events-only managed type; an empty type // defaults to LMCache via the CRD marker, so both are allowed. - case cachev1alpha1.CacheBackendTypeExternal: - errs = append(errs, field.Forbidden( - field.NewPath("spec", "integration", "mode"), - fmt.Sprintf("mode %q is incompatible with spec.type %q: events-only wires no KV connector, while External provisions an operator-run offload server the connector would dial", - cachev1alpha1.CacheBackendIntegrationModeEventsOnly, cachev1alpha1.CacheBackendTypeExternal), - )) default: - // Any other managed type (Mooncake today, and any future adapter): - // events-only wires no KV connector, so a backend that provisions an - // offload store has nothing the mode would use. LMCache is the only - // supported events-only managed type. + // Any other engine-cache type cannot supply the vLLM event stream + // contract this mode currently implements. errs = append(errs, field.Forbidden( field.NewPath("spec", "integration", "mode"), - fmt.Sprintf("mode %q is only supported with spec.type %q; got spec.type %q. Events-only wires no KV connector, so a managed backend that provisions an offload store (e.g. a Mooncake master) has nothing the mode would use", + fmt.Sprintf("mode %q is only supported with spec.type %q; got spec.type %q. Events-only wires no KV connector", cachev1alpha1.CacheBackendIntegrationModeEventsOnly, cachev1alpha1.CacheBackendTypeLMCache, cb.Spec.Type), )) } @@ -1532,17 +1231,15 @@ func rejectEventsOnlyMisconfiguration(cb *cachev1alpha1.CacheBackend) field.Erro // resolves into a Service in a namespace other than the CacheBackend's // own, unless spec.allowCrossNamespace is true. Crossing a namespace is // a tenancy boundary the operator should explicitly acknowledge; the -// rule covers both canonical spec.remoteStorage.endpoint and deprecated -// spec.endpoint, and fires only when the endpoint is a recognisable in-cluster -// Service DNS. External hostnames and IPs pass through because they expose no +// rule covers spec.remoteStorage.endpoint and fires only when the endpoint is +// a recognisable in-cluster Service DNS. External hostnames and IPs pass through because they expose no // namespace to compare against. func rejectCrossNamespaceEndpointWithoutOptIn(cb *cachev1alpha1.CacheBackend) field.ErrorList { - endpoint := cb.Spec.Endpoint - endpointPath := field.NewPath("spec", "endpoint") - if cb.Spec.RemoteStorage != nil && strings.TrimSpace(cb.Spec.RemoteStorage.Endpoint) != "" { - endpoint = cb.Spec.RemoteStorage.Endpoint - endpointPath = field.NewPath("spec", "remoteStorage", "endpoint") + if cb.Spec.RemoteStorage == nil { + return nil } + endpoint := cb.Spec.RemoteStorage.Endpoint + endpointPath := field.NewPath("spec", "remoteStorage", "endpoint") ns, ok := serviceDNSNamespace(endpoint) if !ok { @@ -1627,15 +1324,15 @@ func rejectMooncakeMasterScaleOut(cb *cachev1alpha1.CacheBackend) field.ErrorLis if cb.Spec.Autoscaling != nil { errs = append(errs, field.Invalid( field.NewPath("spec", "autoscaling"), cb.Spec.Autoscaling, - "spec.autoscaling is not supported for type=Mooncake: the master is a singleton on the host network, so scaling it out either cannot bind "+ + "spec.autoscaling is not supported for remoteStorage.provider=Mooncake: the master is a singleton on the host network, so scaling it out either cannot bind "+ "the node's ports or splits the store across independent masters. Remove spec.autoscaling.", )) } return errs } -// rejectResourceLimitsBelowRequests rejects spec.resources where the -// request/limit relationship is invalid for the named resource. K8s +// rejectResourceLimitsBelowRequests rejects the selected provider resource +// block when the request/limit relationship is invalid for the named resource. K8s // distinguishes two regimes: // // - Overcommittable resources (cpu, memory, ephemeral-storage): @@ -1654,16 +1351,17 @@ func rejectMooncakeMasterScaleOut(cb *cachev1alpha1.CacheBackend) field.ErrorLis // at `kubectl apply`. Missing Request OR missing Limit has no // comparison to make and admits. func rejectResourceLimitsBelowRequests(cb *cachev1alpha1.CacheBackend) field.ErrorList { - if cb.Spec.Resources == nil { + resources, resourcesPath := selectedProviderResources(cb) + if resources == nil { return nil } var errs field.ErrorList - for name, req := range cb.Spec.Resources.Requests { - lim, ok := cb.Spec.Resources.Limits[name] + for name, req := range resources.Requests { + lim, ok := resources.Limits[name] if !ok { continue } - path := field.NewPath("spec", "resources", "limits").Key(string(name)) + path := resourcesPath.Child("limits").Key(string(name)) if isOvercommittableResource(name) { if lim.Cmp(req) >= 0 { continue @@ -1671,7 +1369,7 @@ func rejectResourceLimitsBelowRequests(cb *cachev1alpha1.CacheBackend) field.Err errs = append(errs, field.Invalid( path, lim.String(), - fmt.Sprintf("must be greater than or equal to spec.resources.requests[%s] (%s)", name, req.String()), + fmt.Sprintf("must be greater than or equal to %s[%s] (%s)", resourcesPath.Child("requests"), name, req.String()), )) continue } @@ -1682,7 +1380,7 @@ func rejectResourceLimitsBelowRequests(cb *cachev1alpha1.CacheBackend) field.Err errs = append(errs, field.Invalid( path, lim.String(), - fmt.Sprintf("must equal spec.resources.requests[%s] (%s) — %q is a non-overcommittable resource (hugepages and extended resources require request == limit)", name, req.String(), name), + fmt.Sprintf("must equal %s[%s] (%s) — %q is a non-overcommittable resource (hugepages and extended resources require request == limit)", resourcesPath.Child("requests"), name, req.String(), name), )) } return errs @@ -1717,7 +1415,8 @@ func isOvercommittableResource(name corev1.ResourceName) bool { // take any kubelet-valid quantity, and vendor-prefixed extended // resources are integer-checked by rejectFractionalExtendedResources. func rejectMisalignedHugepageQuantities(cb *cachev1alpha1.CacheBackend) field.ErrorList { - if cb.Spec.Resources == nil { + resources, resourcesPath := selectedProviderResources(cb) + if resources == nil { return nil } const hugePagesPrefix = "hugepages-" @@ -1746,15 +1445,15 @@ func rejectMisalignedHugepageQuantities(cb *cachev1alpha1.CacheBackend) field.Er } if qtyVal%pageVal != 0 { errs = append(errs, field.Invalid( - field.NewPath("spec", "resources", kind).Key(s), + resourcesPath.Child(kind).Key(s), qty.String(), fmt.Sprintf("must be a multiple of the page size %s — the Linux kernel allocates hugepages in whole-page chunks", suffix), )) } } } - check(cb.Spec.Resources.Requests, "requests") - check(cb.Spec.Resources.Limits, "limits") + check(resources.Requests, "requests") + check(resources.Limits, "limits") return errs } @@ -1773,7 +1472,8 @@ func rejectMisalignedHugepageQuantities(cb *cachev1alpha1.CacheBackend) field.Er // quantity is also non-fractional by construction but we don't gate // on quantity here. func rejectFractionalExtendedResources(cb *cachev1alpha1.CacheBackend) field.ErrorList { - if cb.Spec.Resources == nil { + resources, resourcesPath := selectedProviderResources(cb) + if resources == nil { return nil } var errs field.ErrorList @@ -1787,20 +1487,20 @@ func rejectFractionalExtendedResources(cb *cachev1alpha1.CacheBackend) field.Err } if _, ok := qty.AsInt64(); !ok { errs = append(errs, field.Invalid( - field.NewPath("spec", "resources", kind).Key(string(name)), + resourcesPath.Child(kind).Key(string(name)), qty.String(), fmt.Sprintf("%q is an extended resource and must be an integer quantity — K8s allocates extended resources by whole units", name), )) } } } - check(cb.Spec.Resources.Requests, "requests") - check(cb.Spec.Resources.Limits, "limits") + check(resources.Requests, "requests") + check(resources.Limits, "limits") return errs } -// rejectInvalidResourceNames rejects any spec.resources.requests or -// spec.resources.limits key that fails the K8s container-resource-name +// rejectInvalidResourceNames rejects any selected provider resources.requests or +// resources.limits key that fails the K8s container-resource-name // rules. The CRD schema treats ResourceList keys as opaque strings, so // an invalid name persists in etcd and only fails when the apiserver // rejects the rendered child pod. Rejecting at admission turns that @@ -1819,7 +1519,8 @@ func rejectFractionalExtendedResources(cb *cachev1alpha1.CacheBackend) field.Err // apply the same rule here so the rejection is consistent with what // the rendered Pod would face downstream. func rejectInvalidResourceNames(cb *cachev1alpha1.CacheBackend) field.ErrorList { - if cb.Spec.Resources == nil { + resources, resourcesPath := selectedProviderResources(cb) + if resources == nil { return nil } var errs field.ErrorList @@ -1827,15 +1528,15 @@ func rejectInvalidResourceNames(cb *cachev1alpha1.CacheBackend) field.ErrorList for name := range list { if msg, ok := validateContainerResourceName(name); !ok { errs = append(errs, field.Invalid( - field.NewPath("spec", "resources", kind).Key(string(name)), + resourcesPath.Child(kind).Key(string(name)), string(name), msg, )) } } } - check(cb.Spec.Resources.Requests, "requests") - check(cb.Spec.Resources.Limits, "limits") + check(resources.Requests, "requests") + check(resources.Limits, "limits") return errs } @@ -1889,7 +1590,7 @@ func validateContainerResourceName(name corev1.ResourceName) (string, bool) { } // rejectNegativeResourceQuantities rejects any strictly-negative -// quantity in spec.resources.requests or spec.resources.limits. The +// quantity in the selected provider resources.requests or resources.limits. The // CRD schema serialises each entry as a resource.Quantity string, which // admits a leading "-" without complaint at structural validation — // the apiserver's Pod resource validator later rejects the pod with a @@ -1903,7 +1604,8 @@ func validateContainerResourceName(name corev1.ResourceName) (string, bool) { // kubelet's default treatment of a missing request and a reasonable // shape to admit verbatim. func rejectNegativeResourceQuantities(cb *cachev1alpha1.CacheBackend) field.ErrorList { - if cb.Spec.Resources == nil { + resources, resourcesPath := selectedProviderResources(cb) + if resources == nil { return nil } var errs field.ErrorList @@ -1913,21 +1615,21 @@ func rejectNegativeResourceQuantities(cb *cachev1alpha1.CacheBackend) field.Erro continue } errs = append(errs, field.Invalid( - field.NewPath("spec", "resources", kind).Key(string(name)), + resourcesPath.Child(kind).Key(string(name)), qty.String(), "must be a non-negative quantity", )) } } - check(cb.Spec.Resources.Requests, "requests") - check(cb.Spec.Resources.Limits, "limits") + check(resources.Requests, "requests") + check(resources.Limits, "limits") return errs } // rejectRequestsOnlyForNonOvercommittableResources rejects a non- // overcommittable resource (hugepages-*, vendor-prefixed extended -// resource) declared in `spec.resources.requests` without a matching -// entry in `spec.resources.limits`. K8s requires both halves for +// resource) declared in the selected provider `resources.requests` without a +// matching entry in `resources.limits`. K8s requires both halves for // non-overcommittable resources — the kubelet allocates whole pages // or devices, so the request and limit must be declared together and // be equal. Limits-only IS admitted by K8s (the apiserver auto- @@ -1937,28 +1639,29 @@ func rejectNegativeResourceQuantities(cb *cachev1alpha1.CacheBackend) field.Erro // requests-only cpu / memory shape is the canonical kubelet "no upper // bound" pattern. func rejectRequestsOnlyForNonOvercommittableResources(cb *cachev1alpha1.CacheBackend) field.ErrorList { - if cb.Spec.Resources == nil { + resources, resourcesPath := selectedProviderResources(cb) + if resources == nil { return nil } var errs field.ErrorList - for name := range cb.Spec.Resources.Requests { + for name := range resources.Requests { if isOvercommittableResource(name) { continue } - if _, ok := cb.Spec.Resources.Limits[name]; ok { + if _, ok := resources.Limits[name]; ok { continue } - qty := cb.Spec.Resources.Requests[name] + qty := resources.Requests[name] errs = append(errs, field.Invalid( - field.NewPath("spec", "resources", "requests").Key(string(name)), + resourcesPath.Child("requests").Key(string(name)), qty.String(), - fmt.Sprintf("%q is a non-overcommittable resource — it must also be set in spec.resources.limits with the same value (hugepages and extended resources require requests and limits to be declared together)", name), + fmt.Sprintf("%q is a non-overcommittable resource — it must also be set in %s with the same value (hugepages and extended resources require requests and limits to be declared together)", name, resourcesPath.Child("limits")), )) } return errs } -// rejectResourceClaims rejects a non-empty spec.resources.claims slice. +// rejectResourceClaims rejects a non-empty selected provider resources.claims slice. // corev1.ResourceRequirements exposes Claims for the Dynamic Resource // Allocation (DRA) feature, but the runtime adapter only copies // Container.Resources onto the rendered pod template — it does NOT @@ -1972,13 +1675,14 @@ func rejectRequestsOnlyForNonOvercommittableResources(cb *cachev1alpha1.CacheBac // A nil/empty Claims slice is the absence of the field and admits // unchanged — the rule fires only on operator-supplied entries. func rejectResourceClaims(cb *cachev1alpha1.CacheBackend) field.ErrorList { - if cb.Spec.Resources == nil || len(cb.Spec.Resources.Claims) == 0 { + resources, resourcesPath := selectedProviderResources(cb) + if resources == nil || len(resources.Claims) == 0 { return nil } return field.ErrorList{ field.Forbidden( - field.NewPath("spec", "resources", "claims"), - "spec.resources.claims is not supported in v1alpha1: the runtime adapter does not plumb pod.spec.resourceClaims, so a claim-bound container.resources.claims would render a pod the apiserver rejects", + resourcesPath.Child("claims"), + resourcesPath.Child("claims").String()+" is not supported in v1alpha1: the runtime adapter does not plumb pod.spec.resourceClaims, so a claim-bound container.resources.claims would render a pod the apiserver rejects", ), } } diff --git a/internal/webhook/v1alpha1/cachebackend_webhook_test.go b/internal/webhook/v1alpha1/cachebackend_webhook_test.go index 42b81452..c31555f0 100644 --- a/internal/webhook/v1alpha1/cachebackend_webhook_test.go +++ b/internal/webhook/v1alpha1/cachebackend_webhook_test.go @@ -15,6 +15,8 @@ import ( "k8s.io/apimachinery/pkg/util/validation/field" cachev1alpha1 "github.com/cachebox-project/inference-cache/api/v1alpha1" + builtinruntime "github.com/cachebox-project/inference-cache/internal/adapters/builtin/runtime" + backendadapter "github.com/cachebox-project/inference-cache/pkg/adapters/backend" adapterruntime "github.com/cachebox-project/inference-cache/pkg/adapters/runtime" ) @@ -25,22 +27,47 @@ func newBackend() *cachev1alpha1.CacheBackend { return &cachev1alpha1.CacheBackend{ ObjectMeta: metav1.ObjectMeta{Name: "cb", Namespace: "team-a"}, Spec: cachev1alpha1.CacheBackendSpec{ - Type: cachev1alpha1.CacheBackendTypeLMCache, + Runtime: cachev1alpha1.CacheBackendRuntimeVLLM, + Type: cachev1alpha1.CacheBackendTypeLMCache, }, } } +func managedLMCacheServer(cb *cachev1alpha1.CacheBackend) *cachev1alpha1.LMCacheServerRemoteStorageSpec { + if cb.Spec.RemoteStorage == nil { + cb.Spec.RemoteStorage = &cachev1alpha1.CacheBackendRemoteStorageSpec{} + } + cb.Spec.RemoteStorage.Provider = cachev1alpha1.CacheBackendRemoteStorageProviderLMCacheServer + cb.Spec.RemoteStorage.Ownership = cachev1alpha1.CacheBackendRemoteStorageOwnershipManaged + if cb.Spec.RemoteStorage.LMCacheServer == nil { + cb.Spec.RemoteStorage.LMCacheServer = &cachev1alpha1.LMCacheServerRemoteStorageSpec{} + } + return cb.Spec.RemoteStorage.LMCacheServer +} + func i32p(v int32) *int32 { return &v } +func defaultShippingRegistry() *adapterruntime.Registry { + registry := adapterruntime.NewRegistry() + registry.Register(builtinruntime.NewVLLMLMCacheAdapter()) + registry.Register(builtinruntime.NewSGLangLMCacheAdapter()) + registry.Register(builtinruntime.NewSGLangHiCacheAdapter()) + return registry +} + +func shippingValidator() *CacheBackendValidator { + return &CacheBackendValidator{Registry: defaultShippingRegistry()} +} + func newHiCacheBackend() *cachev1alpha1.CacheBackend { return &cachev1alpha1.CacheBackend{ ObjectMeta: metav1.ObjectMeta{Name: "hicache", Namespace: "team-a"}, Spec: cachev1alpha1.CacheBackendSpec{ - Type: cachev1alpha1.CacheBackendTypeSGLangHiCache, + Runtime: cachev1alpha1.CacheBackendRuntimeSGLang, + Type: cachev1alpha1.CacheBackendTypeSGLangHiCache, Integration: &cachev1alpha1.CacheBackendIntegrationSpec{ - Engine: "sglang", - Mode: cachev1alpha1.CacheBackendIntegrationModeOffload, - Role: cachev1alpha1.CacheBackendIntegrationRoleReadWrite, + Mode: cachev1alpha1.CacheBackendIntegrationModeOffload, + Role: cachev1alpha1.CacheBackendIntegrationRoleReadWrite, }, EngineSelector: &cachev1alpha1.CacheBackendEngineSelector{ MatchLabels: map[string]string{"app": "sglang"}, @@ -51,13 +78,13 @@ func newHiCacheBackend() *cachev1alpha1.CacheBackend { IOBackend: cachev1alpha1.SGLangHiCacheIOKernel, MemoryLayout: cachev1alpha1.SGLangHiCacheMemoryPageFirst, }, - BackendConfig: map[string]string{"model": "model-a"}, + Observation: &cachev1alpha1.CacheBackendObservationSpec{ModelID: "model-a"}, }, } } func TestValidator_SGLangHiCacheAccepted(t *testing.T) { - if _, err := (&CacheBackendValidator{}).ValidateCreate(context.Background(), newHiCacheBackend()); err != nil { + if _, err := (shippingValidator()).ValidateCreate(context.Background(), newHiCacheBackend()); err != nil { t.Fatalf("valid SGLangHiCache rejected: %v", err) } } @@ -65,20 +92,18 @@ func TestValidator_SGLangHiCacheAccepted(t *testing.T) { func TestValidator_CanonicalSGLangHiCacheRejectsRemoteStorage(t *testing.T) { cb := newHiCacheBackend() cb.Spec.Runtime = cachev1alpha1.CacheBackendRuntimeSGLang - cb.Spec.Integration.Engine = "" - cb.Spec.BackendConfig = nil cb.Spec.Observation = &cachev1alpha1.CacheBackendObservationSpec{ModelID: "model-a"} cb.Spec.RemoteStorage = &cachev1alpha1.CacheBackendRemoteStorageSpec{ Provider: cachev1alpha1.CacheBackendRemoteStorageProviderRedis, Ownership: cachev1alpha1.CacheBackendRemoteStorageOwnershipManaged, Redis: &cachev1alpha1.RedisRemoteStorageSpec{}, } - requireInvalidWithCause(t, &CacheBackendValidator{}, cb, "spec.remoteStorage.provider", - "does not accept remote binding protocol") + requireInvalidWithCause(t, shippingValidator(), cb, "spec.remoteStorage.provider", + "does not accept remote-storage protocol") } func TestValidator_CanonicalCacheHierarchy(t *testing.T) { - validator := &CacheBackendValidator{} + validator := shippingValidator() t.Run("sglang host-only", func(t *testing.T) { cb := newBackend() @@ -93,7 +118,7 @@ func TestValidator_CanonicalCacheHierarchy(t *testing.T) { cb.Spec.Runtime = cachev1alpha1.CacheBackendRuntimeVLLM cb.Spec.Autoscaling = &cachev1alpha1.CacheBackendAutoscalingSpec{MaxReplicas: 3} requireInvalidWithCause(t, validator, cb, "spec.autoscaling", - "canonical host-only backends") + "host-only backends") }) t.Run("host memory capacity must be positive", func(t *testing.T) { @@ -173,12 +198,20 @@ func TestValidator_CanonicalCacheHierarchy(t *testing.T) { {name: "redis bare", runtime: cachev1alpha1.CacheBackendRuntimeSGLang, provider: cachev1alpha1.CacheBackendRemoteStorageProviderRedis, endpoint: "redis.example:6379"}, {name: "redis rejects lm scheme", runtime: cachev1alpha1.CacheBackendRuntimeSGLang, provider: cachev1alpha1.CacheBackendRemoteStorageProviderRedis, endpoint: "lm://redis.example:6379", wantErr: true}, {name: "redis rejects named port", runtime: cachev1alpha1.CacheBackendRuntimeSGLang, provider: cachev1alpha1.CacheBackendRemoteStorageProviderRedis, endpoint: "redis.example:redis", wantErr: true}, + {name: "redis rejects zero port", runtime: cachev1alpha1.CacheBackendRuntimeSGLang, provider: cachev1alpha1.CacheBackendRemoteStorageProviderRedis, endpoint: "redis.example:0", wantErr: true}, + {name: "redis rejects out-of-range port", runtime: cachev1alpha1.CacheBackendRuntimeSGLang, provider: cachev1alpha1.CacheBackendRemoteStorageProviderRedis, endpoint: "redis.example:70000", wantErr: true}, {name: "lmcache bare", runtime: cachev1alpha1.CacheBackendRuntimeVLLM, provider: cachev1alpha1.CacheBackendRemoteStorageProviderLMCacheServer, endpoint: "cache.example:8200"}, {name: "lmcache explicit scheme", runtime: cachev1alpha1.CacheBackendRuntimeVLLM, provider: cachev1alpha1.CacheBackendRemoteStorageProviderLMCacheServer, endpoint: "lm://cache.example:8200"}, {name: "lmcache rejects mooncake scheme", runtime: cachev1alpha1.CacheBackendRuntimeVLLM, provider: cachev1alpha1.CacheBackendRemoteStorageProviderLMCacheServer, endpoint: "mooncakestore://cache.example:50051", wantErr: true}, + {name: "lmcache rejects named port", runtime: cachev1alpha1.CacheBackendRuntimeVLLM, provider: cachev1alpha1.CacheBackendRemoteStorageProviderLMCacheServer, endpoint: "cache.example:not-a-port", wantErr: true}, + {name: "lmcache rejects zero port", runtime: cachev1alpha1.CacheBackendRuntimeVLLM, provider: cachev1alpha1.CacheBackendRemoteStorageProviderLMCacheServer, endpoint: "cache.example:0", wantErr: true}, + {name: "lmcache rejects out-of-range port", runtime: cachev1alpha1.CacheBackendRuntimeVLLM, provider: cachev1alpha1.CacheBackendRemoteStorageProviderLMCacheServer, endpoint: "cache.example:70000", wantErr: true}, {name: "mooncake bare", runtime: cachev1alpha1.CacheBackendRuntimeVLLM, provider: cachev1alpha1.CacheBackendRemoteStorageProviderMooncake, endpoint: "mooncake.example:50051"}, {name: "mooncake explicit scheme", runtime: cachev1alpha1.CacheBackendRuntimeVLLM, provider: cachev1alpha1.CacheBackendRemoteStorageProviderMooncake, endpoint: "mooncakestore://mooncake.example:50051"}, {name: "mooncake rejects lm scheme", runtime: cachev1alpha1.CacheBackendRuntimeVLLM, provider: cachev1alpha1.CacheBackendRemoteStorageProviderMooncake, endpoint: "lm://mooncake.example:50051", wantErr: true}, + {name: "mooncake rejects named port", runtime: cachev1alpha1.CacheBackendRuntimeVLLM, provider: cachev1alpha1.CacheBackendRemoteStorageProviderMooncake, endpoint: "mooncakestore://mooncake.example:not-a-port", wantErr: true}, + {name: "mooncake rejects zero port", runtime: cachev1alpha1.CacheBackendRuntimeVLLM, provider: cachev1alpha1.CacheBackendRemoteStorageProviderMooncake, endpoint: "mooncakestore://mooncake.example:0", wantErr: true}, + {name: "mooncake rejects out-of-range port", runtime: cachev1alpha1.CacheBackendRuntimeVLLM, provider: cachev1alpha1.CacheBackendRemoteStorageProviderMooncake, endpoint: "mooncakestore://mooncake.example:70000", wantErr: true}, } for _, tt := range tests { @@ -258,87 +291,18 @@ func TestValidator_CanonicalCacheHierarchy(t *testing.T) { requireInvalidWithCause(t, validator, cb, "spec.remoteStorage.mooncake.command[0]", "must not be empty") }) - t.Run("rejects legacy backendConfig", func(t *testing.T) { - cb := newBackend() - cb.Spec.Runtime = cachev1alpha1.CacheBackendRuntimeSGLang - cb.Spec.BackendConfig = map[string]string{"redisImage": "redis:test"} - requireInvalidWithCause(t, validator, cb, "spec.backendConfig", - "deprecated top-level configuration") - }) - - t.Run("typed observation preserves legacy provider mapping", func(t *testing.T) { + t.Run("typed observation does not synthesize provider storage", func(t *testing.T) { cb := newBackend() cb.Spec.Observation = &cachev1alpha1.CacheBackendObservationSpec{ModelID: "model-a"} - cb.Spec.BackendConfig = map[string]string{"model": "model-a"} if _, err := validator.ValidateCreate(context.Background(), cb); err != nil { t.Fatalf("ValidateCreate: %v", err) } storage := cb.Spec.EffectiveRemoteStorage() - if storage == nil || - storage.Provider != cachev1alpha1.CacheBackendRemoteStorageProviderLMCacheServer || - storage.Ownership != cachev1alpha1.CacheBackendRemoteStorageOwnershipManaged { - t.Fatalf("EffectiveRemoteStorage() = %+v, want legacy Managed LMCacheServer", storage) + if storage != nil { + t.Fatalf("EffectiveRemoteStorage() = %+v, want nil", storage) } }) - t.Run("typed observation rejects conflicting legacy model", func(t *testing.T) { - cb := newBackend() - cb.Spec.Observation = &cachev1alpha1.CacheBackendObservationSpec{ModelID: "model-a"} - cb.Spec.BackendConfig = map[string]string{"model": "model-b"} - requireInvalidWithCause(t, validator, cb, "spec.backendConfig[model]", - "conflicts with spec.observation.modelID") - }) - - t.Run("rejects legacy top-level resources", func(t *testing.T) { - cb := newBackend() - cb.Spec.Runtime = cachev1alpha1.CacheBackendRuntimeSGLang - cb.Spec.Resources = &corev1.ResourceRequirements{} - requireInvalidWithCause(t, validator, cb, "spec.resources", - "deprecated top-level resources") - }) -} - -func TestValidator_LegacyToCanonicalMigrationIsAtomic(t *testing.T) { - validator := &CacheBackendValidator{} - old := newBackend() - old.Spec.Integration = &cachev1alpha1.CacheBackendIntegrationSpec{Engine: "vllm"} - old.Spec.BackendConfig = map[string]string{ - "model": "Qwen/Qwen2.5-0.5B-Instruct", - "serverImage": "lmcache/standalone:v0.4.7", - } - old.Spec.Resources = &corev1.ResourceRequirements{ - Requests: corev1.ResourceList{corev1.ResourceMemory: resource.MustParse("4Gi")}, - Limits: corev1.ResourceList{corev1.ResourceMemory: resource.MustParse("8Gi")}, - } - - partial := old.DeepCopy() - partial.Spec.Runtime = cachev1alpha1.CacheBackendRuntimeVLLM - requireUpdateInvalidWithCause(t, validator, old, partial, "spec.backendConfig", - "deprecated top-level configuration") - requireUpdateInvalidWithCause(t, validator, old, partial, "spec.resources", - "deprecated top-level resources") - - canonical := old.DeepCopy() - canonical.Spec.Runtime = cachev1alpha1.CacheBackendRuntimeVLLM - // The mutating webhook derives this compatibility value from runtime before - // ValidateUpdate sees the object, even though the canonical manifest omits it. - canonical.Spec.Integration.Engine = "vllm" - canonical.Spec.BackendConfig = nil - canonical.Spec.Resources = nil - canonical.Spec.Observation = &cachev1alpha1.CacheBackendObservationSpec{ - ModelID: "Qwen/Qwen2.5-0.5B-Instruct", - } - canonical.Spec.RemoteStorage = &cachev1alpha1.CacheBackendRemoteStorageSpec{ - Provider: cachev1alpha1.CacheBackendRemoteStorageProviderLMCacheServer, - Ownership: cachev1alpha1.CacheBackendRemoteStorageOwnershipManaged, - LMCacheServer: &cachev1alpha1.LMCacheServerRemoteStorageSpec{ - Image: "lmcache/standalone:v0.4.7", - Resources: old.Spec.Resources.DeepCopy(), - }, - } - if _, err := validator.ValidateUpdate(context.Background(), old, canonical); err != nil { - t.Fatalf("complete legacy-to-canonical migration rejected: %v", err) - } } func TestValidator_SGLangHiCacheContract(t *testing.T) { @@ -358,7 +322,7 @@ func TestValidator_SGLangHiCacheContract(t *testing.T) { cb.Spec.HiCache.SizeGB = &zero }, "sizeGB"}, {"invalid ratio", func(cb *cachev1alpha1.CacheBackend) { cb.Spec.HiCache.Ratio = "Inf" }, "ratio"}, - {"wrong engine", func(cb *cachev1alpha1.CacheBackend) { cb.Spec.Integration.Engine = "vllm" }, "integration.engine"}, + {"wrong runtime", func(cb *cachev1alpha1.CacheBackend) { cb.Spec.Runtime = cachev1alpha1.CacheBackendRuntimeVLLM }, "spec.runtime"}, {"missing selector", func(cb *cachev1alpha1.CacheBackend) { cb.Spec.EngineSelector = nil }, "engineSelector.matchLabels"}, {"events only", func(cb *cachev1alpha1.CacheBackend) { cb.Spec.Integration.Mode = cachev1alpha1.CacheBackendIntegrationModeEventsOnly @@ -372,9 +336,6 @@ func TestValidator_SGLangHiCacheContract(t *testing.T) { {"autoscaling", func(cb *cachev1alpha1.CacheBackend) { cb.Spec.Autoscaling = &cachev1alpha1.CacheBackendAutoscalingSpec{MaxReplicas: 2} }, "spec.autoscaling"}, - {"unknown backendConfig", func(cb *cachev1alpha1.CacheBackend) { - cb.Spec.BackendConfig["l1SizeGB"] = "8" - }, "backendConfig[l1SizeGB]"}, {"invalid write policy", func(cb *cachev1alpha1.CacheBackend) { cb.Spec.HiCache.WritePolicy = "sometimes" }, "writePolicy"}, @@ -385,7 +346,7 @@ func TestValidator_SGLangHiCacheContract(t *testing.T) { cb.Spec.HiCache.MemoryLayout = "tensor_first" }, "memoryLayout"}, } - validator := &CacheBackendValidator{} + validator := shippingValidator() for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { cb := newHiCacheBackend() @@ -401,7 +362,7 @@ func TestValidator_SGLangHiCacheContract(t *testing.T) { func TestValidator_HiCacheBlockRejectedOnOtherTypes(t *testing.T) { cb := newBackend() cb.Spec.HiCache = &cachev1alpha1.SGLangHiCacheSpec{Ratio: "2"} - _, err := (&CacheBackendValidator{}).ValidateCreate(context.Background(), cb) + _, err := (shippingValidator()).ValidateCreate(context.Background(), cb) if err == nil || !strings.Contains(err.Error(), "spec.hiCache") { t.Fatalf("ValidateCreate error = %v, want hiCache type-scope error", err) } @@ -421,7 +382,7 @@ func TestValidator_SGLangHiCacheArgsAreReserved(t *testing.T) { cb.Spec.Integration.EngineOverrides = &cachev1alpha1.EngineInjectionOverrides{ SuppressArgs: []string{flag}, } - _, err := (&CacheBackendValidator{}).ValidateCreate(context.Background(), cb) + _, err := (shippingValidator()).ValidateCreate(context.Background(), cb) if err == nil || !strings.Contains(err.Error(), flag) || !strings.Contains(err.Error(), "reserved") { t.Fatalf("ValidateCreate error = %v, want reserved %s", err, flag) } @@ -429,20 +390,7 @@ func TestValidator_SGLangHiCacheArgsAreReserved(t *testing.T) { } } -func TestDefaulter_MaterialisesIntegrationForFirstEventTimeout(t *testing.T) { - // The webhook materialises spec.integration solely to persist - // firstEventTimeout: the CRD-schema default for firstEventTimeout only - // applies when spec.integration is present in the submitted object, so the - // common CR that omits integration entirely relies on the webhook stamping - // it here. - // - // Other Phase-1 literal defaults (spec.replicas=1, spec.type=LMCache, - // spec.deploymentKind=Deployment, - // spec.integration.mode=Offload, spec.integration.role=ReadWrite) ride on `+kubebuilder:default=` markers - // stamped by the apiserver before this handler runs — they are NOT this - // defaulter's job, and a unit-level call to Default() on a raw struct - // will not see them. The persisted-CR shape is asserted end-to-end in the - // envtest below (TestDefaulter_MinimumViableYAMLGetsFullyDefaulted). +func TestDefaulter_MaterialisesIntegrationAndObservation(t *testing.T) { d := &CacheBackendDefaulter{} cb := newBackend() @@ -453,45 +401,14 @@ func TestDefaulter_MaterialisesIntegrationForFirstEventTimeout(t *testing.T) { if cb.Spec.Integration == nil { t.Fatal("integration block not materialised") } - if cb.Spec.Integration.Engine != "vllm" { - t.Errorf("integration.engine = %q, want legacy default vllm", cb.Spec.Integration.Engine) - } - if cb.Spec.Integration.FirstEventTimeout == nil || cb.Spec.Integration.FirstEventTimeout.Duration != defaultFirstEventTimeout { - t.Errorf("firstEventTimeout = %v, want %s", cb.Spec.Integration.FirstEventTimeout, defaultFirstEventTimeout) - } - if cb.Spec.Resources == nil { - t.Fatal("legacy spec.resources was not defaulted") - } - if got := cb.Spec.Resources.Requests.Memory(); got == nil || got.Cmp(resource.MustParse("4Gi")) != 0 { - t.Errorf("legacy resources.requests.memory = %v, want 4Gi", got) - } - if got := cb.Spec.Resources.Limits.Memory(); got == nil || got.Cmp(resource.MustParse("8Gi")) != 0 { - t.Errorf("legacy resources.limits.memory = %v, want 8Gi", got) + if cb.Spec.Observation == nil || cb.Spec.Observation.FirstEventTimeout == nil || cb.Spec.Observation.FirstEventTimeout.Duration != defaultFirstEventTimeout { + t.Fatalf("observation.firstEventTimeout = %v, want %s", cb.Spec.Observation, defaultFirstEventTimeout) } } -func TestDefaulter_DerivesLegacyEngineFieldFromCanonicalRuntime(t *testing.T) { +func TestDefaulter_PreservesExplicitObservationTimeout(t *testing.T) { cb := newBackend() - cb.Spec.Runtime = cachev1alpha1.CacheBackendRuntimeSGLang - cb.Spec.Integration = &cachev1alpha1.CacheBackendIntegrationSpec{ - Role: cachev1alpha1.CacheBackendIntegrationRoleReadWrite, - } - - if err := (&CacheBackendDefaulter{}).Default(context.Background(), cb); err != nil { - t.Fatalf("Default returned error: %v", err) - } - if cb.Spec.Integration.Engine != "sglang" { - t.Fatalf("integration.engine = %q, want sglang derived from spec.runtime", cb.Spec.Integration.Engine) - } - if cb.Spec.Resources != nil { - t.Fatalf("canonical spec.resources = %+v, want nil", cb.Spec.Resources) - } -} - -func TestDefaulter_CanonicalMigrationCarriesForwardLegacyObservationTimeout(t *testing.T) { - cb := newBackend() - cb.Spec.Runtime = cachev1alpha1.CacheBackendRuntimeVLLM - cb.Spec.Integration = &cachev1alpha1.CacheBackendIntegrationSpec{ + cb.Spec.Observation = &cachev1alpha1.CacheBackendObservationSpec{ FirstEventTimeout: &metav1.Duration{Duration: 90 * time.Second}, } @@ -499,22 +416,10 @@ func TestDefaulter_CanonicalMigrationCarriesForwardLegacyObservationTimeout(t *t t.Fatalf("Default returned error: %v", err) } if cb.Spec.Observation == nil || cb.Spec.Observation.FirstEventTimeout == nil { - t.Fatalf("canonical observation timeout not materialised: %+v", cb.Spec.Observation) + t.Fatalf("observation timeout not materialised: %+v", cb.Spec.Observation) } if got := cb.Spec.Observation.FirstEventTimeout.Duration; got != 90*time.Second { - t.Fatalf("observation.firstEventTimeout = %s, want legacy 90s", got) - } -} - -func TestDefaulter_PreservesExplicitEmptyLegacyResources(t *testing.T) { - cb := newBackend() - cb.Spec.Resources = &corev1.ResourceRequirements{} - - if err := (&CacheBackendDefaulter{}).Default(context.Background(), cb); err != nil { - t.Fatalf("Default returned error: %v", err) - } - if len(cb.Spec.Resources.Requests) != 0 || len(cb.Spec.Resources.Limits) != 0 { - t.Fatalf("explicit empty resources were clobbered: %+v", cb.Spec.Resources) + t.Fatalf("observation.firstEventTimeout = %s, want 90s", got) } } @@ -522,7 +427,7 @@ func TestDefaulter_DoesNotClobberOperatorValues(t *testing.T) { d := &CacheBackendDefaulter{} cb := newBackend() cb.Spec.Replicas = i32p(7) - cb.Spec.Integration = &cachev1alpha1.CacheBackendIntegrationSpec{ + cb.Spec.Observation = &cachev1alpha1.CacheBackendObservationSpec{ FirstEventTimeout: &metav1.Duration{Duration: 90 * time.Second}, } @@ -536,27 +441,8 @@ func TestDefaulter_DoesNotClobberOperatorValues(t *testing.T) { if *cb.Spec.Replicas != 7 { t.Errorf("replicas clobbered: got %d, want 7", *cb.Spec.Replicas) } - if cb.Spec.Integration.FirstEventTimeout == nil || cb.Spec.Integration.FirstEventTimeout.Duration != 90*time.Second { - t.Errorf("firstEventTimeout clobbered: got %v, want 90s", cb.Spec.Integration.FirstEventTimeout) - } -} - -func TestDefaulter_PreservesPartiallySetIntegration(t *testing.T) { - // Operator pinned firstEventTimeout on an otherwise-empty integration - // block — the defaulter should leave the pinned value alone (and there are - // no other integration fields left for it to fill in). - d := &CacheBackendDefaulter{} - cb := newBackend() - cb.Spec.Integration = &cachev1alpha1.CacheBackendIntegrationSpec{ - FirstEventTimeout: &metav1.Duration{Duration: 30 * time.Second}, - } - - if err := d.Default(context.Background(), cb); err != nil { - t.Fatalf("Default returned error: %v", err) - } - - if cb.Spec.Integration.FirstEventTimeout == nil || cb.Spec.Integration.FirstEventTimeout.Duration != 30*time.Second { - t.Errorf("operator firstEventTimeout clobbered: got %v, want 30s", cb.Spec.Integration.FirstEventTimeout) + if cb.Spec.Observation.FirstEventTimeout == nil || cb.Spec.Observation.FirstEventTimeout.Duration != 90*time.Second { + t.Errorf("firstEventTimeout clobbered: got %v, want 90s", cb.Spec.Observation.FirstEventTimeout) } } @@ -720,57 +606,14 @@ func requireUpdateInvalidWithCause(t *testing.T, v *CacheBackendValidator, oldCB } func TestValidator_HappyPath_LMCacheAdmitted(t *testing.T) { - v := &CacheBackendValidator{} + v := shippingValidator() cb := newBackend() if _, err := v.ValidateCreate(context.Background(), cb); err != nil { t.Fatalf("happy-path LMCache rejected: %v", err) } } -func TestValidator_External_WithEndpointAdmitted(t *testing.T) { - // External now flows through the runtime-adapter check; use a registry - // that includes the External adapter (matching production cmd/controller - // wiring) so the (vllm, External) pair is supported. - v := &CacheBackendValidator{Registry: stubRegistryWithExternal()} - cb := newBackend() - cb.Spec.Type = cachev1alpha1.CacheBackendTypeExternal - cb.Spec.Endpoint = "team-a-cache.team-a.svc.cluster.local:9000" - cb.Spec.Integration = &cachev1alpha1.CacheBackendIntegrationSpec{Engine: "vllm"} - if _, err := v.ValidateCreate(context.Background(), cb); err != nil { - t.Fatalf("External with endpoint rejected: %v", err) - } -} - -func TestValidator_External_WithoutEndpointRejected(t *testing.T) { - v := &CacheBackendValidator{} - cb := newBackend() - cb.Spec.Type = cachev1alpha1.CacheBackendTypeExternal - requireInvalidWithCause(t, v, cb, "spec.endpoint", - "spec.type=External requires spec.endpoint") -} - -func TestValidator_External_BlankEndpointRejected(t *testing.T) { - // Whitespace-only is the same as unset: a whitespace string is not a valid - // network address, but a naïve != "" check would accept it. - v := &CacheBackendValidator{} - cb := newBackend() - cb.Spec.Type = cachev1alpha1.CacheBackendTypeExternal - cb.Spec.Endpoint = " " - requireInvalidWithCause(t, v, cb, "spec.endpoint", - "spec.type=External requires spec.endpoint") -} - func mooncakeBackendWithEngineHostNetwork(optIn bool) *cachev1alpha1.CacheBackend { - cb := newBackend() - cb.Spec.Type = cachev1alpha1.CacheBackendTypeMooncake - if cb.Spec.Integration == nil { - cb.Spec.Integration = &cachev1alpha1.CacheBackendIntegrationSpec{} - } - cb.Spec.Integration.EngineHostNetwork = optIn - return cb -} - -func canonicalMooncakeBackendWithEngineHostNetwork(optIn bool) *cachev1alpha1.CacheBackend { cb := newBackend() cb.Spec.Runtime = cachev1alpha1.CacheBackendRuntimeVLLM cb.Spec.Integration = &cachev1alpha1.CacheBackendIntegrationSpec{ @@ -784,66 +627,77 @@ func canonicalMooncakeBackendWithEngineHostNetwork(optIn bool) *cachev1alpha1.Ca return cb } +func setCanonicalExternalStorage(cb *cachev1alpha1.CacheBackend, endpoint string) { + cb.Spec.Runtime = cachev1alpha1.CacheBackendRuntimeVLLM + cb.Spec.Type = cachev1alpha1.CacheBackendTypeLMCache + cb.Spec.RemoteStorage = &cachev1alpha1.CacheBackendRemoteStorageSpec{ + Provider: cachev1alpha1.CacheBackendRemoteStorageProviderLMCacheServer, + Ownership: cachev1alpha1.CacheBackendRemoteStorageOwnershipExternal, + Endpoint: endpoint, + } +} + +func setCanonicalMooncakeStorage(cb *cachev1alpha1.CacheBackend) { + cb.Spec.Runtime = cachev1alpha1.CacheBackendRuntimeVLLM + cb.Spec.Type = cachev1alpha1.CacheBackendTypeLMCache + cb.Spec.RemoteStorage = &cachev1alpha1.CacheBackendRemoteStorageSpec{ + Provider: cachev1alpha1.CacheBackendRemoteStorageProviderMooncake, + Ownership: cachev1alpha1.CacheBackendRemoteStorageOwnershipManaged, + } +} + func TestValidator_MooncakeWarnsUntilEngineHostNetworkOptIn(t *testing.T) { // Mooncake's transfer engine is a peer-to-peer mesh: engine pods must run with // hostNetwork or the backend reports Ready and moves zero KV. That move rewrites // a pod the operator owns, so it is opt-in rather than injected. Until they opt // in, say so at apply time and name the exact field — otherwise the failure is // discoverable only from a flat cache-hit graph. - for name, cb := range map[string]*cachev1alpha1.CacheBackend{ - "legacy": mooncakeBackendWithEngineHostNetwork(false), - "canonical": canonicalMooncakeBackendWithEngineHostNetwork(false), - } { - t.Run(name, func(t *testing.T) { - v := &CacheBackendValidator{} - warnings, err := v.ValidateCreate(context.Background(), cb) - if err != nil { - t.Fatalf("a Mooncake backend must still be admitted (warning, not rejection): %v", err) - } - if len(warnings) != 1 || !strings.Contains(warnings[0], "spec.integration.engineHostNetwork=true") { - t.Fatalf("create warnings = %v, want one warning naming the opt-in field", warnings) - } + cb := mooncakeBackendWithEngineHostNetwork(false) + t.Run("without opt-in", func(t *testing.T) { + v := shippingValidator() + warnings, err := v.ValidateCreate(context.Background(), cb) + if err != nil { + t.Fatalf("a Mooncake backend must still be admitted (warning, not rejection): %v", err) + } + if len(warnings) != 1 || !strings.Contains(warnings[0], "spec.integration.engineHostNetwork=true") { + t.Fatalf("create warnings = %v, want one warning naming the opt-in field", warnings) + } - // It must persist across updates, not only on first apply — an operator who - // edits the CR later should still be told. - warnings, err = v.ValidateUpdate(context.Background(), cb, cb) - if err != nil { - t.Fatalf("a Mooncake update must still be admitted: %v", err) - } - if len(warnings) != 1 { - t.Fatalf("update warnings = %v, want the engine-hostNetwork warning", warnings) - } - }) - } + // It must persist across updates, not only on first apply — an operator who + // edits the CR later should still be told. + warnings, err = v.ValidateUpdate(context.Background(), cb, cb) + if err != nil { + t.Fatalf("a Mooncake update must still be admitted: %v", err) + } + if len(warnings) != 1 { + t.Fatalf("update warnings = %v, want the engine-hostNetwork warning", warnings) + } + }) } func TestValidator_MooncakeOptInSilencesTheWarning(t *testing.T) { // Once the operator opts in, the pod webhook completes the data plane. A warning // that keeps firing after the gap is closed trains operators to ignore warnings. - for name, cb := range map[string]*cachev1alpha1.CacheBackend{ - "legacy": mooncakeBackendWithEngineHostNetwork(true), - "canonical": canonicalMooncakeBackendWithEngineHostNetwork(true), - } { - t.Run(name, func(t *testing.T) { - v := &CacheBackendValidator{} - warnings, err := v.ValidateCreate(context.Background(), cb) - if err != nil { - t.Fatalf("an opted-in Mooncake backend must be admitted: %v", err) - } - if len(warnings) != 0 { - t.Fatalf("warnings = %v, want none once engineHostNetwork is set", warnings) - } - }) - } + cb := mooncakeBackendWithEngineHostNetwork(true) + t.Run("with opt-in", func(t *testing.T) { + v := shippingValidator() + warnings, err := v.ValidateCreate(context.Background(), cb) + if err != nil { + t.Fatalf("an opted-in Mooncake backend must be admitted: %v", err) + } + if len(warnings) != 0 { + t.Fatalf("warnings = %v, want none once engineHostNetwork is set", warnings) + } + }) } func TestValidator_EngineHostNetworkRejectedOnBackendThatDoesNotNeedIt(t *testing.T) { // The flag would silently do nothing on a pod-network backend while leaving the // operator convinced they had granted their engine host networking. hostNetwork // is a privilege — a no-op that looks like it granted one is worse than an error. - v := &CacheBackendValidator{} + v := shippingValidator() cb := mooncakeBackendWithEngineHostNetwork(true) - cb.Spec.Type = cachev1alpha1.CacheBackendTypeLMCache + cb.Spec.RemoteStorage = nil requireInvalidWithCause(t, v, cb, "spec.integration.engineHostNetwork", "only meaningful when the effective remote storage provider is Mooncake") } @@ -857,10 +711,10 @@ func TestValidator_EngineHostNetworkGoesInertWhenTypeFlipsAwayFromMooncake(t *te // object *introduces* — errors already present on the old object are filtered // out. The old object here (Mooncake + flag) is valid, so the error IS newly // introduced and must be caught. Nothing about that is obvious from the rule. - v := &CacheBackendValidator{} + v := shippingValidator() old := mooncakeBackendWithEngineHostNetwork(true) newCB := mooncakeBackendWithEngineHostNetwork(true) - newCB.Spec.Type = cachev1alpha1.CacheBackendTypeLMCache + newCB.Spec.RemoteStorage = nil requireUpdateInvalidWithCause(t, v, old, newCB, "spec.integration.engineHostNetwork", "only meaningful when the effective remote storage provider is Mooncake") } @@ -869,10 +723,10 @@ func TestValidator_DroppingEngineHostNetworkWithTheTypeFlipIsAccepted(t *testing // The escape hatch the rejection above implies: retyping away from Mooncake is // fine as long as the flag goes with it. If this failed, the rule would have // wedged the object — rejecting both keeping and dropping the flag. - v := &CacheBackendValidator{} + v := shippingValidator() old := mooncakeBackendWithEngineHostNetwork(true) newCB := mooncakeBackendWithEngineHostNetwork(false) - newCB.Spec.Type = cachev1alpha1.CacheBackendTypeLMCache + newCB.Spec.RemoteStorage = nil if _, err := v.ValidateUpdate(context.Background(), old, newCB); err != nil { t.Fatalf("retyping away from Mooncake while dropping engineHostNetwork must be accepted, got: %v", err) } @@ -889,11 +743,11 @@ func TestValidator_EngineHostNetworkCannotGoInertViaEventsOnly(t *testing.T) { // Pinned because that safety is emergent, not stated: it comes from two // independent rules meeting. Loosening either one — allowing events-only // Mooncake, say — would silently open the inert-flag hole this asserts shut. - v := &CacheBackendValidator{} + v := shippingValidator() cb := mooncakeBackendWithEngineHostNetwork(true) cb.Spec.Integration.Mode = cachev1alpha1.CacheBackendIntegrationModeEventsOnly - requireInvalidWithCause(t, v, cb, "spec.integration.mode", - "is only supported with spec.type") + requireInvalidWithCause(t, v, cb, "spec.remoteStorage", + "provision no remote-storage provider") } func TestValidator_WarningTextStaysConcise(t *testing.T) { @@ -912,7 +766,7 @@ func TestValidator_WarningTextStaysConcise(t *testing.T) { func TestValidator_NonMooncakeEmitsNoHostNetworkWarning(t *testing.T) { // Blast radius: the DEFAULT (vLLM) LMCache pairing — engine unset defaults to // vLLM — must stay warning-free (the Mooncake mesh warning does not apply to it). - v := &CacheBackendValidator{} + v := shippingValidator() cb := newBackend() cb.Spec.Type = cachev1alpha1.CacheBackendTypeLMCache warnings, err := v.ValidateCreate(context.Background(), cb) @@ -933,7 +787,8 @@ func TestValidator_SGLangLMCacheEmitsNoWarning(t *testing.T) { v := &CacheBackendValidator{Registry: defaultShippingRegistry()} cb := newBackend() cb.Spec.Type = cachev1alpha1.CacheBackendTypeLMCache - cb.Spec.Integration = &cachev1alpha1.CacheBackendIntegrationSpec{Engine: "sglang"} + cb.Spec.Runtime = cachev1alpha1.CacheBackendRuntimeSGLang + cb.Spec.Integration = &cachev1alpha1.CacheBackendIntegrationSpec{} warnings, err := v.ValidateCreate(context.Background(), cb) if err != nil { @@ -958,7 +813,8 @@ func TestValidator_VLLMLMCacheEmitsNoSGLangWarning(t *testing.T) { v := &CacheBackendValidator{Registry: defaultShippingRegistry()} cb := newBackend() cb.Spec.Type = cachev1alpha1.CacheBackendTypeLMCache - cb.Spec.Integration = &cachev1alpha1.CacheBackendIntegrationSpec{Engine: "vllm"} + cb.Spec.Runtime = cachev1alpha1.CacheBackendRuntimeVLLM + cb.Spec.Integration = &cachev1alpha1.CacheBackendIntegrationSpec{} warnings, err := v.ValidateCreate(context.Background(), cb) if err != nil { t.Fatalf("(vllm, LMCache) must be admitted: %v", err) @@ -977,9 +833,9 @@ func TestValidator_SGLangEventsOnlyEmitsNoDataPlaneWarning(t *testing.T) { v := &CacheBackendValidator{Registry: defaultShippingRegistry()} cb := newBackend() cb.Spec.Type = cachev1alpha1.CacheBackendTypeLMCache + cb.Spec.Runtime = cachev1alpha1.CacheBackendRuntimeSGLang cb.Spec.Integration = &cachev1alpha1.CacheBackendIntegrationSpec{ - Engine: "sglang", - Mode: cachev1alpha1.CacheBackendIntegrationModeEventsOnly, + Mode: cachev1alpha1.CacheBackendIntegrationModeEventsOnly, } warnings, err := v.ValidateCreate(context.Background(), cb) if err != nil { @@ -997,28 +853,28 @@ func TestValidator_MooncakeMultiReplicaRejected(t *testing.T) { // bind the node ports the first already holds, and on a different node it comes up // as an independent master and silently splits the store. Both failures surface // long after the object looks healthy, so admission rejects them at write time. - v := &CacheBackendValidator{} + v := shippingValidator() cb := newBackend() - cb.Spec.Type = cachev1alpha1.CacheBackendTypeMooncake + setCanonicalMooncakeStorage(cb) two := int32(2) cb.Spec.Replicas = &two requireInvalidWithCause(t, v, cb, "spec.replicas", "singleton on the host network") } func TestValidator_MooncakeAutoscalingRejected(t *testing.T) { - v := &CacheBackendValidator{} + v := shippingValidator() cb := newBackend() - cb.Spec.Type = cachev1alpha1.CacheBackendTypeMooncake + setCanonicalMooncakeStorage(cb) cb.Spec.Autoscaling = &cachev1alpha1.CacheBackendAutoscalingSpec{} - requireInvalidWithCause(t, v, cb, "spec.autoscaling", "not supported for type=Mooncake") + requireInvalidWithCause(t, v, cb, "spec.autoscaling", "not supported for remoteStorage.provider=Mooncake") } func TestValidator_MooncakeSingletonAndDisabledReplicasAccepted(t *testing.T) { // 1 is the singleton; 0 is the "disabled" case. Neither can split the store. - v := &CacheBackendValidator{} + v := shippingValidator() for _, replicas := range []int32{0, 1} { cb := newBackend() - cb.Spec.Type = cachev1alpha1.CacheBackendTypeMooncake + setCanonicalMooncakeStorage(cb) r := replicas cb.Spec.Replicas = &r if _, err := v.ValidateCreate(context.Background(), cb); err != nil { @@ -1030,12 +886,13 @@ func TestValidator_MooncakeSingletonAndDisabledReplicasAccepted(t *testing.T) { func TestValidator_LMCacheScaleOutUnaffectedByMooncakeRule(t *testing.T) { // Blast radius: the lm:// server is an ordinary pod-network workload and must // keep scaling out (and autoscaling) normally. - v := &CacheBackendValidator{} + v := shippingValidator() cb := newBackend() cb.Spec.Type = cachev1alpha1.CacheBackendTypeLMCache three := int32(3) cb.Spec.Replicas = &three cb.Spec.Autoscaling = &cachev1alpha1.CacheBackendAutoscalingSpec{} + managedLMCacheServer(cb) if _, err := v.ValidateCreate(context.Background(), cb); err != nil { t.Fatalf("multi-replica autoscaled LMCache must be admitted: %v", err) } @@ -1043,7 +900,14 @@ func TestValidator_LMCacheScaleOutUnaffectedByMooncakeRule(t *testing.T) { func sglangLMCacheBackend() *cachev1alpha1.CacheBackend { cb := newBackend() // Type=LMCache - cb.Spec.Integration = &cachev1alpha1.CacheBackendIntegrationSpec{Engine: "sglang"} + cb.Spec.Runtime = cachev1alpha1.CacheBackendRuntimeSGLang + cb.Spec.Runtime = cachev1alpha1.CacheBackendRuntimeSGLang + cb.Spec.Integration = &cachev1alpha1.CacheBackendIntegrationSpec{} + cb.Spec.RemoteStorage = &cachev1alpha1.CacheBackendRemoteStorageSpec{ + Provider: cachev1alpha1.CacheBackendRemoteStorageProviderRedis, + Ownership: cachev1alpha1.CacheBackendRemoteStorageOwnershipManaged, + Redis: &cachev1alpha1.RedisRemoteStorageSpec{}, + } return cb } @@ -1052,7 +916,7 @@ func TestValidator_SGLangRedisL2MultiReplicaRejected(t *testing.T) { // (the MP worker's --l2-adapter target). A second pod behind the one Service // shards the keyspace across independent instances, so a key stored via one is a // miss via the other — the L2 silently partitions. Reject at write time. - v := &CacheBackendValidator{} + v := shippingValidator() cb := sglangLMCacheBackend() two := int32(2) cb.Spec.Replicas = &two @@ -1060,7 +924,7 @@ func TestValidator_SGLangRedisL2MultiReplicaRejected(t *testing.T) { } func TestValidator_SGLangRedisL2AutoscalingRejected(t *testing.T) { - v := &CacheBackendValidator{} + v := shippingValidator() cb := sglangLMCacheBackend() cb.Spec.Autoscaling = &cachev1alpha1.CacheBackendAutoscalingSpec{} requireInvalidWithCause(t, v, cb, "spec.autoscaling", "not supported for the (sglang, LMCache) backend") @@ -1068,7 +932,7 @@ func TestValidator_SGLangRedisL2AutoscalingRejected(t *testing.T) { func TestValidator_SGLangRedisL2SingletonAndDisabledAccepted(t *testing.T) { // 1 is the singleton; 0 is "disabled". Neither partitions the keyspace. - v := &CacheBackendValidator{} + v := shippingValidator() for _, replicas := range []int32{0, 1} { cb := sglangLMCacheBackend() r := replicas @@ -1085,11 +949,12 @@ func TestValidator_SGLangEventsOnlyScaleOutAccepted(t *testing.T) { // fire. Rejecting here would be factually wrong (the message explains a Redis // split that cannot happen) and would make SGLang gratuitously stricter than an // otherwise-identical (vllm, LMCache) events-only backend. - v := &CacheBackendValidator{} + v := shippingValidator() t.Run("multi-replica is admitted", func(t *testing.T) { cb := sglangLMCacheBackend() cb.Spec.Integration.Mode = cachev1alpha1.CacheBackendIntegrationModeEventsOnly + cb.Spec.RemoteStorage = nil cb.Spec.Replicas = i32p(3) if _, err := v.ValidateCreate(context.Background(), cb); err != nil { t.Fatalf("(sglang, LMCache) EventsOnly with replicas=3 must be admitted (no Redis is provisioned): %v", err) @@ -1103,6 +968,7 @@ func TestValidator_SGLangEventsOnlyScaleOutAccepted(t *testing.T) { // and would make SGLang stricter than vLLM). Pinning the reason is the point. cb := sglangLMCacheBackend() cb.Spec.Integration.Mode = cachev1alpha1.CacheBackendIntegrationModeEventsOnly + cb.Spec.RemoteStorage = nil cb.Spec.Autoscaling = &cachev1alpha1.CacheBackendAutoscalingSpec{MaxReplicas: 3} _, err := v.ValidateCreate(context.Background(), cb) if err == nil { @@ -1120,104 +986,21 @@ func TestValidator_SGLangEventsOnlyScaleOutAccepted(t *testing.T) { func TestValidator_VLLMLMCacheScaleOutUnaffectedBySGLangRule(t *testing.T) { // Blast radius: vLLM's lm:// server is an ordinary pod-network workload and must // keep scaling out (and autoscaling) — the singleton rule is (sglang, LMCache)-only. - v := &CacheBackendValidator{} + v := shippingValidator() cb := newBackend() // Type=LMCache, engine defaults to vllm - cb.Spec.Integration = &cachev1alpha1.CacheBackendIntegrationSpec{Engine: "vllm"} + cb.Spec.Runtime = cachev1alpha1.CacheBackendRuntimeVLLM + cb.Spec.Integration = &cachev1alpha1.CacheBackendIntegrationSpec{} three := int32(3) cb.Spec.Replicas = &three cb.Spec.Autoscaling = &cachev1alpha1.CacheBackendAutoscalingSpec{} + managedLMCacheServer(cb) if _, err := v.ValidateCreate(context.Background(), cb); err != nil { t.Fatalf("multi-replica autoscaled (vllm, LMCache) must be admitted: %v", err) } } -func TestValidator_EndpointOnManagedTypeRejected(t *testing.T) { - // spec.endpoint is the External-passthrough field; setting it on a - // managed type silently does nothing today (the reconciler overwrites - // status.endpoint from the live Service it provisions), so admission - // hard-rejects to make the misconfiguration visible at write time. - v := &CacheBackendValidator{} - cb := newBackend() - cb.Spec.Type = cachev1alpha1.CacheBackendTypeLMCache - cb.Spec.Endpoint = "user-supplied.example:8080" - requireInvalidWithCause(t, v, cb, "spec.endpoint", - "spec.endpoint is only valid when spec.type=External") -} - -func TestValidator_EndpointOnManagedType_PreExistingUpdateAllowed(t *testing.T) { - // v1alpha1 backward-compat: a CR that was admitted before - // rejectEndpointOnNonExternal landed (e.g. LMCache with a stale - // spec.endpoint) must remain editable for unrelated changes — the - // new rule only rejects updates that *introduce* a new violation. - // Without this property, every existing CR with the legacy - // combination becomes un-updatable the moment an operator runs - // `kubectl annotate`. - v := &CacheBackendValidator{Registry: stubRegistry()} - old := newBackend() - old.Spec.Type = cachev1alpha1.CacheBackendTypeLMCache - old.Spec.Endpoint = "legacy.example:9000" - old.Spec.Integration = &cachev1alpha1.CacheBackendIntegrationSpec{Engine: "vllm"} - - // Same offending fields; only an unrelated label change. - newCB := old.DeepCopy() - if newCB.Labels == nil { - newCB.Labels = map[string]string{} - } - newCB.Labels["edited"] = "true" - - if _, err := v.ValidateUpdate(context.Background(), old, newCB); err != nil { - t.Fatalf("unrelated update on pre-existing CR rejected: %v", err) - } -} - -func TestValidator_EndpointOnManagedType_UpdateThatWorsensIsRejected(t *testing.T) { - // The diff-only semantics must not turn into a loophole: if the - // update *changes* the bad value (different invalid endpoint), the - // error key differs and the new violation is rejected. The locked - // rule still bites when the operator actively edits the bad field. - v := &CacheBackendValidator{Registry: stubRegistry()} - old := newBackend() - old.Spec.Type = cachev1alpha1.CacheBackendTypeLMCache - old.Spec.Endpoint = "legacy.example:9000" - old.Spec.Integration = &cachev1alpha1.CacheBackendIntegrationSpec{Engine: "vllm"} - - newCB := old.DeepCopy() - newCB.Spec.Endpoint = "freshly-typed.example:8080" // different bad value - - requireUpdateInvalidWithCause(t, v, old, newCB, "spec.endpoint", - "spec.endpoint is only valid when spec.type=External") -} - -func TestValidator_EndpointOnManagedType_UpdateThatIntroducesViolationIsRejected(t *testing.T) { - // If the old CR was clean (no spec.endpoint on a managed type) and - // the update adds one, the violation is freshly introduced — reject. - v := &CacheBackendValidator{Registry: stubRegistry()} - old := newBackend() - old.Spec.Type = cachev1alpha1.CacheBackendTypeLMCache - old.Spec.Integration = &cachev1alpha1.CacheBackendIntegrationSpec{Engine: "vllm"} - - newCB := old.DeepCopy() - newCB.Spec.Endpoint = "user-supplied.example:8080" - - requireUpdateInvalidWithCause(t, v, old, newCB, "spec.endpoint", - "spec.endpoint is only valid when spec.type=External") -} - -func TestValidator_EndpointOnManagedTypeBlankAdmitted(t *testing.T) { - // Whitespace-only spec.endpoint on a managed type passes — same - // leniency the External-required rule applies; the field is treated - // as empty. - v := &CacheBackendValidator{} - cb := newBackend() - cb.Spec.Type = cachev1alpha1.CacheBackendTypeLMCache - cb.Spec.Endpoint = " " - if _, err := v.ValidateCreate(context.Background(), cb); err != nil { - t.Fatalf("LMCache with whitespace endpoint rejected: %v", err) - } -} - func TestValidator_InvalidKernelCheckAnnotationRejected(t *testing.T) { - v := &CacheBackendValidator{} + v := shippingValidator() // A typo for "strict" would silently fall back to "auto" (report-only) and // disable the fail-closed gate — reject it at admission instead. @@ -1251,9 +1034,9 @@ func TestValidator_ResourcesLimitsBelowRequestsRejected(t *testing.T) { // admission with a field-scoped error rather than admit a CR the // pod will refuse later (and that the operator would have to // diagnose through downstream kubectl-describe spelunking). - v := &CacheBackendValidator{} + v := shippingValidator() cb := newBackend() - cb.Spec.Resources = &corev1.ResourceRequirements{ + managedLMCacheServer(cb).Resources = &corev1.ResourceRequirements{ Requests: corev1.ResourceList{ corev1.ResourceMemory: resource.MustParse("8Gi"), }, @@ -1261,16 +1044,16 @@ func TestValidator_ResourcesLimitsBelowRequestsRejected(t *testing.T) { corev1.ResourceMemory: resource.MustParse("4Gi"), }, } - requireInvalidWithCause(t, v, cb, "spec.resources.limits[memory]", - "must be greater than or equal to spec.resources.requests[memory]") + requireInvalidWithCause(t, v, cb, "spec.remoteStorage.lmCacheServer.resources.limits[memory]", + "must be greater than or equal to spec.remoteStorage.lmCacheServer.resources.requests[memory]") } func TestValidator_ResourcesLimitsEqualRequestsAdmitted(t *testing.T) { // limits == requests is the canonical "exact size" intent and must // admit. The rule only rejects strict-less-than. - v := &CacheBackendValidator{} + v := shippingValidator() cb := newBackend() - cb.Spec.Resources = &corev1.ResourceRequirements{ + managedLMCacheServer(cb).Resources = &corev1.ResourceRequirements{ Requests: corev1.ResourceList{corev1.ResourceMemory: resource.MustParse("4Gi")}, Limits: corev1.ResourceList{corev1.ResourceMemory: resource.MustParse("4Gi")}, } @@ -1282,9 +1065,9 @@ func TestValidator_ResourcesLimitsEqualRequestsAdmitted(t *testing.T) { func TestValidator_ResourcesRequestsOnlyAdmitted(t *testing.T) { // Requests-only is a valid shape (no upper bound declared); the // rule MUST NOT synthesise a phantom limit to compare against. - v := &CacheBackendValidator{} + v := shippingValidator() cb := newBackend() - cb.Spec.Resources = &corev1.ResourceRequirements{ + managedLMCacheServer(cb).Resources = &corev1.ResourceRequirements{ Requests: corev1.ResourceList{corev1.ResourceMemory: resource.MustParse("4Gi")}, } if _, err := v.ValidateCreate(context.Background(), cb); err != nil { @@ -1296,9 +1079,9 @@ func TestValidator_ResourcesLimitsOnlyAdmitted(t *testing.T) { // Limits-only is also valid (scheduler treats limit as the request // when no request is given); no comparison is meaningful, so the // rule must not fire. - v := &CacheBackendValidator{} + v := shippingValidator() cb := newBackend() - cb.Spec.Resources = &corev1.ResourceRequirements{ + managedLMCacheServer(cb).Resources = &corev1.ResourceRequirements{ Limits: corev1.ResourceList{corev1.ResourceMemory: resource.MustParse("8Gi")}, } if _, err := v.ValidateCreate(context.Background(), cb); err != nil { @@ -1315,9 +1098,9 @@ func TestValidator_ResourcesFractionalExtendedRejected(t *testing.T) { // storage) allow fractional values and are not affected. for _, side := range []string{"requests", "limits"} { t.Run(side, func(t *testing.T) { - v := &CacheBackendValidator{} + v := shippingValidator() cb := newBackend() - cb.Spec.Resources = &corev1.ResourceRequirements{} + managedLMCacheServer(cb).Resources = &corev1.ResourceRequirements{} entry := corev1.ResourceList{ corev1.ResourceName("nvidia.com/gpu"): resource.MustParse("500m"), } @@ -1325,14 +1108,14 @@ func TestValidator_ResourcesFractionalExtendedRejected(t *testing.T) { corev1.ResourceName("nvidia.com/gpu"): resource.MustParse("500m"), } if side == "requests" { - cb.Spec.Resources.Requests = entry - cb.Spec.Resources.Limits = matching + managedLMCacheServer(cb).Resources.Requests = entry + managedLMCacheServer(cb).Resources.Limits = matching } else { - cb.Spec.Resources.Limits = entry - cb.Spec.Resources.Requests = matching + managedLMCacheServer(cb).Resources.Limits = entry + managedLMCacheServer(cb).Resources.Requests = matching } requireInvalidWithCause(t, v, cb, - fmt.Sprintf("spec.resources.%s[nvidia.com/gpu]", side), + fmt.Sprintf("spec.remoteStorage.lmCacheServer.resources.%s[nvidia.com/gpu]", side), "must be an integer quantity") }) } @@ -1341,9 +1124,9 @@ func TestValidator_ResourcesFractionalExtendedRejected(t *testing.T) { func TestValidator_ResourcesIntegerExtendedAdmitted(t *testing.T) { // Integer extended-resource quantities (e.g. nvidia.com/gpu: 1) // admit — the rule fires only on fractional values. - v := &CacheBackendValidator{} + v := shippingValidator() cb := newBackend() - cb.Spec.Resources = &corev1.ResourceRequirements{ + managedLMCacheServer(cb).Resources = &corev1.ResourceRequirements{ Limits: corev1.ResourceList{ corev1.ResourceName("nvidia.com/gpu"): resource.MustParse("1"), }, @@ -1360,9 +1143,9 @@ func TestValidator_ResourcesFractionalCPUAdmitted(t *testing.T) { // Standard overcommittable CPU MUST still accept fractional values // (250m is the canonical kubelet shape) — the integer rule applies // only to vendor-prefixed extended resources. - v := &CacheBackendValidator{} + v := shippingValidator() cb := newBackend() - cb.Spec.Resources = &corev1.ResourceRequirements{ + managedLMCacheServer(cb).Resources = &corev1.ResourceRequirements{ Requests: corev1.ResourceList{corev1.ResourceCPU: resource.MustParse("250m")}, } if _, err := v.ValidateCreate(context.Background(), cb); err != nil { @@ -1382,18 +1165,18 @@ func TestValidator_ResourcesRequestsOnlyNonOvercommittableRejected(t *testing.T) "nvidia.com/gpu", } { t.Run(string(name), func(t *testing.T) { - v := &CacheBackendValidator{} + v := shippingValidator() cb := newBackend() qty := "1" if name == "hugepages-2Mi" { qty = "2Mi" } - cb.Spec.Resources = &corev1.ResourceRequirements{ + managedLMCacheServer(cb).Resources = &corev1.ResourceRequirements{ Requests: corev1.ResourceList{name: resource.MustParse(qty)}, } requireInvalidWithCause(t, v, cb, - fmt.Sprintf("spec.resources.requests[%s]", name), - "must also be set in spec.resources.limits") + fmt.Sprintf("spec.remoteStorage.lmCacheServer.resources.requests[%s]", name), + "must also be set in spec.remoteStorage.lmCacheServer.resources.limits") }) } } @@ -1402,9 +1185,9 @@ func TestValidator_ResourcesLimitsOnlyNonOvercommittableAdmitted(t *testing.T) { // Limits-only IS admitted for non-overcommittable resources — // K8s auto-populates requests from limits when only limits is set. // The rule we add fires only on the requests-only direction. - v := &CacheBackendValidator{} + v := shippingValidator() cb := newBackend() - cb.Spec.Resources = &corev1.ResourceRequirements{ + managedLMCacheServer(cb).Resources = &corev1.ResourceRequirements{ Limits: corev1.ResourceList{ corev1.ResourceName("nvidia.com/gpu"): resource.MustParse("1"), }, @@ -1420,9 +1203,9 @@ func TestValidator_ResourcesRequestsOnlyOvercommittableAdmitted(t *testing.T) { // "no upper bound" pattern and admits today. for _, name := range []corev1.ResourceName{corev1.ResourceCPU, corev1.ResourceMemory, corev1.ResourceEphemeralStorage} { t.Run(string(name), func(t *testing.T) { - v := &CacheBackendValidator{} + v := shippingValidator() cb := newBackend() - cb.Spec.Resources = &corev1.ResourceRequirements{ + managedLMCacheServer(cb).Resources = &corev1.ResourceRequirements{ Requests: corev1.ResourceList{name: resource.MustParse("1")}, } if _, err := v.ValidateCreate(context.Background(), cb); err != nil { @@ -1450,24 +1233,24 @@ func TestValidator_ResourcesNonOvercommittableMismatchRejected(t *testing.T) { {"nvidia gpu mismatch", corev1.ResourceName("nvidia.com/gpu"), "1", "2"}, } { t.Run(tc.name, func(t *testing.T) { - v := &CacheBackendValidator{} + v := shippingValidator() cb := newBackend() - cb.Spec.Resources = &corev1.ResourceRequirements{ + managedLMCacheServer(cb).Resources = &corev1.ResourceRequirements{ Requests: corev1.ResourceList{tc.resource: resource.MustParse(tc.req)}, Limits: corev1.ResourceList{tc.resource: resource.MustParse(tc.lim)}, } requireInvalidWithCause(t, v, cb, - fmt.Sprintf("spec.resources.limits[%s]", tc.resource), - "must equal spec.resources.requests") + fmt.Sprintf("spec.remoteStorage.lmCacheServer.resources.limits[%s]", tc.resource), + "must equal spec.remoteStorage.lmCacheServer.resources.requests") }) } } func TestValidator_ResourcesNonOvercommittableEqualAdmitted(t *testing.T) { // The same non-overcommittable resources admit when limits == requests. - v := &CacheBackendValidator{} + v := shippingValidator() cb := newBackend() - cb.Spec.Resources = &corev1.ResourceRequirements{ + managedLMCacheServer(cb).Resources = &corev1.ResourceRequirements{ Requests: corev1.ResourceList{corev1.ResourceName("nvidia.com/gpu"): resource.MustParse("1")}, Limits: corev1.ResourceList{corev1.ResourceName("nvidia.com/gpu"): resource.MustParse("1")}, } @@ -1486,15 +1269,15 @@ func TestValidator_ResourcesReservedPrefixesRejected(t *testing.T) { "requests.kubernetes.io/myresource", } { t.Run(name, func(t *testing.T) { - v := &CacheBackendValidator{} + v := shippingValidator() cb := newBackend() - cb.Spec.Resources = &corev1.ResourceRequirements{ + managedLMCacheServer(cb).Resources = &corev1.ResourceRequirements{ Requests: corev1.ResourceList{ corev1.ResourceName(name): resource.MustParse("1"), }, } requireInvalidWithCause(t, v, cb, - fmt.Sprintf("spec.resources.requests[%s]", name), + fmt.Sprintf("spec.remoteStorage.lmCacheServer.resources.requests[%s]", name), "not a valid container resource name") }) } @@ -1505,14 +1288,14 @@ func TestValidator_ResourcesInvalidNameRejected(t *testing.T) { // a CR can be admitted with structurally-malformed names ("memory!", // empty string), and the kubelet rejects the pod later. Reject at // admission so the regression surfaces at `kubectl apply`. - v := &CacheBackendValidator{} + v := shippingValidator() cb := newBackend() - cb.Spec.Resources = &corev1.ResourceRequirements{ + managedLMCacheServer(cb).Resources = &corev1.ResourceRequirements{ Requests: corev1.ResourceList{ corev1.ResourceName("memory!"): resource.MustParse("4Gi"), }, } - requireInvalidWithCause(t, v, cb, "spec.resources.requests[memory!]", + requireInvalidWithCause(t, v, cb, "spec.remoteStorage.lmCacheServer.resources.requests[memory!]", "not a valid container resource name") } @@ -1524,14 +1307,14 @@ func TestValidator_ResourcesUnqualifiedNonStandardNameRejected(t *testing.T) { // prefixed (e.g. "nvidia.com/gpu"). Reject at admission so the // operator sees a field-scoped error at `kubectl apply` rather // than chasing it through a child Deployment apply. - v := &CacheBackendValidator{} + v := shippingValidator() cb := newBackend() - cb.Spec.Resources = &corev1.ResourceRequirements{ + managedLMCacheServer(cb).Resources = &corev1.ResourceRequirements{ Requests: corev1.ResourceList{ corev1.ResourceName("foo"): resource.MustParse("1"), }, } - requireInvalidWithCause(t, v, cb, "spec.resources.requests[foo]", + requireInvalidWithCause(t, v, cb, "spec.remoteStorage.lmCacheServer.resources.requests[foo]", "not a valid container resource name") } @@ -1543,15 +1326,15 @@ func TestValidator_ResourcesMalformedHugepagesRejected(t *testing.T) { // same shapes at write time. for _, name := range []string{"hugepages-", "hugepages-nope", "hugepages-0"} { t.Run(name, func(t *testing.T) { - v := &CacheBackendValidator{} + v := shippingValidator() cb := newBackend() - cb.Spec.Resources = &corev1.ResourceRequirements{ + managedLMCacheServer(cb).Resources = &corev1.ResourceRequirements{ Requests: corev1.ResourceList{ corev1.ResourceName(name): resource.MustParse("1"), }, } requireInvalidWithCause(t, v, cb, - fmt.Sprintf("spec.resources.requests[%s]", name), + fmt.Sprintf("spec.remoteStorage.lmCacheServer.resources.requests[%s]", name), "not a valid container resource name") }) } @@ -1576,9 +1359,9 @@ func TestValidator_ResourcesStandardContainerResourceNamesAdmitted(t *testing.T) {corev1.ResourceName("hugepages-1Gi"), "2Gi"}, } { t.Run(string(tc.name), func(t *testing.T) { - v := &CacheBackendValidator{} + v := shippingValidator() cb := newBackend() - cb.Spec.Resources = &corev1.ResourceRequirements{ + managedLMCacheServer(cb).Resources = &corev1.ResourceRequirements{ Requests: corev1.ResourceList{tc.name: resource.MustParse(tc.qty)}, Limits: corev1.ResourceList{tc.name: resource.MustParse(tc.qty)}, } @@ -1604,9 +1387,9 @@ func TestValidator_ResourcesHugepagesQuantityMustBeDivisible(t *testing.T) { {"hugepages-1Gi", "512Mi"}, // 512Mi is not a multiple of 1Gi } { t.Run(tc.page+"/"+tc.qty, func(t *testing.T) { - v := &CacheBackendValidator{} + v := shippingValidator() cb := newBackend() - cb.Spec.Resources = &corev1.ResourceRequirements{ + managedLMCacheServer(cb).Resources = &corev1.ResourceRequirements{ Requests: corev1.ResourceList{ corev1.ResourceName(tc.page): resource.MustParse(tc.qty), }, @@ -1615,7 +1398,7 @@ func TestValidator_ResourcesHugepagesQuantityMustBeDivisible(t *testing.T) { }, } requireInvalidWithCause(t, v, cb, - fmt.Sprintf("spec.resources.requests[%s]", tc.page), + fmt.Sprintf("spec.remoteStorage.lmCacheServer.resources.requests[%s]", tc.page), "must be a multiple of the page size") }) } @@ -1635,9 +1418,9 @@ func TestValidator_ResourcesHugepagesAlignedQuantityAdmitted(t *testing.T) { {"hugepages-1Gi", "4Gi"}, } { t.Run(tc.page+"/"+tc.qty, func(t *testing.T) { - v := &CacheBackendValidator{} + v := shippingValidator() cb := newBackend() - cb.Spec.Resources = &corev1.ResourceRequirements{ + managedLMCacheServer(cb).Resources = &corev1.ResourceRequirements{ Requests: corev1.ResourceList{ corev1.ResourceName(tc.page): resource.MustParse(tc.qty), }, @@ -1657,9 +1440,9 @@ func TestValidator_ResourcesValidExtendedNameAdmitted(t *testing.T) { // the rule MUST admit them so operators can declare e.g. // nvidia.com/gpu on the cache-server container (rare but not // structurally forbidden). - v := &CacheBackendValidator{} + v := shippingValidator() cb := newBackend() - cb.Spec.Resources = &corev1.ResourceRequirements{ + managedLMCacheServer(cb).Resources = &corev1.ResourceRequirements{ Limits: corev1.ResourceList{ corev1.ResourceName("nvidia.com/gpu"): resource.MustParse("1"), }, @@ -1675,22 +1458,22 @@ func TestValidator_ResourcesNegativeRequestRejected(t *testing.T) { // kubelet rejects the negative quantity only when the pod tries // to schedule. Reject at admission with a field-scoped error so // the regression surfaces at `kubectl apply`. - v := &CacheBackendValidator{} + v := shippingValidator() cb := newBackend() - cb.Spec.Resources = &corev1.ResourceRequirements{ + managedLMCacheServer(cb).Resources = &corev1.ResourceRequirements{ Requests: corev1.ResourceList{corev1.ResourceMemory: resource.MustParse("-1Gi")}, } - requireInvalidWithCause(t, v, cb, "spec.resources.requests[memory]", + requireInvalidWithCause(t, v, cb, "spec.remoteStorage.lmCacheServer.resources.requests[memory]", "must be a non-negative quantity") } func TestValidator_ResourcesNegativeLimitRejected(t *testing.T) { - v := &CacheBackendValidator{} + v := shippingValidator() cb := newBackend() - cb.Spec.Resources = &corev1.ResourceRequirements{ + managedLMCacheServer(cb).Resources = &corev1.ResourceRequirements{ Limits: corev1.ResourceList{corev1.ResourceCPU: resource.MustParse("-100m")}, } - requireInvalidWithCause(t, v, cb, "spec.resources.limits[cpu]", + requireInvalidWithCause(t, v, cb, "spec.remoteStorage.lmCacheServer.resources.limits[cpu]", "must be a non-negative quantity") } @@ -1699,9 +1482,9 @@ func TestValidator_ResourcesZeroQuantityAdmitted(t *testing.T) { // operator who writes `requests.memory: "0"` is explicitly opting // into "no guaranteed minimum", which is a valid (if unusual) // shape. Only strictly-negative quantities are rejected. - v := &CacheBackendValidator{} + v := shippingValidator() cb := newBackend() - cb.Spec.Resources = &corev1.ResourceRequirements{ + managedLMCacheServer(cb).Resources = &corev1.ResourceRequirements{ Requests: corev1.ResourceList{corev1.ResourceMemory: resource.MustParse("0")}, Limits: corev1.ResourceList{corev1.ResourceMemory: resource.MustParse("8Gi")}, } @@ -1718,21 +1501,21 @@ func TestValidator_ResourcesClaimsRejected(t *testing.T) { // would render a Deployment the apiserver rejects because the claim // names don't resolve at the pod level. Reject at admission until the // renderer learns to thread resourceClaims onto the PodSpec. - v := &CacheBackendValidator{} + v := shippingValidator() cb := newBackend() - cb.Spec.Resources = &corev1.ResourceRequirements{ + managedLMCacheServer(cb).Resources = &corev1.ResourceRequirements{ Claims: []corev1.ResourceClaim{{Name: "gpu-claim"}}, } - requireInvalidWithCause(t, v, cb, "spec.resources.claims", - "spec.resources.claims is not supported") + requireInvalidWithCause(t, v, cb, "spec.remoteStorage.lmCacheServer.resources.claims", + "spec.remoteStorage.lmCacheServer.resources.claims is not supported") } func TestValidator_ResourcesEmptyClaimsAdmitted(t *testing.T) { // A nil/empty Claims slice MUST admit — the rule only fires on // operator-supplied entries, never on the absence of the field. - v := &CacheBackendValidator{} + v := shippingValidator() cb := newBackend() - cb.Spec.Resources = &corev1.ResourceRequirements{ + managedLMCacheServer(cb).Resources = &corev1.ResourceRequirements{ Requests: corev1.ResourceList{corev1.ResourceMemory: resource.MustParse("4Gi")}, } if _, err := v.ValidateCreate(context.Background(), cb); err != nil { @@ -1745,14 +1528,14 @@ func TestValidator_ResourcesCPULimitsBelowRequestsRejected(t *testing.T) { // Requests and Limits maps — it's not specific to memory. CPU is // the obvious second case worth pinning so future contributors don't // silently narrow the rule back to memory-only. - v := &CacheBackendValidator{} + v := shippingValidator() cb := newBackend() - cb.Spec.Resources = &corev1.ResourceRequirements{ + managedLMCacheServer(cb).Resources = &corev1.ResourceRequirements{ Requests: corev1.ResourceList{corev1.ResourceCPU: resource.MustParse("500m")}, Limits: corev1.ResourceList{corev1.ResourceCPU: resource.MustParse("250m")}, } - requireInvalidWithCause(t, v, cb, "spec.resources.limits[cpu]", - "must be greater than or equal to spec.resources.requests[cpu]") + requireInvalidWithCause(t, v, cb, "spec.remoteStorage.lmCacheServer.resources.limits[cpu]", + "must be greater than or equal to spec.remoteStorage.lmCacheServer.resources.requests[cpu]") } func TestValidator_ReplicasZeroWithAutoscalingAndNilMinReplicasRejected(t *testing.T) { @@ -1764,7 +1547,7 @@ func TestValidator_ReplicasZeroWithAutoscalingAndNilMinReplicasRejected(t *testi // "scale to zero" intent without notification. Admission must reject // the combination so the operator either sets the floor explicitly or // removes the autoscaling block to truly scale to zero. - v := &CacheBackendValidator{} + v := shippingValidator() cb := newBackend() cb.Spec.Replicas = i32p(0) cb.Spec.Autoscaling = &cachev1alpha1.CacheBackendAutoscalingSpec{MaxReplicas: 10} @@ -1781,13 +1564,14 @@ func TestValidator_ReplicasZeroWithAutoscalingAndExplicitMinReplicasAdmitted(t * // schema enforces Minimum=1 on minReplicas, so the smallest legal // explicit value here is 1; true scale-to-zero requires removing the // autoscaling block entirely, which the next test covers.) - v := &CacheBackendValidator{} + v := shippingValidator() cb := newBackend() cb.Spec.Replicas = i32p(0) cb.Spec.Autoscaling = &cachev1alpha1.CacheBackendAutoscalingSpec{ MinReplicas: i32p(1), MaxReplicas: 10, } + managedLMCacheServer(cb) if _, err := v.ValidateCreate(context.Background(), cb); err != nil { t.Fatalf("replicas=0 + autoscaling + explicit minReplicas rejected: %v", err) } @@ -1796,7 +1580,7 @@ func TestValidator_ReplicasZeroWithAutoscalingAndExplicitMinReplicasAdmitted(t * func TestValidator_ReplicasZeroWithoutAutoscalingAdmitted(t *testing.T) { // Pure scale-to-zero (no autoscaling block) is allowed. The HPA-fallback // trap only applies when autoscaling is opted into. - v := &CacheBackendValidator{} + v := shippingValidator() cb := newBackend() cb.Spec.Replicas = i32p(0) if _, err := v.ValidateCreate(context.Background(), cb); err != nil { @@ -1805,30 +1589,29 @@ func TestValidator_ReplicasZeroWithoutAutoscalingAdmitted(t *testing.T) { } func TestValidator_CrossNamespaceEndpointWithoutOptInRejected(t *testing.T) { - v := &CacheBackendValidator{} + v := shippingValidator() cb := newBackend() - cb.Spec.Type = cachev1alpha1.CacheBackendTypeExternal - cb.Spec.Endpoint = "shared-cache.team-b.svc.cluster.local:9000" - requireInvalidWithCause(t, v, cb, "spec.endpoint", + setCanonicalExternalStorage(cb, "shared-cache.team-b.svc.cluster.local:9000") + requireInvalidWithCause(t, v, cb, "spec.remoteStorage.endpoint", "references namespace \"team-b\"") } func TestValidator_CrossNamespaceEndpointWithOptInAdmitted(t *testing.T) { - v := &CacheBackendValidator{Registry: stubRegistryWithExternal()} + v := &CacheBackendValidator{Registry: stubRegistry()} cb := newBackend() - cb.Spec.Type = cachev1alpha1.CacheBackendTypeExternal // Carry a port — the new shape rule requires host:port; the // cross-namespace assertion below is unaffected by the port suffix. - cb.Spec.Endpoint = "shared-cache.team-b.svc.cluster.local:9000" + setCanonicalExternalStorage(cb, "shared-cache.team-b.svc.cluster.local:9000") cb.Spec.AllowCrossNamespace = true - cb.Spec.Integration = &cachev1alpha1.CacheBackendIntegrationSpec{Engine: "vllm"} + cb.Spec.Runtime = cachev1alpha1.CacheBackendRuntimeVLLM + cb.Spec.Integration = &cachev1alpha1.CacheBackendIntegrationSpec{} if _, err := v.ValidateCreate(context.Background(), cb); err != nil { t.Fatalf("External cross-namespace endpoint with opt-in rejected: %v", err) } } func TestValidator_CanonicalCrossNamespaceEndpointWithoutOptInRejected(t *testing.T) { - v := &CacheBackendValidator{} + v := shippingValidator() cb := newBackend() cb.Spec.Runtime = cachev1alpha1.CacheBackendRuntimeVLLM cb.Spec.RemoteStorage = &cachev1alpha1.CacheBackendRemoteStorageSpec{ @@ -1841,7 +1624,7 @@ func TestValidator_CanonicalCrossNamespaceEndpointWithoutOptInRejected(t *testin } func TestValidator_CanonicalCrossNamespaceEndpointWithOptInAdmitted(t *testing.T) { - v := &CacheBackendValidator{} + v := shippingValidator() cb := newBackend() cb.Spec.Runtime = cachev1alpha1.CacheBackendRuntimeVLLM cb.Spec.RemoteStorage = &cachev1alpha1.CacheBackendRemoteStorageSpec{ @@ -1858,9 +1641,9 @@ func TestValidator_CanonicalCrossNamespaceEndpointWithOptInAdmitted(t *testing.T func TestValidator_SameNamespaceEndpointAdmitted(t *testing.T) { v := &CacheBackendValidator{Registry: stubRegistryWithExternal()} cb := newBackend() - cb.Spec.Type = cachev1alpha1.CacheBackendTypeExternal - cb.Spec.Endpoint = "team-a-cache.team-a.svc.cluster.local:9000" - cb.Spec.Integration = &cachev1alpha1.CacheBackendIntegrationSpec{Engine: "vllm"} + setCanonicalExternalStorage(cb, "team-a-cache.team-a.svc.cluster.local:9000") + cb.Spec.Runtime = cachev1alpha1.CacheBackendRuntimeVLLM + cb.Spec.Integration = &cachev1alpha1.CacheBackendIntegrationSpec{} if _, err := v.ValidateCreate(context.Background(), cb); err != nil { t.Fatalf("same-namespace endpoint rejected: %v", err) } @@ -1873,9 +1656,9 @@ func TestValidator_ExternalHostnamePassesThrough(t *testing.T) { // adapter prepends the lm:// scheme on injection). v := &CacheBackendValidator{Registry: stubRegistryWithExternal()} cb := newBackend() - cb.Spec.Type = cachev1alpha1.CacheBackendTypeExternal - cb.Spec.Endpoint = "cache.example.com:8200" - cb.Spec.Integration = &cachev1alpha1.CacheBackendIntegrationSpec{Engine: "vllm"} + setCanonicalExternalStorage(cb, "cache.example.com:8200") + cb.Spec.Runtime = cachev1alpha1.CacheBackendRuntimeVLLM + cb.Spec.Integration = &cachev1alpha1.CacheBackendIntegrationSpec{} if _, err := v.ValidateCreate(context.Background(), cb); err != nil { t.Fatalf("external hostname rejected: %v", err) } @@ -1886,9 +1669,9 @@ func TestValidator_ExternalEndpoint_LMSchemeAdmitted(t *testing.T) { // the LMCache lm:// scheme; the adapter passes it through unchanged. v := &CacheBackendValidator{Registry: stubRegistryWithExternal()} cb := newBackend() - cb.Spec.Type = cachev1alpha1.CacheBackendTypeExternal - cb.Spec.Endpoint = "lm://cache.example.com:8200" - cb.Spec.Integration = &cachev1alpha1.CacheBackendIntegrationSpec{Engine: "vllm"} + setCanonicalExternalStorage(cb, "lm://cache.example.com:8200") + cb.Spec.Runtime = cachev1alpha1.CacheBackendRuntimeVLLM + cb.Spec.Integration = &cachev1alpha1.CacheBackendIntegrationSpec{} if _, err := v.ValidateCreate(context.Background(), cb); err != nil { t.Fatalf("External with lm:// scheme rejected: %v", err) } @@ -1901,10 +1684,10 @@ func TestValidator_ExternalEndpoint_HTTPSchemeRejected(t *testing.T) { // engine-pod crash logs. v := &CacheBackendValidator{Registry: stubRegistryWithExternal()} cb := newBackend() - cb.Spec.Type = cachev1alpha1.CacheBackendTypeExternal - cb.Spec.Endpoint = "https://cache.example.com:443/api" - cb.Spec.Integration = &cachev1alpha1.CacheBackendIntegrationSpec{Engine: "vllm"} - requireInvalidWithCause(t, v, cb, "spec.endpoint", + setCanonicalExternalStorage(cb, "https://cache.example.com:443/api") + cb.Spec.Runtime = cachev1alpha1.CacheBackendRuntimeVLLM + cb.Spec.Integration = &cachev1alpha1.CacheBackendIntegrationSpec{} + requireInvalidWithCause(t, v, cb, "spec.remoteStorage.endpoint", `scheme "https" is not supported`) } @@ -1915,10 +1698,10 @@ func TestValidator_ExternalEndpoint_PathRejected(t *testing.T) { // surfaces the problem. v := &CacheBackendValidator{Registry: stubRegistryWithExternal()} cb := newBackend() - cb.Spec.Type = cachev1alpha1.CacheBackendTypeExternal - cb.Spec.Endpoint = "cache.example.com:8200/path" - cb.Spec.Integration = &cachev1alpha1.CacheBackendIntegrationSpec{Engine: "vllm"} - requireInvalidWithCause(t, v, cb, "spec.endpoint", + setCanonicalExternalStorage(cb, "cache.example.com:8200/path") + cb.Spec.Runtime = cachev1alpha1.CacheBackendRuntimeVLLM + cb.Spec.Integration = &cachev1alpha1.CacheBackendIntegrationSpec{} + requireInvalidWithCause(t, v, cb, "spec.remoteStorage.endpoint", "must be host:port (optionally prefixed lm://)") } @@ -1929,10 +1712,10 @@ func TestValidator_ExternalEndpoint_LMSchemeOnlyRejected(t *testing.T) { // exists to prevent). v := &CacheBackendValidator{Registry: stubRegistryWithExternal()} cb := newBackend() - cb.Spec.Type = cachev1alpha1.CacheBackendTypeExternal - cb.Spec.Endpoint = "lm://" - cb.Spec.Integration = &cachev1alpha1.CacheBackendIntegrationSpec{Engine: "vllm"} - requireInvalidWithCause(t, v, cb, "spec.endpoint", + setCanonicalExternalStorage(cb, "lm://") + cb.Spec.Runtime = cachev1alpha1.CacheBackendRuntimeVLLM + cb.Spec.Integration = &cachev1alpha1.CacheBackendIntegrationSpec{} + requireInvalidWithCause(t, v, cb, "spec.remoteStorage.endpoint", "must be a non-empty host AND port") } @@ -1940,10 +1723,10 @@ func TestValidator_ExternalEndpoint_PortOnlyRejected(t *testing.T) { // `:8200` is a port with no host — same broken-injection risk. v := &CacheBackendValidator{Registry: stubRegistryWithExternal()} cb := newBackend() - cb.Spec.Type = cachev1alpha1.CacheBackendTypeExternal - cb.Spec.Endpoint = ":8200" - cb.Spec.Integration = &cachev1alpha1.CacheBackendIntegrationSpec{Engine: "vllm"} - requireInvalidWithCause(t, v, cb, "spec.endpoint", + setCanonicalExternalStorage(cb, ":8200") + cb.Spec.Runtime = cachev1alpha1.CacheBackendRuntimeVLLM + cb.Spec.Integration = &cachev1alpha1.CacheBackendIntegrationSpec{} + requireInvalidWithCause(t, v, cb, "spec.remoteStorage.endpoint", "must be a non-empty host AND port") } @@ -1951,26 +1734,27 @@ func TestValidator_ExternalEndpoint_LMSchemePortOnlyRejected(t *testing.T) { // Scheme + port with no host. v := &CacheBackendValidator{Registry: stubRegistryWithExternal()} cb := newBackend() - cb.Spec.Type = cachev1alpha1.CacheBackendTypeExternal - cb.Spec.Endpoint = "lm://:8200" - cb.Spec.Integration = &cachev1alpha1.CacheBackendIntegrationSpec{Engine: "vllm"} - requireInvalidWithCause(t, v, cb, "spec.endpoint", + setCanonicalExternalStorage(cb, "lm://:8200") + cb.Spec.Runtime = cachev1alpha1.CacheBackendRuntimeVLLM + cb.Spec.Integration = &cachev1alpha1.CacheBackendIntegrationSpec{} + requireInvalidWithCause(t, v, cb, "spec.remoteStorage.endpoint", "must be a non-empty host AND port") } func TestValidator_ExternalEndpoint_PortlessHostRejected(t *testing.T) { // Bare host with no port is rejected: the LMCache connector dials a - // specific TCP target, so spec.endpoint must carry both halves. + // specific TCP target, so spec.remoteStorage.endpoint must carry both + // halves. // Without this check the CR admits and the engine boots with // LMCACHE_REMOTE_URL=lm://cache.example.com — the connector then // either picks an undocumented default or crashes; either way the // failure surfaces at the engine, not at admission where it belongs. v := &CacheBackendValidator{Registry: stubRegistryWithExternal()} cb := newBackend() - cb.Spec.Type = cachev1alpha1.CacheBackendTypeExternal - cb.Spec.Endpoint = "cache.example.com" - cb.Spec.Integration = &cachev1alpha1.CacheBackendIntegrationSpec{Engine: "vllm"} - requireInvalidWithCause(t, v, cb, "spec.endpoint", + setCanonicalExternalStorage(cb, "cache.example.com") + cb.Spec.Runtime = cachev1alpha1.CacheBackendRuntimeVLLM + cb.Spec.Integration = &cachev1alpha1.CacheBackendIntegrationSpec{} + requireInvalidWithCause(t, v, cb, "spec.remoteStorage.endpoint", "must be a non-empty host AND port") } @@ -1980,10 +1764,10 @@ func TestValidator_ExternalEndpoint_EmptyPortRejected(t *testing.T) { // LMCACHE_REMOTE_URL=lm://cache.example.com: at injection. v := &CacheBackendValidator{Registry: stubRegistryWithExternal()} cb := newBackend() - cb.Spec.Type = cachev1alpha1.CacheBackendTypeExternal - cb.Spec.Endpoint = "cache.example.com:" - cb.Spec.Integration = &cachev1alpha1.CacheBackendIntegrationSpec{Engine: "vllm"} - requireInvalidWithCause(t, v, cb, "spec.endpoint", + setCanonicalExternalStorage(cb, "cache.example.com:") + cb.Spec.Runtime = cachev1alpha1.CacheBackendRuntimeVLLM + cb.Spec.Integration = &cachev1alpha1.CacheBackendIntegrationSpec{} + requireInvalidWithCause(t, v, cb, "spec.remoteStorage.endpoint", "must be a non-empty host AND port") } @@ -1991,10 +1775,10 @@ func TestValidator_ExternalEndpoint_PortlessLMSchemeRejected(t *testing.T) { // Same rule applies when the scheme is explicit. v := &CacheBackendValidator{Registry: stubRegistryWithExternal()} cb := newBackend() - cb.Spec.Type = cachev1alpha1.CacheBackendTypeExternal - cb.Spec.Endpoint = "lm://cache.example.com" - cb.Spec.Integration = &cachev1alpha1.CacheBackendIntegrationSpec{Engine: "vllm"} - requireInvalidWithCause(t, v, cb, "spec.endpoint", + setCanonicalExternalStorage(cb, "lm://cache.example.com") + cb.Spec.Runtime = cachev1alpha1.CacheBackendRuntimeVLLM + cb.Spec.Integration = &cachev1alpha1.CacheBackendIntegrationSpec{} + requireInvalidWithCause(t, v, cb, "spec.remoteStorage.endpoint", "must be a non-empty host AND port") } @@ -2004,10 +1788,10 @@ func TestValidator_ExternalEndpoint_PortlessIPv6Rejected(t *testing.T) { // port-required rule. v := &CacheBackendValidator{Registry: stubRegistryWithExternal()} cb := newBackend() - cb.Spec.Type = cachev1alpha1.CacheBackendTypeExternal - cb.Spec.Endpoint = "[2001:db8::1]" - cb.Spec.Integration = &cachev1alpha1.CacheBackendIntegrationSpec{Engine: "vllm"} - requireInvalidWithCause(t, v, cb, "spec.endpoint", + setCanonicalExternalStorage(cb, "[2001:db8::1]") + cb.Spec.Runtime = cachev1alpha1.CacheBackendRuntimeVLLM + cb.Spec.Integration = &cachev1alpha1.CacheBackendIntegrationSpec{} + requireInvalidWithCause(t, v, cb, "spec.remoteStorage.endpoint", "must be a non-empty host AND port") } @@ -2020,10 +1804,10 @@ func TestValidator_ExternalEndpoint_EmbeddedWhitespaceRejected(t *testing.T) { // the misconfiguration loudly at write time. v := &CacheBackendValidator{Registry: stubRegistryWithExternal()} cb := newBackend() - cb.Spec.Type = cachev1alpha1.CacheBackendTypeExternal - cb.Spec.Endpoint = "cache example.com:8200" - cb.Spec.Integration = &cachev1alpha1.CacheBackendIntegrationSpec{Engine: "vllm"} - requireInvalidWithCause(t, v, cb, "spec.endpoint", + setCanonicalExternalStorage(cb, "cache example.com:8200") + cb.Spec.Runtime = cachev1alpha1.CacheBackendRuntimeVLLM + cb.Spec.Integration = &cachev1alpha1.CacheBackendIntegrationSpec{} + requireInvalidWithCause(t, v, cb, "spec.remoteStorage.endpoint", "must not contain whitespace or control characters") } @@ -2033,10 +1817,10 @@ func TestValidator_ExternalEndpoint_EmbeddedWhitespaceInPortRejected(t *testing. // broken URL. v := &CacheBackendValidator{Registry: stubRegistryWithExternal()} cb := newBackend() - cb.Spec.Type = cachev1alpha1.CacheBackendTypeExternal - cb.Spec.Endpoint = "cache.example:82 00" - cb.Spec.Integration = &cachev1alpha1.CacheBackendIntegrationSpec{Engine: "vllm"} - requireInvalidWithCause(t, v, cb, "spec.endpoint", + setCanonicalExternalStorage(cb, "cache.example:82 00") + cb.Spec.Runtime = cachev1alpha1.CacheBackendRuntimeVLLM + cb.Spec.Integration = &cachev1alpha1.CacheBackendIntegrationSpec{} + requireInvalidWithCause(t, v, cb, "spec.remoteStorage.endpoint", "must not contain whitespace or control characters") } @@ -2047,10 +1831,10 @@ func TestValidator_ExternalEndpoint_ControlCharRejected(t *testing.T) { // consumer ever templates the endpoint into a text format. v := &CacheBackendValidator{Registry: stubRegistryWithExternal()} cb := newBackend() - cb.Spec.Type = cachev1alpha1.CacheBackendTypeExternal - cb.Spec.Endpoint = "cache.example.com:8200\nLMCACHE_LOG_LEVEL=debug" - cb.Spec.Integration = &cachev1alpha1.CacheBackendIntegrationSpec{Engine: "vllm"} - requireInvalidWithCause(t, v, cb, "spec.endpoint", + setCanonicalExternalStorage(cb, "cache.example.com:8200\nLMCACHE_LOG_LEVEL=debug") + cb.Spec.Runtime = cachev1alpha1.CacheBackendRuntimeVLLM + cb.Spec.Integration = &cachev1alpha1.CacheBackendIntegrationSpec{} + requireInvalidWithCause(t, v, cb, "spec.remoteStorage.endpoint", "must not contain whitespace or control characters") } @@ -2064,10 +1848,10 @@ func TestValidator_ExternalEndpoint_BracketedIPv6ExtraColonRejected(t *testing.T // LMCACHE_REMOTE_URL=lm://[::1]:8200:bad at injection. v := &CacheBackendValidator{Registry: stubRegistryWithExternal()} cb := newBackend() - cb.Spec.Type = cachev1alpha1.CacheBackendTypeExternal - cb.Spec.Endpoint = "[::1]:8200:bad" - cb.Spec.Integration = &cachev1alpha1.CacheBackendIntegrationSpec{Engine: "vllm"} - requireInvalidWithCause(t, v, cb, "spec.endpoint", + setCanonicalExternalStorage(cb, "[::1]:8200:bad") + cb.Spec.Runtime = cachev1alpha1.CacheBackendRuntimeVLLM + cb.Spec.Integration = &cachev1alpha1.CacheBackendIntegrationSpec{} + requireInvalidWithCause(t, v, cb, "spec.remoteStorage.endpoint", "must be a non-empty host AND port") } @@ -2076,10 +1860,10 @@ func TestValidator_ExternalEndpoint_BracketedIPv6ExtraColonWithSchemeRejected(t // shouldn't change the host:port shape check that follows. v := &CacheBackendValidator{Registry: stubRegistryWithExternal()} cb := newBackend() - cb.Spec.Type = cachev1alpha1.CacheBackendTypeExternal - cb.Spec.Endpoint = "lm://[::1]:8200:bad" - cb.Spec.Integration = &cachev1alpha1.CacheBackendIntegrationSpec{Engine: "vllm"} - requireInvalidWithCause(t, v, cb, "spec.endpoint", + setCanonicalExternalStorage(cb, "lm://[::1]:8200:bad") + cb.Spec.Runtime = cachev1alpha1.CacheBackendRuntimeVLLM + cb.Spec.Integration = &cachev1alpha1.CacheBackendIntegrationSpec{} + requireInvalidWithCause(t, v, cb, "spec.remoteStorage.endpoint", "must be a non-empty host AND port") } @@ -2092,10 +1876,10 @@ func TestValidator_ExternalEndpoint_UnbracketedIPv6Rejected(t *testing.T) { // LMCache connector cannot parse. Refuse at write time. v := &CacheBackendValidator{Registry: stubRegistryWithExternal()} cb := newBackend() - cb.Spec.Type = cachev1alpha1.CacheBackendTypeExternal - cb.Spec.Endpoint = "2001:db8::1" - cb.Spec.Integration = &cachev1alpha1.CacheBackendIntegrationSpec{Engine: "vllm"} - requireInvalidWithCause(t, v, cb, "spec.endpoint", + setCanonicalExternalStorage(cb, "2001:db8::1") + cb.Spec.Runtime = cachev1alpha1.CacheBackendRuntimeVLLM + cb.Spec.Integration = &cachev1alpha1.CacheBackendIntegrationSpec{} + requireInvalidWithCause(t, v, cb, "spec.remoteStorage.endpoint", "must be a non-empty host AND port") } @@ -2105,9 +1889,9 @@ func TestValidator_ExternalEndpoint_IPv6Admitted(t *testing.T) { // scheme/port separators. v := &CacheBackendValidator{Registry: stubRegistryWithExternal()} cb := newBackend() - cb.Spec.Type = cachev1alpha1.CacheBackendTypeExternal - cb.Spec.Endpoint = "[2001:db8::1]:8200" - cb.Spec.Integration = &cachev1alpha1.CacheBackendIntegrationSpec{Engine: "vllm"} + setCanonicalExternalStorage(cb, "[2001:db8::1]:8200") + cb.Spec.Runtime = cachev1alpha1.CacheBackendRuntimeVLLM + cb.Spec.Integration = &cachev1alpha1.CacheBackendIntegrationSpec{} if _, err := v.ValidateCreate(context.Background(), cb); err != nil { t.Fatalf("IPv6 endpoint rejected: %v", err) } @@ -2118,22 +1902,25 @@ func TestValidator_ExternalEndpoint_LMSchemeWithPathRejected(t *testing.T) { // is just as broken. v := &CacheBackendValidator{Registry: stubRegistryWithExternal()} cb := newBackend() - cb.Spec.Type = cachev1alpha1.CacheBackendTypeExternal - cb.Spec.Endpoint = "lm://cache.example.com:8200/path" - cb.Spec.Integration = &cachev1alpha1.CacheBackendIntegrationSpec{Engine: "vllm"} - requireInvalidWithCause(t, v, cb, "spec.endpoint", + setCanonicalExternalStorage(cb, "lm://cache.example.com:8200/path") + cb.Spec.Runtime = cachev1alpha1.CacheBackendRuntimeVLLM + cb.Spec.Integration = &cachev1alpha1.CacheBackendIntegrationSpec{} + requireInvalidWithCause(t, v, cb, "spec.remoteStorage.endpoint", "must be host:port (optionally prefixed lm://)") } func TestValidator_AggregatesMultipleViolations(t *testing.T) { // Two independent violations on a single CR must both appear in the // rejection's status.details.causes, so kubectl prints them together. - // Here: an endpoint on a non-External backend plus scale-to-zero with - // autoscaling and no explicit minReplicas. Both rules should fire on + // Here: non-positive host memory plus scale-to-zero with autoscaling and no + // explicit minReplicas. Both rules should fire on // the same spec. - v := &CacheBackendValidator{} + v := shippingValidator() cb := newBackend() - cb.Spec.Endpoint = "cache.example.com:8200" + zeroQuantity := resource.MustParse("0") + cb.Spec.LMCache = &cachev1alpha1.LMCacheEngineSpec{ + HostMemory: &cachev1alpha1.CacheBackendHostMemorySpec{Capacity: &zeroQuantity}, + } zero := int32(0) cb.Spec.Replicas = &zero cb.Spec.Autoscaling = &cachev1alpha1.CacheBackendAutoscalingSpec{MaxReplicas: 3} @@ -2149,12 +1936,11 @@ func TestValidator_AggregatesMultipleViolations(t *testing.T) { } func TestValidator_Update_NewObjectChecked(t *testing.T) { - // ValidateUpdate validates the *new* object — flipping spec.type to - // External on update must fail just as it would on create. - v := &CacheBackendValidator{} + // ValidateUpdate validates the new object just as create does. + v := shippingValidator() old := newBackend() newCB := newBackend() - newCB.Spec.Type = cachev1alpha1.CacheBackendTypeExternal + newCB.Spec.Type = cachev1alpha1.CacheBackendType("unsupported") _, err := v.ValidateUpdate(context.Background(), old, newCB) if err == nil || !apierrors.IsInvalid(err) { t.Fatalf("expected Invalid on update, got %v", err) @@ -2164,9 +1950,9 @@ func TestValidator_Update_NewObjectChecked(t *testing.T) { func TestValidator_Delete_AlwaysAllowed(t *testing.T) { // Even a structurally-broken backend must be deletable so operators can // clean up bad state — the validator must never block delete. - v := &CacheBackendValidator{} + v := shippingValidator() cb := newBackend() - cb.Spec.Type = cachev1alpha1.CacheBackendTypeExternal // no endpoint + cb.Spec.Type = cachev1alpha1.CacheBackendType("unsupported") if _, err := v.ValidateDelete(context.Background(), cb); err != nil { t.Fatalf("ValidateDelete rejected: %v", err) } @@ -2199,13 +1985,19 @@ func (stubVLLMLMCacheAdapter) Supports(rt adapterruntime.RuntimeID, cb *cachev1a return rt == adapterruntime.RuntimeVLLM && cb.Spec.Type == cachev1alpha1.CacheBackendTypeLMCache } +func (stubVLLMLMCacheAdapter) SupportsBinding(binding *backendadapter.Binding) bool { + return binding == nil || + binding.Protocol == backendadapter.ProtocolLMCache || + binding.Protocol == backendadapter.ProtocolMooncakeStore +} + func (stubVLLMLMCacheAdapter) ResolveCacheServer(*cachev1alpha1.CacheBackend) (*corev1.PodSpec, *corev1.Service, error) { return nil, nil, nil } -func (stubVLLMLMCacheAdapter) InjectEngineConfig(*corev1.PodSpec, string, *cachev1alpha1.CacheBackend) error { +func (stubVLLMLMCacheAdapter) InjectEngineConfig(*corev1.PodSpec, *backendadapter.Binding, *cachev1alpha1.CacheBackend) error { return nil } -func (stubVLLMLMCacheAdapter) InjectRouterConfig(*corev1.PodSpec, string, *cachev1alpha1.CacheBackend) error { +func (stubVLLMLMCacheAdapter) InjectRouterConfig(*corev1.PodSpec, *backendadapter.Binding, *cachev1alpha1.CacheBackend) error { return nil } func (stubVLLMLMCacheAdapter) ObservationSidecar(*cachev1alpha1.CacheBackend, *corev1.Pod) (*corev1.Container, error) { @@ -2225,86 +2017,31 @@ func (stubVLLMLMCacheAdapter) ReservedEnv() []string { } func (stubVLLMLMCacheAdapter) EngineContainerName() string { return "vllm" } -// stubExternalAdapter mirrors the real External adapter's Supports gate -// (vllm-only). Used by validator tests so admission of an External CR -// runs through the registry the same way production does — without -// dragging in the real adapter package and its enginewire dependency -// from a unit-test file. -type stubExternalAdapter struct{} - -func (stubExternalAdapter) Supports(rt adapterruntime.RuntimeID, cb *cachev1alpha1.CacheBackend) bool { - if cb == nil { - return false - } - return rt == adapterruntime.RuntimeVLLM && cb.Spec.Type == cachev1alpha1.CacheBackendTypeExternal -} - -func (stubExternalAdapter) ResolveCacheServer(*cachev1alpha1.CacheBackend) (*corev1.PodSpec, *corev1.Service, error) { - return nil, nil, nil -} -func (stubExternalAdapter) InjectEngineConfig(*corev1.PodSpec, string, *cachev1alpha1.CacheBackend) error { - return nil -} -func (stubExternalAdapter) InjectRouterConfig(*corev1.PodSpec, string, *cachev1alpha1.CacheBackend) error { - return nil -} -func (stubExternalAdapter) ObservationSidecar(*cachev1alpha1.CacheBackend, *corev1.Pod) (*corev1.Container, error) { - return nil, nil -} - -// stubExternalAdapter's reserved set mirrors the production adapter at -// pkg/adapters/runtime/external — same load-bearing LMCache wire, so the -// same flag/env entries are reserved. Keeping them aligned here means the -// reserved-args/env admission check exercises the External adapter -// realistically rather than against an artificially-empty surface. -func (stubExternalAdapter) ReservedArgs() []string { return []string{"--kv-transfer-config"} } -func (stubExternalAdapter) ReservedEnv() []string { - return []string{"LMCACHE_REMOTE_URL", "VLLM_USE_V1", "INFERENCECACHE_FAIL_OPEN", "PYTHONHASHSEED"} -} -func (stubExternalAdapter) EngineContainerName() string { return "vllm" } -func (stubExternalAdapter) SupportedPairs() []adapterruntime.SupportedPair { - return []adapterruntime.SupportedPair{{ - Runtime: adapterruntime.RuntimeVLLM, - Backend: cachev1alpha1.CacheBackendTypeExternal, - }} -} - // stubRegistry returns a Registry with the stub vLLM+LMCache adapter // installed. Hermetic — tests don't depend on the in-tree -// builtin adapter composition, so they keep passing if a -// future adapter joins or leaves the default set. An External-specific -// runtime adapter is added by stubRegistryWithExternal so tests that -// exercise admission of External CRs run against both adapters the -// production wiring registers. +// builtin adapter composition, so they keep passing if a future adapter joins +// or leaves the default set. External ownership uses this same runtime adapter; +// it is a remote-storage binding property, not a separate cache type. func stubRegistry() *adapterruntime.Registry { r := adapterruntime.NewRegistry() - r.Register(stubVLLMLMCacheAdapter{}) + r.Register(builtinruntime.NewVLLMLMCacheAdapter()) return r } -// stubRegistryWithExternal mirrors the production cmd/controller wiring: -// the stub managed-LMCache adapter PLUS a stub External adapter that -// supports the same (vllm, External) pair the real External adapter -// supports. Tests of admission's External-with-supported-engine and -// External-with-unsupported-engine branches use this so they assert -// against the registry composition the running controller actually -// sees, rather than the bare stubRegistry that omits External. +// stubRegistryWithExternal uses the same LMCache runtime adapter because +// external ownership is a remote-storage property, not a cache type. func stubRegistryWithExternal() *adapterruntime.Registry { - r := stubRegistry() - r.Register(stubExternalAdapter{}) - return r + return stubRegistry() } func TestValidator_RuntimeAdapter_VLLMPlusLMCacheAdmitted(t *testing.T) { // Happy path: an explicit (vLLM, LMCache) pair the stub registry // supports must be admitted. Pins the C7 check's positive side so a - // regression doesn't silently start rejecting it. (vLLM+LMCache is one of - // the shipping pairs; vLLM+Mooncake is the other — see - // TestValidator_RuntimeAdapter_VLLMPlusMooncakeAdmittedViaShippingRegistry, - // which checks it against the real built-in registry rather than this stub.) + // regression doesn't silently start rejecting it. v := &CacheBackendValidator{Registry: stubRegistry()} cb := newBackend() // type=LMCache - cb.Spec.Integration = &cachev1alpha1.CacheBackendIntegrationSpec{Engine: "vllm"} + cb.Spec.Runtime = cachev1alpha1.CacheBackendRuntimeVLLM + cb.Spec.Integration = &cachev1alpha1.CacheBackendIntegrationSpec{} if _, err := v.ValidateCreate(context.Background(), cb); err != nil { t.Fatalf("vLLM+LMCache rejected: %v", err) } @@ -2340,7 +2077,8 @@ func TestValidator_RuntimeAdapter_SGLangPlusLMCacheAdmitted(t *testing.T) { // adapter is not actually wired into the validator's registry. v := &CacheBackendValidator{Registry: defaultShippingRegistry()} cb := newBackend() // type=LMCache - cb.Spec.Integration = &cachev1alpha1.CacheBackendIntegrationSpec{Engine: "sglang"} + cb.Spec.Runtime = cachev1alpha1.CacheBackendRuntimeSGLang + cb.Spec.Integration = &cachev1alpha1.CacheBackendIntegrationSpec{} if _, err := v.ValidateCreate(context.Background(), cb); err != nil { t.Fatalf("sglang+LMCache rejected: %v", err) } @@ -2353,9 +2091,9 @@ func TestValidator_RuntimeAdapter_SGLangPlusExternalRejected(t *testing.T) { // the actionable alternative. v := &CacheBackendValidator{Registry: defaultShippingRegistry()} cb := newBackend() - cb.Spec.Type = cachev1alpha1.CacheBackendTypeExternal - cb.Spec.Endpoint = "cache.example.com:65432" // shape-valid External endpoint - cb.Spec.Integration = &cachev1alpha1.CacheBackendIntegrationSpec{Engine: "sglang"} + cb.Spec.Type = cachev1alpha1.CacheBackendType("unsupported") + cb.Spec.Runtime = cachev1alpha1.CacheBackendRuntimeSGLang + cb.Spec.Integration = &cachev1alpha1.CacheBackendIntegrationSpec{} _, err := v.ValidateCreate(context.Background(), cb) if err == nil { @@ -2367,15 +2105,15 @@ func TestValidator_RuntimeAdapter_SGLangPlusExternalRejected(t *testing.T) { } var match *metav1.StatusCause for i := range statusErr.Status().Details.Causes { - if statusErr.Status().Details.Causes[i].Field == "spec.integration.engine" { + if statusErr.Status().Details.Causes[i].Field == "spec.runtime" { match = &statusErr.Status().Details.Causes[i] break } } if match == nil { - t.Fatalf("no cause on spec.integration.engine; got: %+v", statusErr.Status().Details.Causes) + t.Fatalf("no cause on spec.runtime; got: %+v", statusErr.Status().Details.Causes) } - for _, want := range []string{"sglang", "External", "sglang/LMCache"} { + for _, want := range []string{"sglang", "unsupported", "sglang/LMCache"} { if !strings.Contains(match.Message, want) { t.Errorf("rejection message missing %q; got %q", want, match.Message) } @@ -2386,17 +2124,17 @@ func TestValidator_RuntimeAdapter_VLLMPlusUnsupportedTypeRejected(t *testing.T) // Rejection path: a (vLLM, ) pair no installed adapter // supports must be rejected with a message that names BOTH sides of // the offending pair and lists the supported pairs so the user has - // an actionable next step. AIBrix is the example because no shipping - // adapter handles it (unlike Mooncake, which this PR added an adapter - // for) — so it stays a genuinely-unsupported pair in any registry. + // an actionable next step. The arbitrary value below is unsupported by any + // shipping adapter. v := &CacheBackendValidator{Registry: stubRegistry()} cb := newBackend() - cb.Spec.Type = cachev1alpha1.CacheBackendTypeAIBrix - cb.Spec.Integration = &cachev1alpha1.CacheBackendIntegrationSpec{Engine: "vllm"} + cb.Spec.Type = cachev1alpha1.CacheBackendType("unsupported") + cb.Spec.Runtime = cachev1alpha1.CacheBackendRuntimeVLLM + cb.Spec.Integration = &cachev1alpha1.CacheBackendIntegrationSpec{} _, err := v.ValidateCreate(context.Background(), cb) if err == nil { - t.Fatalf("expected vLLM+AIBrix to be rejected") + t.Fatalf("expected vLLM+unsupported to be rejected") } statusErr, ok := err.(*apierrors.StatusError) if !ok { @@ -2408,15 +2146,15 @@ func TestValidator_RuntimeAdapter_VLLMPlusUnsupportedTypeRejected(t *testing.T) var match *metav1.StatusCause causes := statusErr.Status().Details.Causes for i := range causes { - if causes[i].Field == "spec.integration.engine" { + if causes[i].Field == "spec.runtime" { match = &causes[i] break } } if match == nil { - t.Fatalf("no cause on spec.integration.engine; got: %+v", causes) + t.Fatalf("no cause on spec.runtime; got: %+v", causes) } - for _, want := range []string{"vllm", "AIBrix", "vllm/LMCache"} { + for _, want := range []string{"vllm", "unsupported", "vllm/LMCache"} { if !strings.Contains(match.Message, want) { t.Errorf("rejection message missing %q; got %q", want, match.Message) } @@ -2429,8 +2167,9 @@ func TestValidator_RuntimeAdapter_UnknownEngineRejected(t *testing.T) { // and only failing at reconcile. v := &CacheBackendValidator{Registry: stubRegistry()} cb := newBackend() // type=LMCache - cb.Spec.Integration = &cachev1alpha1.CacheBackendIntegrationSpec{Engine: "vllmm"} - requireInvalidWithCause(t, v, cb, "spec.integration.engine", + cb.Spec.Runtime = cachev1alpha1.CacheBackendRuntime("vllmm") + cb.Spec.Integration = &cachev1alpha1.CacheBackendIntegrationSpec{} + requireInvalidWithCause(t, v, cb, "spec.runtime", "engine=\"vllmm\"") } @@ -2440,7 +2179,8 @@ func TestValidator_RuntimeAdapter_EngineNormalisedToLowerCase(t *testing.T) { // not admitted by one layer and rejected by the other. v := &CacheBackendValidator{Registry: stubRegistry()} cb := newBackend() // type=LMCache - cb.Spec.Integration = &cachev1alpha1.CacheBackendIntegrationSpec{Engine: "VLLM"} + cb.Spec.Runtime = cachev1alpha1.CacheBackendRuntimeVLLM + cb.Spec.Integration = &cachev1alpha1.CacheBackendIntegrationSpec{} if _, err := v.ValidateCreate(context.Background(), cb); err != nil { t.Fatalf("VLLM (uppercase) + LMCache rejected: %v", err) } @@ -2450,8 +2190,8 @@ func TestValidator_RuntimeAdapter_EmptyEngineDefaultsToVLLM(t *testing.T) { // Engine is optional on the CRD; the reconciler and pod webhook // default it to vLLM via adapterruntime.ResolveRuntimeID, so // admission must use the same defaulting or pairs like - // "type: AIBrix with no engine" slip past the webhook and only - // fail at reconcile (the exact gap C7 closes). AIBrix has no adapter + // an unsupported type with no engine slip past the webhook and only + // fail at reconcile (the exact gap C7 closes). The value has no adapter // in any registry, so it stays a genuinely-unsupported example. // // With LMCache the default vLLM pair is supported → admit. @@ -2464,16 +2204,14 @@ func TestValidator_RuntimeAdapter_EmptyEngineDefaultsToVLLM(t *testing.T) { func TestValidator_RuntimeAdapter_EmptyEngineWithUnsupportedTypeRejected(t *testing.T) { // Counterpart to the previous test: the default vLLM resolution - // must also fire C7 — type: AIBrix with no engine must be + // must also fire C7 — an unsupported type with no engine must be // rejected at admission, since the reconciler would otherwise try - // vllm+AIBrix and fall back to unmanaged. (AIBrix, not Mooncake: - // this PR added a vllm+Mooncake adapter, so Mooncake is no longer an - // unsupported pair.) + // vllm/unsupported and fall back to unmanaged. v := &CacheBackendValidator{Registry: stubRegistry()} cb := newBackend() - cb.Spec.Type = cachev1alpha1.CacheBackendTypeAIBrix - requireInvalidWithCause(t, v, cb, "spec.integration.engine", - "backend=\"AIBrix\"") + cb.Spec.Type = cachev1alpha1.CacheBackendType("unsupported") + requireInvalidWithCause(t, v, cb, "spec.runtime", + "backend=\"unsupported\"") } func TestValidator_RuntimeAdapter_EmptyTypeSkipsCheck(t *testing.T) { @@ -2482,22 +2220,21 @@ func TestValidator_RuntimeAdapter_EmptyTypeSkipsCheck(t *testing.T) { v := &CacheBackendValidator{Registry: stubRegistry()} cb := newBackend() cb.Spec.Type = "" - cb.Spec.Integration = &cachev1alpha1.CacheBackendIntegrationSpec{Engine: "vllm"} + cb.Spec.Runtime = cachev1alpha1.CacheBackendRuntimeVLLM + cb.Spec.Integration = &cachev1alpha1.CacheBackendIntegrationSpec{} if _, err := v.ValidateCreate(context.Background(), cb); err != nil { t.Fatalf("empty type must not trigger C7; got %v", err) } } func TestValidator_RuntimeAdapter_ExternalWithSupportedEngineAdmitted(t *testing.T) { - // External flows through the adapter registry the same way managed - // types do (the pod webhook needs to find an adapter to wire engine - // pods to the operator-supplied endpoint). vLLM is the engine the - // External adapter supports today, so the pair must admit. - v := &CacheBackendValidator{Registry: stubRegistryWithExternal()} + // External ownership does not change runtime-adapter selection: this remains + // a supported vLLM/LMCache pair with an external remote binding. + v := &CacheBackendValidator{Registry: stubRegistry()} cb := newBackend() - cb.Spec.Type = cachev1alpha1.CacheBackendTypeExternal - cb.Spec.Endpoint = "team-a-cache.team-a.svc.cluster.local:9000" - cb.Spec.Integration = &cachev1alpha1.CacheBackendIntegrationSpec{Engine: "vllm"} + setCanonicalExternalStorage(cb, "team-a-cache.team-a.svc.cluster.local:9000") + cb.Spec.Runtime = cachev1alpha1.CacheBackendRuntimeVLLM + cb.Spec.Integration = &cachev1alpha1.CacheBackendIntegrationSpec{} if _, err := v.ValidateCreate(context.Background(), cb); err != nil { t.Fatalf("External with engine=vllm rejected by C7: %v", err) } @@ -2509,13 +2246,14 @@ func TestValidator_RuntimeAdapter_ExternalWithUnsupportedEngineRejected(t *testi // would fail-open and never inject — the engine boots un-wired to the // external cache. Reject at admission with a useful error instead of // letting the silent miss happen. - v := &CacheBackendValidator{Registry: stubRegistryWithExternal()} + v := &CacheBackendValidator{Registry: stubRegistry()} cb := newBackend() - cb.Spec.Type = cachev1alpha1.CacheBackendTypeExternal - cb.Spec.Endpoint = "team-a-cache.team-a.svc.cluster.local:9000" - cb.Spec.Integration = &cachev1alpha1.CacheBackendIntegrationSpec{Engine: "sglang"} - requireInvalidWithCause(t, v, cb, "spec.integration.engine", - "backend=\"External\"") + setCanonicalExternalStorage(cb, "team-a-cache.team-a.svc.cluster.local:9000") + cb.Spec.Runtime = cachev1alpha1.CacheBackendRuntimeSGLang + cb.Spec.Runtime = cachev1alpha1.CacheBackendRuntimeSGLang + cb.Spec.Integration = &cachev1alpha1.CacheBackendIntegrationSpec{} + requireInvalidWithCause(t, v, cb, "spec.runtime", + "backend=\"LMCache\"") } func TestValidator_RuntimeAdapter_UpdateAlsoChecks(t *testing.T) { @@ -2524,9 +2262,9 @@ func TestValidator_RuntimeAdapter_UpdateAlsoChecks(t *testing.T) { // must be rejected just as it would on create. v := &CacheBackendValidator{Registry: stubRegistry()} old := newBackend() - old.Spec.Integration = &cachev1alpha1.CacheBackendIntegrationSpec{Engine: "vllm"} + old.Spec.Integration = &cachev1alpha1.CacheBackendIntegrationSpec{} newCB := old.DeepCopy() - newCB.Spec.Type = cachev1alpha1.CacheBackendTypeAIBrix + newCB.Spec.Type = cachev1alpha1.CacheBackendType("unsupported") _, err := v.ValidateUpdate(context.Background(), old, newCB) if err == nil || !apierrors.IsInvalid(err) { @@ -2539,27 +2277,38 @@ func TestValidator_RuntimeAdapter_DeleteSkipsCheck(t *testing.T) { // since admission) must still be allowed so operators can clean up. v := &CacheBackendValidator{Registry: stubRegistry()} cb := newBackend() - cb.Spec.Type = cachev1alpha1.CacheBackendTypeAIBrix - cb.Spec.Integration = &cachev1alpha1.CacheBackendIntegrationSpec{Engine: "vllm"} + cb.Spec.Type = cachev1alpha1.CacheBackendType("unsupported") + cb.Spec.Runtime = cachev1alpha1.CacheBackendRuntimeVLLM + cb.Spec.Integration = &cachev1alpha1.CacheBackendIntegrationSpec{} if _, err := v.ValidateDelete(context.Background(), cb); err != nil { t.Fatalf("ValidateDelete rejected unsupported pair: %v", err) } } -func TestValidator_RuntimeAdapter_NilRegistry_AdmitsExternal(t *testing.T) { - // The nil-Registry fallback mirrors production cmd/controller wiring - // through the complete built-in composition, so a bare - // `CacheBackendValidator{}` admits the same set the running controller does. - // Without External in the fallback, this CR would be rejected for "no - // adapter supports (vllm, External)" even though the production webhook - // wires it just fine. - v := &CacheBackendValidator{} +func TestValidator_RuntimeAdapter_ShippingRegistryAdmitsExternal(t *testing.T) { + // The explicitly injected shipping registry must admit the same pair the + // running controller can reconcile and inject. + v := shippingValidator() cb := newBackend() - cb.Spec.Type = cachev1alpha1.CacheBackendTypeExternal - cb.Spec.Endpoint = "ext.example.com:8200" - cb.Spec.Integration = &cachev1alpha1.CacheBackendIntegrationSpec{Engine: "vllm"} + setCanonicalExternalStorage(cb, "ext.example.com:8200") + cb.Spec.Runtime = cachev1alpha1.CacheBackendRuntimeVLLM + cb.Spec.Integration = &cachev1alpha1.CacheBackendIntegrationSpec{} if _, err := v.ValidateCreate(context.Background(), cb); err != nil { - t.Fatalf("nil-Registry fallback rejected vLLM+External: %v", err) + t.Fatalf("shipping registry rejected vLLM+External: %v", err) + } +} + +func TestValidator_RuntimeAdapter_NilRegistryRejectsMisconfiguration(t *testing.T) { + v := &CacheBackendValidator{} + _, err := v.ValidateCreate(context.Background(), newBackend()) + if err == nil || !apierrors.IsInvalid(err) || !strings.Contains(err.Error(), "registry is not configured") { + t.Fatalf("ValidateCreate error = %v, want invalid missing-registry error", err) + } +} + +func TestSetupCacheBackendWebhookRequiresRegistry(t *testing.T) { + if err := SetupCacheBackendWebhookWithManager(nil, nil); err == nil || !strings.Contains(err.Error(), "registry is required") { + t.Fatalf("SetupCacheBackendWebhookWithManager error = %v, want missing-registry error", err) } } @@ -2568,30 +2317,25 @@ func TestValidator_RuntimeAdapter_NilRegistryFallsBackToDefault(t *testing.T) { // against the complete built-in registry — the production safety net for // cmd/controller wiring drift. The built-in registry ships the vLLM+LMCache // adapter, so the happy pair admits. - v := &CacheBackendValidator{} + v := shippingValidator() cb := newBackend() // type=LMCache - cb.Spec.Integration = &cachev1alpha1.CacheBackendIntegrationSpec{Engine: "vllm"} + cb.Spec.Runtime = cachev1alpha1.CacheBackendRuntimeVLLM + cb.Spec.Integration = &cachev1alpha1.CacheBackendIntegrationSpec{} if _, err := v.ValidateCreate(context.Background(), cb); err != nil { t.Fatalf("nil-registry fallback rejected vLLM+LMCache: %v", err) } } func TestValidator_RuntimeAdapter_VLLMPlusMooncakeAdmittedViaShippingRegistry(t *testing.T) { - // The Mooncake admission contract: with the Mooncake adapter registered in - // the built-in registry, the registry-driven C7 check must ADMIT (vLLM, - // Mooncake). A zero-value validator (Registry nil) falls back to that real - // shipping registry, so this exercises the same adapter set the running - // controller installs — not a stub. (The - // stub-registry rejection tests above use AIBrix as their unsupported - // example — a type no shipping adapter handles — since Mooncake is now - // supported; this test is the real-registry counterpart proving Mooncake - // admits.) - v := &CacheBackendValidator{} + // Mooncake is a remote binding for the vLLM/LMCache runtime pair, so the + // shipping registry must admit it without a provider-specific runtime adapter. + v := shippingValidator() cb := newBackend() - cb.Spec.Type = cachev1alpha1.CacheBackendTypeMooncake - cb.Spec.Integration = &cachev1alpha1.CacheBackendIntegrationSpec{Engine: "vllm"} + setCanonicalMooncakeStorage(cb) + cb.Spec.Runtime = cachev1alpha1.CacheBackendRuntimeVLLM + cb.Spec.Integration = &cachev1alpha1.CacheBackendIntegrationSpec{} if _, err := v.ValidateCreate(context.Background(), cb); err != nil { - t.Fatalf("shipping registry rejected vLLM+Mooncake (adapter should be registered): %v", err) + t.Fatalf("shipping registry rejected vLLM/LMCache with Mooncake binding: %v", err) } } @@ -2645,7 +2389,6 @@ func TestServiceDNSNamespace(t *testing.T) { func withVLLMOverrides(o cachev1alpha1.EngineInjectionOverrides) *cachev1alpha1.CacheBackend { cb := newBackend() cb.Spec.Integration = &cachev1alpha1.CacheBackendIntegrationSpec{ - Engine: "vllm", EngineOverrides: &o, } return cb @@ -2657,7 +2400,8 @@ func TestValidator_EngineOverrides_NoOverrideAdmitted(t *testing.T) { // (byte-identical default) hinges on this. v := &CacheBackendValidator{Registry: stubRegistry()} cb := newBackend() - cb.Spec.Integration = &cachev1alpha1.CacheBackendIntegrationSpec{Engine: "vllm"} + cb.Spec.Runtime = cachev1alpha1.CacheBackendRuntimeVLLM + cb.Spec.Integration = &cachev1alpha1.CacheBackendIntegrationSpec{} if _, err := v.ValidateCreate(context.Background(), cb); err != nil { t.Fatalf("no-override CR rejected: %v", err) } @@ -2744,8 +2488,8 @@ func TestValidator_EngineOverrides_SuppressPythonHashSeedRejected(t *testing.T) // SELECTED adapter, not a hardcoded vLLM list. func withSGLangOverrides(o cachev1alpha1.EngineInjectionOverrides) *cachev1alpha1.CacheBackend { cb := newBackend() // type=LMCache + cb.Spec.Runtime = cachev1alpha1.CacheBackendRuntimeSGLang cb.Spec.Integration = &cachev1alpha1.CacheBackendIntegrationSpec{ - Engine: "sglang", EngineOverrides: &o, } return cb @@ -2795,7 +2539,8 @@ func TestValidator_SGLangRoleRejected(t *testing.T) { } { t.Run(string(role), func(t *testing.T) { cb := newBackend() // type=LMCache - cb.Spec.Integration = &cachev1alpha1.CacheBackendIntegrationSpec{Engine: "sglang", Role: role} + cb.Spec.Runtime = cachev1alpha1.CacheBackendRuntimeSGLang + cb.Spec.Integration = &cachev1alpha1.CacheBackendIntegrationSpec{Role: role} requireInvalidWithCause(t, v, cb, "spec.integration.role", "sglang") }) } @@ -2806,11 +2551,12 @@ func TestValidator_SGLangRoleReadWriteAndUnsetAdmitted(t *testing.T) { // SGLang role; both must admit. v := &CacheBackendValidator{Registry: defaultShippingRegistry()} cases := []*cachev1alpha1.CacheBackendIntegrationSpec{ - {Engine: "sglang"}, // role unset - {Engine: "sglang", Role: cachev1alpha1.CacheBackendIntegrationRoleReadWrite}, + {}, // role unset + {Role: cachev1alpha1.CacheBackendIntegrationRoleReadWrite}, } for _, integ := range cases { cb := newBackend() + cb.Spec.Runtime = cachev1alpha1.CacheBackendRuntimeSGLang cb.Spec.Integration = integ if _, err := v.ValidateCreate(context.Background(), cb); err != nil { t.Fatalf("sglang role=%q rejected: %v", integ.Role, err) @@ -2825,8 +2571,7 @@ func TestValidator_VLLMRoleReadOnlyStillAdmitted(t *testing.T) { v := &CacheBackendValidator{Registry: defaultShippingRegistry()} cb := newBackend() cb.Spec.Integration = &cachev1alpha1.CacheBackendIntegrationSpec{ - Engine: "vllm", - Role: cachev1alpha1.CacheBackendIntegrationRoleReadOnly, + Role: cachev1alpha1.CacheBackendIntegrationRoleReadOnly, } if _, err := v.ValidateCreate(context.Background(), cb); err != nil { t.Fatalf("vllm role=ReadOnly rejected by the sglang role rule: %v", err) @@ -3018,21 +2763,17 @@ func TestValidator_EngineOverrides_ValueFromAloneAdmitted(t *testing.T) { } func TestValidator_EngineOverrides_ExternalBackendChecksReservedSet(t *testing.T) { - // External now flows through the runtime-adapter check (it has its - // own adapter with its own ReservedArgs/ReservedEnv). engineOverrides - // on an External CR is structurally meaningful — the same canonical + // engineOverrides on an externally owned binding is structurally meaningful: + // the same canonical // LMCache wire reaches the engine pod whether the cache is managed // or operator-supplied, so suppressing `--kv-transfer-config` would // silently un-wire the integration in both cases. The - // reserved-args/env check must therefore fire on External just like - // on managed, and the registry the validator consults must include - // the External adapter so its declared reserved set is consulted. + // reserved-args/env check must therefore fire regardless of ownership. v := &CacheBackendValidator{Registry: stubRegistryWithExternal()} cb := withVLLMOverrides(cachev1alpha1.EngineInjectionOverrides{ SuppressArgs: []string{"--kv-transfer-config"}, }) - cb.Spec.Type = cachev1alpha1.CacheBackendTypeExternal - cb.Spec.Endpoint = "shared.team-a.svc.cluster.local:9000" + setCanonicalExternalStorage(cb, "shared.team-a.svc.cluster.local:9000") _, err := v.ValidateCreate(context.Background(), cb) if err == nil { t.Fatalf("External CR suppressing --kv-transfer-config admitted; reserved-arg check must fire on External too") @@ -3043,22 +2784,19 @@ func TestValidator_EngineOverrides_ExternalBackendChecksReservedSet(t *testing.T } func TestValidator_EngineOverrides_MooncakeBackendChecksReservedSet(t *testing.T) { - // Mooncake is a registered managed pair (vLLM+Mooncake) that reuses the - // LMCache connector wire (pointed at a mooncakestore:// remote), so it - // declares the SAME reserved args/env. An operator must not be able to + // A Mooncake binding reuses the LMCache connector wire (pointed at a + // mooncakestore:// remote), so the same runtime adapter declares the same + // reserved args/env. An operator must not be able to // un-wire it via engineOverrides any more than on LMCache/External. Use the - // built-in shipping registry (via the nil fallback) so the Mooncake - // adapter's own ReservedArgs/ReservedEnv - // drive the admission check — this pins the new registered pair's - // reserved-override enforcement on the admission surface, not just the - // adapter's returned slice (which the adapter unit test already covers). - v := &CacheBackendValidator{} + // explicitly injected built-in shipping registry so the shipping + // adapter's ReservedArgs/ReservedEnv drive the admission check. + v := shippingValidator() // Arg side: suppressing the connector arg must hard-reject, naming the flag. cbArg := withVLLMOverrides(cachev1alpha1.EngineInjectionOverrides{ SuppressArgs: []string{"--kv-transfer-config"}, }) - cbArg.Spec.Type = cachev1alpha1.CacheBackendTypeMooncake + setCanonicalMooncakeStorage(cbArg) if _, err := v.ValidateCreate(context.Background(), cbArg); err == nil || !strings.Contains(err.Error(), "--kv-transfer-config") { t.Fatalf("Mooncake CR suppressing --kv-transfer-config must reject naming the flag; got %v", err) @@ -3068,7 +2806,7 @@ func TestValidator_EngineOverrides_MooncakeBackendChecksReservedSet(t *testing.T cbEnv := withVLLMOverrides(cachev1alpha1.EngineInjectionOverrides{ Env: []corev1.EnvVar{{Name: "LMCACHE_REMOTE_URL", Value: "mooncakestore://evil:50051"}}, }) - cbEnv.Spec.Type = cachev1alpha1.CacheBackendTypeMooncake + setCanonicalMooncakeStorage(cbEnv) if _, err := v.ValidateCreate(context.Background(), cbEnv); err == nil || !strings.Contains(err.Error(), "LMCACHE_REMOTE_URL") { t.Fatalf("Mooncake CR overriding reserved LMCACHE_REMOTE_URL must reject naming the env; got %v", err) @@ -3078,18 +2816,16 @@ func TestValidator_EngineOverrides_MooncakeBackendChecksReservedSet(t *testing.T func TestValidator_EngineOverrides_NilRegistry_FallsBackToShippingSet(t *testing.T) { // A zero-value validator (Registry: nil) must consult the SAME // shipping adapter set in BOTH checkRuntimeAdapter and - // checkEngineOverrides — otherwise External admits the (vllm, External) - // pair via the External adapter in defaultShippingRegistry but then - // silently bypasses its reserved-arg enforcement here, letting an + // checkEngineOverrides — otherwise an external binding could admit and then + // silently bypass reserved-arg enforcement, letting an // operator un-wire the cache at the engine pod. Pin both halves of // the contract: nil-registry rejects External + suppressed // --kv-transfer-config with a field-scoped error. - v := &CacheBackendValidator{} + v := shippingValidator() cb := withVLLMOverrides(cachev1alpha1.EngineInjectionOverrides{ SuppressArgs: []string{"--kv-transfer-config"}, }) - cb.Spec.Type = cachev1alpha1.CacheBackendTypeExternal - cb.Spec.Endpoint = "shared.team-a.svc.cluster.local:9000" + setCanonicalExternalStorage(cb, "shared.team-a.svc.cluster.local:9000") _, err := v.ValidateCreate(context.Background(), cb) if err == nil { t.Fatalf("nil-registry validator admitted External + suppressed --kv-transfer-config; reserved-arg check must fire via the shipping-set fallback") @@ -3100,33 +2836,30 @@ func TestValidator_EngineOverrides_NilRegistry_FallsBackToShippingSet(t *testing } func TestValidator_EngineOverrides_ExternalBackendAdmittedWhenSafe(t *testing.T) { - // An External CR carrying engineOverrides that DON'T touch the - // adapter's reserved set must still admit — the surface is engine- - // agnostic and the External adapter's reserved set is identical to - // the managed adapter's (LMCache wire is shared). LMCACHE_CHUNK_SIZE + // An externally owned CR carrying engineOverrides that DON'T touch the + // adapter's reserved set must still admit. The LMCache wire is shared across + // ownership modes. LMCACHE_CHUNK_SIZE // is a perf knob, not reserved; suppressing or amending it is fine. - v := &CacheBackendValidator{Registry: stubRegistryWithExternal()} + v := &CacheBackendValidator{Registry: stubRegistry()} cb := withVLLMOverrides(cachev1alpha1.EngineInjectionOverrides{ Env: []corev1.EnvVar{{Name: "LMCACHE_CHUNK_SIZE", Value: "512"}}, }) - cb.Spec.Type = cachev1alpha1.CacheBackendTypeExternal - cb.Spec.Endpoint = "shared.team-a.svc.cluster.local:9000" + setCanonicalExternalStorage(cb, "shared.team-a.svc.cluster.local:9000") if _, err := v.ValidateCreate(context.Background(), cb); err != nil { t.Fatalf("External CR with non-reserved override rejected: %v", err) } } func TestValidator_EngineOverrides_ExternalRejectsPythonHashSeedOverride(t *testing.T) { - // The External adapter reserves the same env as the managed adapter - // (shared LMCache wire), so a PYTHONHASHSEED override on an External CR + // The shared LMCache runtime adapter reserves the same env across ownership + // modes, so a PYTHONHASHSEED override on an externally owned CR // is hard-rejected for the same reason — proving the correctness - // invariant holds across both spec.types, not just managed. - v := &CacheBackendValidator{Registry: stubRegistryWithExternal()} + // invariant holds across both ownership modes, not just managed. + v := &CacheBackendValidator{Registry: stubRegistry()} cb := withVLLMOverrides(cachev1alpha1.EngineInjectionOverrides{ Env: []corev1.EnvVar{{Name: "PYTHONHASHSEED", Value: "1"}}, }) - cb.Spec.Type = cachev1alpha1.CacheBackendTypeExternal - cb.Spec.Endpoint = "shared.team-a.svc.cluster.local:9000" + setCanonicalExternalStorage(cb, "shared.team-a.svc.cluster.local:9000") requireInvalidWithCause(t, v, cb, "spec.integration.engineOverrides.env[0].name", "PYTHONHASHSEED") @@ -3137,8 +2870,7 @@ func TestValidator_EngineOverrides_ExternalRejectsPythonHashSeedOverride(t *test // one way the implementation reads it (via spec.integration.mode). func eventsOnlyIntegration() *cachev1alpha1.CacheBackendIntegrationSpec { return &cachev1alpha1.CacheBackendIntegrationSpec{ - Engine: "vllm", - Mode: cachev1alpha1.CacheBackendIntegrationModeEventsOnly, + Mode: cachev1alpha1.CacheBackendIntegrationModeEventsOnly, } } @@ -3147,16 +2879,14 @@ func TestValidator_EventsOnly_ExternalTypeRejected(t *testing.T) { // operator-run offload server a connector would dial — the two are // contradictory. The rejection must point at spec.integration.mode (the // knob the operator flipped), not at spec.type. Use the registry that - // includes the External adapter so admission of the External CR runs the - // same path production does and the only firing rule is the events-only - // one. - v := &CacheBackendValidator{Registry: stubRegistryWithExternal()} + // uses the shipping LMCache adapter; the events-only remote-storage rule is + // the one that must fire. + v := &CacheBackendValidator{Registry: stubRegistry()} cb := newBackend() - cb.Spec.Type = cachev1alpha1.CacheBackendTypeExternal - cb.Spec.Endpoint = "team-a-cache.team-a.svc.cluster.local:9000" + setCanonicalExternalStorage(cb, "team-a-cache.team-a.svc.cluster.local:9000") cb.Spec.Integration = eventsOnlyIntegration() - requireInvalidWithCause(t, v, cb, "spec.integration.mode", - "incompatible with spec.type") + requireInvalidWithCause(t, v, cb, "spec.remoteStorage", + "provision no remote-storage provider") } func TestValidator_EventsOnly_AutoscalingRejected(t *testing.T) { @@ -3173,7 +2903,7 @@ func TestValidator_EventsOnly_AutoscalingRejected(t *testing.T) { } func TestValidator_EventsOnly_RemoteStorageRejected(t *testing.T) { - v := &CacheBackendValidator{} + v := shippingValidator() cb := newBackend() cb.Spec.Runtime = cachev1alpha1.CacheBackendRuntimeVLLM cb.Spec.Integration = eventsOnlyIntegration() @@ -3208,15 +2938,15 @@ func TestValidator_EventsOnly_MooncakeRejected(t *testing.T) { // (vLLM, Mooncake) adapter is registered: the runtime-adapter check ADMITS // the pair (Mooncake is supported), so without the events-only rule's // type check the CR would slip through and reconcile as active events-only. - // Use the built-in shipping registry via the nil fallback so the + // Use the explicitly injected built-in shipping registry so the // runtime-adapter check passes and // the events-only rule is the one that fires, on spec.integration.mode. - v := &CacheBackendValidator{} + v := shippingValidator() cb := newBackend() - cb.Spec.Type = cachev1alpha1.CacheBackendTypeMooncake + setCanonicalMooncakeStorage(cb) cb.Spec.Integration = eventsOnlyIntegration() - requireInvalidWithCause(t, v, cb, "spec.integration.mode", - "only supported with spec.type") + requireInvalidWithCause(t, v, cb, "spec.remoteStorage", + "provision no remote-storage provider") } func TestValidator_EventsOnly_OffloadDefaultLMCacheAdmitted(t *testing.T) { @@ -3227,8 +2957,7 @@ func TestValidator_EventsOnly_OffloadDefaultLMCacheAdmitted(t *testing.T) { v := &CacheBackendValidator{Registry: stubRegistry()} cb := newBackend() // type=LMCache cb.Spec.Integration = &cachev1alpha1.CacheBackendIntegrationSpec{ - Engine: "vllm", - Mode: cachev1alpha1.CacheBackendIntegrationModeOffload, + Mode: cachev1alpha1.CacheBackendIntegrationModeOffload, } if _, err := v.ValidateCreate(context.Background(), cb); err != nil { t.Fatalf("Offload (default) LMCache rejected: %v", err) diff --git a/pkg/adapters/runtime/adapter.go b/pkg/adapters/runtime/adapter.go index 412176b5..c5aae7fe 100644 --- a/pkg/adapters/runtime/adapter.go +++ b/pkg/adapters/runtime/adapter.go @@ -12,10 +12,9 @@ import ( ) // RuntimeID identifies an inference-engine family that a runtime adapter -// handles. Values mirror the free-form string carried in -// CacheBackend.Spec.Integration.Engine — this project deliberately does not -// model a ServingRuntime CRD (cf. OEP-0010's *v1beta1.ServingRuntimeSpec), so -// engine identity flows as a plain identifier the reconciler can pass through. +// handles. Values are resolved from CacheBackend.Spec.Runtime; this project +// deliberately does not model a ServingRuntime CRD (cf. OEP-0010's +// *v1beta1.ServingRuntimeSpec). type RuntimeID string // Canonical runtime identifiers. Adapters are free to support additional @@ -41,23 +40,29 @@ type KVCacheRuntimeAdapter interface { // (runtime, backend) pair; cache is never nil at the call site. Supports(runtime RuntimeID, cache *cachev1alpha1.CacheBackend) bool - // InjectEngineConfig mutates pod so the engine talks to the cache at - // endpoint. Implementations MUST merge: preserve existing containers, - // env, args, and volumes; only add or update what they own. Safe to call - // repeatedly on the same pod. - InjectEngineConfig(pod *corev1.PodSpec, endpoint string, cache *cachev1alpha1.CacheBackend) error + // SupportsBinding reports whether the adapter accepts the structured remote + // storage binding. A nil binding means host-only operation. Admission and + // reconciliation call this before injection so unsupported runtime/provider + // combinations fail at the contract boundary. + SupportsBinding(binding *backendadapter.Binding) bool + + // InjectEngineConfig mutates pod so the engine uses binding. Implementations + // MUST merge: preserve existing containers, env, args, and volumes; only add + // or update what they own. Safe to call repeatedly on the same pod. A nil + // binding means host-only operation. + InjectEngineConfig(pod *corev1.PodSpec, binding *backendadapter.Binding, cache *cachev1alpha1.CacheBackend) error // InjectRouterConfig mutates a router pod so it can route cache-aware - // requests through endpoint. Same merge contract as InjectEngineConfig. + // requests through binding. Same merge contract as InjectEngineConfig. // Backends without a router component should return nil without // touching pod. - InjectRouterConfig(pod *corev1.PodSpec, endpoint string, cache *cachev1alpha1.CacheBackend) error + InjectRouterConfig(pod *corev1.PodSpec, binding *backendadapter.Binding, cache *cachev1alpha1.CacheBackend) error // ObservationSidecar returns the container that observes the engine pod // for the cache plane (the KV-event subscriber for vLLM/LMCache), or // (nil, nil) when no sidecar is needed for this (engine, backend) pair - // — for example, the deprecated legacy External adapter, or a future - // backend that exports observation data some other way. Returning a + // — for example, a future backend that exports observation data some other + // way. Returning a // container does not by itself mutate pod; // the Pod webhook appends it after [InjectEngineConfig] (idempotent: if // a container with the same Name is already present, the caller skips @@ -102,97 +107,6 @@ type KVCacheRuntimeAdapter interface { EngineContainerName() string } -// LegacyCacheServerRenderer is the pre-separation provider-rendering seam. -// Shipping provider adapters call the standalone Resolve*Server functions -// directly; this interface remains only so legacy tests and out-of-tree -// adapters can migrate without keeping provider lifecycle on -// KVCacheRuntimeAdapter. -type LegacyCacheServerRenderer interface { - ResolveCacheServer(*cachev1alpha1.CacheBackend) (*corev1.PodSpec, *corev1.Service, error) -} - -// ResolveLegacyCacheServer invokes the pre-separation rendering seam. -func ResolveLegacyCacheServer(adapter KVCacheRuntimeAdapter, cache *cachev1alpha1.CacheBackend) (*corev1.PodSpec, *corev1.Service, error) { - renderer, ok := adapter.(LegacyCacheServerRenderer) - if !ok { - return nil, nil, fmt.Errorf("runtime adapter has no legacy cache-server renderer") - } - return renderer.ResolveCacheServer(cache) -} - -// EndpointRequirement is an optional adapter capability for engine-local -// integrations that do not dial a separate cache server. Adapters that do not -// implement it require an endpoint by default, preserving the existing -// LMCache, Mooncake, and External behavior. -type EndpointRequirement interface { - RequiresEndpoint() bool -} - -// RemoteBindingAdapter is the canonical engine-side capability. It accepts an -// optional structured remote binding and owns no provider lifecycle. -type RemoteBindingAdapter interface { - SupportsRemoteBinding(*backendadapter.Binding) bool - InjectEngineConfigWithBinding(*corev1.PodSpec, *backendadapter.Binding, *cachev1alpha1.CacheBackend) error -} - -// AdapterRequiresEndpoint returns the endpoint requirement declared by -// adapter, defaulting to true for adapters that predate EndpointRequirement. -func AdapterRequiresEndpoint(adapter KVCacheRuntimeAdapter) bool { - requirement, ok := adapter.(EndpointRequirement) - return !ok || requirement.RequiresEndpoint() -} - -// AdapterRequiresEndpointFor evaluates endpoint need for a concrete cache -// hierarchy. Binding-aware adapters accept nil for host-only operation. -func AdapterRequiresEndpointFor(adapter KVCacheRuntimeAdapter, binding *backendadapter.Binding) bool { - if bindingAware, ok := adapter.(RemoteBindingAdapter); ok { - return !bindingAware.SupportsRemoteBinding(binding) || binding != nil - } - return AdapterRequiresEndpoint(adapter) -} - -// ValidateRemoteBinding verifies that adapter explicitly accepts binding for a -// canonical cache hierarchy. Legacy resources retain the endpoint-based -// fallback while out-of-tree adapters migrate to [RemoteBindingAdapter]. -func ValidateRemoteBinding(adapter KVCacheRuntimeAdapter, binding *backendadapter.Binding, cache *cachev1alpha1.CacheBackend) error { - bindingAware, ok := adapter.(RemoteBindingAdapter) - if !ok { - if cache != nil && cache.Spec.UsesCanonicalCacheHierarchy() { - return fmt.Errorf("runtime adapter does not implement the canonical remote-binding contract") - } - return nil - } - if !bindingAware.SupportsRemoteBinding(binding) { - return fmt.Errorf("runtime adapter does not accept remote binding protocol %q", bindingProtocol(binding)) - } - return nil -} - -// InjectEngineConfigWithBinding routes canonical resources through the -// structured binding contract and falls back to the legacy endpoint method for -// adapters that have not migrated yet only when the resource itself uses the -// legacy hierarchy. -func InjectEngineConfigWithBinding(adapter KVCacheRuntimeAdapter, pod *corev1.PodSpec, binding *backendadapter.Binding, cache *cachev1alpha1.CacheBackend) error { - if err := ValidateRemoteBinding(adapter, binding, cache); err != nil { - return err - } - if bindingAware, ok := adapter.(RemoteBindingAdapter); ok { - return bindingAware.InjectEngineConfigWithBinding(pod, binding, cache) - } - endpoint := "" - if binding != nil { - endpoint = binding.Endpoint - } - return adapter.InjectEngineConfig(pod, endpoint, cache) -} - -func bindingProtocol(binding *backendadapter.Binding) backendadapter.Protocol { - if binding == nil { - return "" - } - return binding.Protocol -} - // ErrNoAdapter is returned by [Registry.Select] when no registered adapter // supports a given (runtime, CacheBackend) pair. An admission validator can // translate this into a user-visible rejection; the reconciler logs and skips. @@ -298,29 +212,24 @@ func (r *Registry) SupportedPairs() []SupportedPair { // renders and the pod webhook injects, so the three callers must read the // CR identically. // -// The CR carries the engine name in Spec.Integration.Engine. When it is -// unset, vLLM is the Phase-1 default — the only engine the shipping -// adapters target — so a CacheBackend that omits the field is treated the -// same way the reconciler used to treat it before C7 landed. Engine values -// are normalised to lower case so common spellings ("vLLM", "VLLM", -// "SGLang") route to the canonical [RuntimeID] constants ([RuntimeVLLM] -// etc.). +// The CR carries the runtime identity in spec.runtime. The schema restricts +// persisted values to the supported case-sensitive enum. func ResolveRuntimeID(cache *cachev1alpha1.CacheBackend) RuntimeID { if cache == nil { - return RuntimeVLLM + return "" } - return RuntimeID(strings.ToLower(string(cache.Spec.EffectiveRuntime()))) + return RuntimeID(strings.ToLower(string(cache.Spec.Runtime))) } -// Options configures the runtime adapters [NewCoreRegistry] constructs and is -// passed through by the built-in production composition. Zero values are +// Options configures runtime adapters and is passed through by the built-in +// production composition. Zero values are // valid: empty PolicyServerGRPCAddress falls back to the package default, and // empty SubscriberImage disables sidecar auto-attach (see the field doc for // why). type Options struct { - // SubscriberImage is the image reference the vLLM/LMCache and - // vLLM/Mooncake adapters use for the kvevent-subscriber sidecar (both - // share the same builder — the KV-event stream is engine-side, not + // SubscriberImage is the image reference the vLLM/LMCache adapter uses for + // the kvevent-subscriber sidecar across remote bindings (the KV-event stream + // is engine-side, not // store-specific). Empty (the zero value) // **disables** sidecar auto-attach — the adapter returns no sidecar // at all. Auto-attach is opt-in by design: a nonexistent default @@ -352,40 +261,3 @@ func WithSubscriberImage(image string) Option { func WithPolicyServerGRPCAddress(addr string) Option { return func(o *Options) { o.PolicyServerGRPCAddress = addr } } - -// NewCoreRegistry returns a Registry containing only the runtime adapters -// implemented in this package — currently vLLM+LMCache and vLLM+Mooncake. It -// deliberately does NOT include -// the External passthrough adapter under pkg/adapters/runtime/external/: that -// package imports this one (for the [KVCacheRuntimeAdapter] interface and the -// [RuntimeID] constants), so registering it here would cycle. The -// complete shipping composition lives in internal/adapters/builtin, so -// production and nil-fallback paths agree on one supported set. Direct uses of -// NewCoreRegistry or the deprecated DefaultRegistry intentionally see only the -// in-package view (LMCache + Mooncake). -// -// Adapter order does not affect selection — [Registry.Select] matches on the -// (runtime, spec.type) pair and the in-package adapters cover disjoint pairs -// (vllm/LMCache, vllm/Mooncake) — so registering Mooncake alongside LMCache -// here is a pure addition. -// -// Options the controller cares about (subscriber sidecar image, policy-server -// address) are passed in via the variadic [Option] helpers and shared by both -// adapters (the kvevent-subscriber sidecar is identical for either L2 store); -// the no-arg form preserves the original Phase-1 behavior. -func NewCoreRegistry(opts ...Option) *Registry { - r := NewRegistry() - r.Register(NewVLLMLMCacheAdapter(opts...)) - r.Register(NewVLLMMooncakeAdapter(opts...)) - return r -} - -// DefaultRegistry is retained for source compatibility with existing -// extension tests. In-repository binaries and nil fallbacks use the complete -// composition root in internal/adapters/builtin. -// -// Deprecated: use NewCoreRegistry when intentionally testing only adapters -// implemented by this package. -func DefaultRegistry(opts ...Option) *Registry { - return NewCoreRegistry(opts...) -} diff --git a/pkg/adapters/runtime/adapter_test.go b/pkg/adapters/runtime/adapter_test.go index bf32b282..194aafcd 100644 --- a/pkg/adapters/runtime/adapter_test.go +++ b/pkg/adapters/runtime/adapter_test.go @@ -2,7 +2,6 @@ package runtime import ( "errors" - "strings" "testing" corev1 "k8s.io/api/core/v1" @@ -20,10 +19,6 @@ type stubAdapter struct { supportsFn func(runtime RuntimeID, cache *cachev1alpha1.CacheBackend) bool } -type endpointFreeStub struct{ stubAdapter } - -func (endpointFreeStub) RequiresEndpoint() bool { return false } - func (s stubAdapter) Supports(r RuntimeID, c *cachev1alpha1.CacheBackend) bool { if s.supportsFn == nil { return false @@ -31,15 +26,17 @@ func (s stubAdapter) Supports(r RuntimeID, c *cachev1alpha1.CacheBackend) bool { return s.supportsFn(r, c) } +func (stubAdapter) SupportsBinding(*backendadapter.Binding) bool { return true } + func (stubAdapter) ResolveCacheServer(*cachev1alpha1.CacheBackend) (*corev1.PodSpec, *corev1.Service, error) { return nil, nil, nil } -func (stubAdapter) InjectEngineConfig(*corev1.PodSpec, string, *cachev1alpha1.CacheBackend) error { +func (stubAdapter) InjectEngineConfig(*corev1.PodSpec, *backendadapter.Binding, *cachev1alpha1.CacheBackend) error { return nil } -func (stubAdapter) InjectRouterConfig(*corev1.PodSpec, string, *cachev1alpha1.CacheBackend) error { +func (stubAdapter) InjectRouterConfig(*corev1.PodSpec, *backendadapter.Binding, *cachev1alpha1.CacheBackend) error { return nil } @@ -58,12 +55,19 @@ func newCacheBackend(t cachev1alpha1.CacheBackendType, engine string) *cachev1al ObjectMeta: metav1.ObjectMeta{Name: "cache", Namespace: "ns1"}, Spec: cachev1alpha1.CacheBackendSpec{Type: t}, } - if engine != "" { - cb.Spec.Integration = &cachev1alpha1.CacheBackendIntegrationSpec{Engine: engine} + switch engine { + case "vllm": + cb.Spec.Runtime = cachev1alpha1.CacheBackendRuntimeVLLM + case "sglang": + cb.Spec.Runtime = cachev1alpha1.CacheBackendRuntimeSGLang } return cb } +func referenceBinding(endpoint string) *backendadapter.Binding { + return &backendadapter.Binding{Protocol: backendadapter.ProtocolLMCache, Endpoint: endpoint} +} + func TestRegistrySelectFirstMatchWins(t *testing.T) { r := NewRegistry() r.Register(stubAdapter{id: "first", supportsFn: func(RuntimeID, *cachev1alpha1.CacheBackend) bool { return true }}) @@ -151,18 +155,11 @@ func TestReferenceAdapterSupports(t *testing.T) { if a.Supports(RuntimeReference, nil) { t.Fatalf("Supports(reference, nil) = true, want false") } -} - -func TestReferenceAdapterResolveCacheServerIsNil(t *testing.T) { - a := NewReferenceAdapter() - cb := newCacheBackend(cachev1alpha1.CacheBackendTypeExternal, "") - - pod, svc, err := ResolveLegacyCacheServer(a, cb) - if err != nil { - t.Fatalf("ResolveCacheServer: %v", err) + if a.SupportsBinding(nil) { + t.Fatal("SupportsBinding(nil) = true, want false") } - if pod != nil || svc != nil { - t.Fatalf("ResolveCacheServer = (%v, %v), want (nil, nil) — reference adapter renders no cache-server", pod, svc) + if !a.SupportsBinding(referenceBinding("cache:65432")) { + t.Fatal("SupportsBinding(remote binding) = false, want true") } } @@ -185,7 +182,7 @@ func TestReferenceAdapterInjectEngineConfigMerges(t *testing.T) { }, } - if err := a.InjectEngineConfig(pod, "cache.ns1.svc.cluster.local:8000", cb); err != nil { + if err := a.InjectEngineConfig(pod, referenceBinding("cache.ns1.svc.cluster.local:8000"), cb); err != nil { t.Fatalf("InjectEngineConfig: %v", err) } @@ -215,11 +212,11 @@ func TestReferenceAdapterInjectEngineConfigIsIdempotent(t *testing.T) { pod := &corev1.PodSpec{Containers: []corev1.Container{{Name: "engine"}}} // First call writes the endpoint env. - if err := a.InjectEngineConfig(pod, "first.svc:9090", cb); err != nil { + if err := a.InjectEngineConfig(pod, referenceBinding("first.svc:9090"), cb); err != nil { t.Fatalf("first InjectEngineConfig: %v", err) } // Second call updates the value in place — must not duplicate the entry. - if err := a.InjectEngineConfig(pod, "second.svc:9090", cb); err != nil { + if err := a.InjectEngineConfig(pod, referenceBinding("second.svc:9090"), cb); err != nil { t.Fatalf("second InjectEngineConfig: %v", err) } @@ -247,14 +244,22 @@ func TestReferenceAdapterInjectRejectsBadInput(t *testing.T) { name string fn func() error }{ - {"nil pod", func() error { return a.InjectEngineConfig(nil, "x", cb) }}, - {"nil cache", func() error { return a.InjectEngineConfig(good, "x", nil) }}, - {"empty endpoint", func() error { return a.InjectEngineConfig(good, "", cb) }}, - {"no containers", func() error { return a.InjectEngineConfig(&corev1.PodSpec{}, "x", cb) }}, - {"router nil pod", func() error { return a.InjectRouterConfig(nil, "x", cb) }}, - {"router nil cache", func() error { return a.InjectRouterConfig(good, "x", nil) }}, - {"router empty endpoint", func() error { return a.InjectRouterConfig(good, "", cb) }}, - {"router no containers", func() error { return a.InjectRouterConfig(&corev1.PodSpec{}, "x", cb) }}, + {"nil pod", func() error { return a.InjectEngineConfig(nil, referenceBinding("x"), cb) }}, + {"nil cache", func() error { return a.InjectEngineConfig(good, referenceBinding("x"), nil) }}, + {"nil binding", func() error { return a.InjectEngineConfig(good, nil, cb) }}, + {"empty protocol", func() error { + return a.InjectEngineConfig(good, &backendadapter.Binding{Endpoint: "x"}, cb) + }}, + {"empty endpoint", func() error { return a.InjectEngineConfig(good, referenceBinding(""), cb) }}, + {"no containers", func() error { return a.InjectEngineConfig(&corev1.PodSpec{}, referenceBinding("x"), cb) }}, + {"router nil pod", func() error { return a.InjectRouterConfig(nil, referenceBinding("x"), cb) }}, + {"router nil cache", func() error { return a.InjectRouterConfig(good, referenceBinding("x"), nil) }}, + {"router nil binding", func() error { return a.InjectRouterConfig(good, nil, cb) }}, + {"router empty protocol", func() error { + return a.InjectRouterConfig(good, &backendadapter.Binding{Endpoint: "x"}, cb) + }}, + {"router empty endpoint", func() error { return a.InjectRouterConfig(good, referenceBinding(""), cb) }}, + {"router no containers", func() error { return a.InjectRouterConfig(&corev1.PodSpec{}, referenceBinding("x"), cb) }}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { @@ -270,7 +275,7 @@ func TestReferenceAdapterInjectRouterConfig(t *testing.T) { cb := newCacheBackend(cachev1alpha1.CacheBackendTypeLMCache, "") pod := &corev1.PodSpec{Containers: []corev1.Container{{Name: "router"}}} - if err := a.InjectRouterConfig(pod, "router.svc:9000", cb); err != nil { + if err := a.InjectRouterConfig(pod, referenceBinding("router.svc:9000"), cb); err != nil { t.Fatalf("InjectRouterConfig: %v", err) } v, ok := lookupEnv(pod.Containers[0].Env, EnvRouterEndpoint) @@ -295,43 +300,9 @@ func TestRegistryResolvesReferenceAdapterByRuntime(t *testing.T) { } } -func TestAdapterRequiresEndpointDefaultsTrue(t *testing.T) { - base := stubAdapter{} - if !AdapterRequiresEndpoint(base) { - t.Fatal("adapter without EndpointRequirement must require an endpoint") - } - if AdapterRequiresEndpoint(endpointFreeStub{stubAdapter: base}) { - t.Fatal("endpoint-free adapter was treated as endpoint-bearing") - } -} - -func TestCanonicalBindingRequiresExplicitAdapterCapability(t *testing.T) { - adapter := endpointFreeStub{stubAdapter: stubAdapter{}} - canonical := newCacheBackend(cachev1alpha1.CacheBackendTypeLMCache, "") - canonical.Spec.Runtime = cachev1alpha1.CacheBackendRuntimeVLLM - binding := &backendadapter.Binding{Protocol: backendadapter.ProtocolLMCache, Endpoint: "cache:65432"} - - if err := ValidateRemoteBinding(adapter, binding, canonical); err == nil || - !strings.Contains(err.Error(), "canonical remote-binding contract") { - t.Fatalf("ValidateRemoteBinding error = %v, want missing canonical capability", err) - } - pod := &corev1.PodSpec{Containers: []corev1.Container{{Name: "engine"}}} - if err := InjectEngineConfigWithBinding(adapter, pod, binding, canonical); err == nil { - t.Fatal("canonical injection unexpectedly used the legacy endpoint fallback") - } - - legacy := newCacheBackend(cachev1alpha1.CacheBackendTypeLMCache, "") - if err := ValidateRemoteBinding(adapter, binding, legacy); err != nil { - t.Fatalf("legacy binding compatibility rejected: %v", err) - } - if err := InjectEngineConfigWithBinding(adapter, pod, binding, legacy); err != nil { - t.Fatalf("legacy endpoint fallback rejected: %v", err) - } -} - func TestResolveRuntimeID(t *testing.T) { // ResolveRuntimeID is the single rule the admission validator, the - // reconciler, and the pod-mutating webhook all read the engine name + // reconciler, and the pod-mutating webhook all read the runtime identity // through — pinning it here prevents a future tweak in one layer // from quietly diverging from the others. cases := []struct { @@ -340,28 +311,23 @@ func TestResolveRuntimeID(t *testing.T) { want RuntimeID }{ { - name: "nil cache defaults to vllm", + name: "nil cache has no runtime", in: nil, - want: RuntimeVLLM, + want: "", }, { - name: "unset integration defaults to vllm", + name: "unset runtime stays empty", in: &cachev1alpha1.CacheBackend{}, - want: RuntimeVLLM, - }, - { - name: "empty engine defaults to vllm", - in: &cachev1alpha1.CacheBackend{Spec: cachev1alpha1.CacheBackendSpec{Integration: &cachev1alpha1.CacheBackendIntegrationSpec{}}}, - want: RuntimeVLLM, + want: "", }, { - name: "case-folded vLLM routes to canonical id", - in: &cachev1alpha1.CacheBackend{Spec: cachev1alpha1.CacheBackendSpec{Integration: &cachev1alpha1.CacheBackendIntegrationSpec{Engine: "vLLM"}}}, + name: "VLLM maps to canonical id", + in: &cachev1alpha1.CacheBackend{Spec: cachev1alpha1.CacheBackendSpec{Runtime: cachev1alpha1.CacheBackendRuntimeVLLM}}, want: RuntimeVLLM, }, { - name: "free-form engine passes through lowercased", - in: &cachev1alpha1.CacheBackend{Spec: cachev1alpha1.CacheBackendSpec{Integration: &cachev1alpha1.CacheBackendIntegrationSpec{Engine: "SGLang"}}}, + name: "SGLang maps to canonical id", + in: &cachev1alpha1.CacheBackend{Spec: cachev1alpha1.CacheBackendSpec{Runtime: cachev1alpha1.CacheBackendRuntimeSGLang}}, want: RuntimeID("sglang"), }, } diff --git a/pkg/adapters/runtime/doc.go b/pkg/adapters/runtime/doc.go index fc892156..7ecb6e0c 100644 --- a/pkg/adapters/runtime/doc.go +++ b/pkg/adapters/runtime/doc.go @@ -1,9 +1,13 @@ // Package runtime is the controller-owned runtime-adapter seam: the plug-point // that keeps engine-specific cache wiring out of the core CacheBackend // reconciler. Adapters implement [KVCacheRuntimeAdapter] (lifted from -// OEP-0010) to render the cache-server side and to inject engine/router pod -// configuration for a given (runtime, CacheBackend) pair. The [Registry] -// selects an adapter via each adapter's Supports method; admission can call -// [Registry.Select] to validate that a (runtime, backend) combination is -// supported. +// OEP-0010) to inject engine/router pod configuration for a given (runtime, +// CacheBackend) pair. Concrete adapters shipped by the controller live under +// internal/adapters/builtin/runtime; this package remains the designated seam +// for build-time out-of-tree adapters. The source contract is pre-stable and +// currently has no external consumers; custom controller forks must pin a +// repository revision and update their adapters when this interface changes. +// The [Registry] selects an adapter via each +// adapter's Supports method; the required SupportsBinding and injection methods +// consume the structured remote-storage binding, where nil means host-only. package runtime diff --git a/pkg/adapters/runtime/external/doc.go b/pkg/adapters/runtime/external/doc.go deleted file mode 100644 index e5f9411e..00000000 --- a/pkg/adapters/runtime/external/doc.go +++ /dev/null @@ -1,20 +0,0 @@ -// Package external is the runtime adapter for CacheBackend{type: External}: -// the controller does NOT provision pods for the cache, the operator points -// the CR at a pre-existing remote cache they manage themselves, and the -// adapter wires engine pods to that endpoint with the same engine wire -// format the managed-LMCache path uses (see -// pkg/adapters/runtime/internal/enginewire). -// -// Owner: controller. Selected by the C5 [runtime.Registry] when the -// reconciler dispatches a managed CacheBackend (which it does not, for -// External — the dispatch path early-returns via reconcileExternal) AND when -// the pod-mutating webhook resolves an adapter for an engine pod that the -// CR's spec.engineSelector matched. The pod-webhook path is the load- -// bearing one: without this adapter the webhook fail-opens an unwired -// engine pod and the External cache never receives traffic. -// -// internal/adapters/builtin is the composition root for the shipping registry. -// This adapter cannot register itself in package runtime because this package -// imports runtime for the extension contract, so a reciprocal import would -// cycle. -package external diff --git a/pkg/adapters/runtime/external/external.go b/pkg/adapters/runtime/external/external.go deleted file mode 100644 index 152c0869..00000000 --- a/pkg/adapters/runtime/external/external.go +++ /dev/null @@ -1,147 +0,0 @@ -package external - -import ( - "fmt" - - corev1 "k8s.io/api/core/v1" - - cachev1alpha1 "github.com/cachebox-project/inference-cache/api/v1alpha1" - runtimeadapter "github.com/cachebox-project/inference-cache/pkg/adapters/runtime" - "github.com/cachebox-project/inference-cache/pkg/adapters/runtime/internal/enginewire" -) - -// adapter wires engine pods to a pre-existing remote cache the operator -// manages themselves. CacheBackend{type: External} carries the address in -// spec.endpoint; the controller never creates a cache-server Deployment for -// it. The engine wire format matches the managed-LMCache path — same env -// vars and --kv-transfer-config arg — so an engine pod cannot tell whether -// the cache it talks to was provisioned by the controller or by the -// operator out-of-band. -// -// The Supports gate is `runtime == vLLM && type == External`: a Mooncake- -// or SGLang-shaped External cache speaks a different engine wire and will -// land as a separate adapter, the same way managed Mooncake / SGLang -// adapters will live alongside the managed LMCache adapter. -type adapter struct{} - -// NewAdapter returns the runtime adapter for CacheBackend{type: External}. -// Wire it into the shared [runtime.Registry] in cmd/controller alongside -// the managed-LMCache adapter so the pod-mutating webhook picks it up for -// engine pods that match an External CR's spec.engineSelector. -func NewAdapter() runtimeadapter.KVCacheRuntimeAdapter { - return adapter{} -} - -// Supports matches vLLM engines against External CacheBackends. Other -// runtime / backend combinations are left for the per-engine managed -// adapter (e.g. vllm+LMCache) or for a future runtime-specific External -// adapter — the External wire is LMCache-compatible today. -func (adapter) Supports(runtime runtimeadapter.RuntimeID, cache *cachev1alpha1.CacheBackend) bool { - if cache == nil { - return false - } - return runtime == runtimeadapter.RuntimeVLLM && cache.Spec.Type == cachev1alpha1.CacheBackendTypeExternal -} - -// SupportedPairs lets the registry surface this adapter's canonical pair in -// the "no adapter supports the (engine, backend) pair" admission error so -// an operator who mistypes the backend type sees External as a candidate. -func (adapter) SupportedPairs() []runtimeadapter.SupportedPair { - return []runtimeadapter.SupportedPair{ - {Runtime: runtimeadapter.RuntimeVLLM, Backend: cachev1alpha1.CacheBackendTypeExternal}, - } -} - -// ResolveCacheServer returns (nil, nil, nil): the cache server is operator- -// managed and pre-exists, so the controller renders neither a pod nor a -// Service for it. The C2 reconciler already short-circuits on -// type==External before even consulting an adapter (see -// CacheBackendReconciler.dispatch), so this method is a safety net for any -// future code path that goes through Registry.Select for an External CR — -// it must never accidentally provision a placeholder cache-server. -func (adapter) ResolveCacheServer(cache *cachev1alpha1.CacheBackend) (*corev1.PodSpec, *corev1.Service, error) { - if cache == nil { - return nil, nil, fmt.Errorf("resolve cache server: cache is nil") - } - return nil, nil, nil -} - -// InjectEngineConfig wires the engine pod to the operator-supplied -// spec.endpoint via the same LMCache engine wire format the managed -// adapter uses (see enginewire.InjectVLLMLMCache). The pod-mutating -// webhook resolves the endpoint type-scoped: for External CRs it -// passes the trimmed cache.Spec.Endpoint (operator-authoritative; -// preferred over status.endpoint so a pod admitting between a -// spec.endpoint update and the reconciler's mirror is wired to the -// fresh address, not the stale one). The adapter itself doesn't -// know which field the caller pulled from — both paths land at the -// same wire — so this method just delegates to the shared helper. -func (adapter) InjectEngineConfig(pod *corev1.PodSpec, endpoint string, cache *cachev1alpha1.CacheBackend) error { - return enginewire.InjectVLLMLMCache(pod, endpoint, cache) -} - -// InjectRouterConfig is a no-op for External: the External topology has no -// router component the controller needs to wire. Returning nil keeps the -// interface contract satisfied so a Registry caller can blindly invoke both -// Inject* paths without branching on backend type — per -// [runtimeadapter.KVCacheRuntimeAdapter.InjectRouterConfig]: "backends -// without a router component should return nil without touching pod." -func (adapter) InjectRouterConfig(pod *corev1.PodSpec, endpoint string, cache *cachev1alpha1.CacheBackend) error { - _ = pod - _ = endpoint - _ = cache - return nil -} - -// ObservationSidecar returns (nil, nil): there is no controller-owned pod -// whose KV events we can subscribe to, and we deliberately do NOT inject a -// subscriber into the engine pod here — the engine talks to an operator- -// managed cache the controller has no observability seam into. A future -// follow-up could surface a scrape-only observation path for External -// caches; until then, [CacheBackend.Status.IndexParticipation] for an -// External backend stays nil unless a separate side-channel populates it. -func (adapter) ObservationSidecar(cache *cachev1alpha1.CacheBackend, pod *corev1.Pod) (*corev1.Container, error) { - if cache == nil { - return nil, fmt.Errorf("observation sidecar: cache is nil") - } - if pod == nil { - return nil, fmt.Errorf("observation sidecar: pod is nil") - } - return nil, nil -} - -// ReservedArgs returns the engine args the External adapter injects that -// the integration cannot function without. The wire format is identical -// to the managed vLLM+LMCache adapter (both call -// enginewire.InjectVLLMLMCache), so the reserved set must be identical -// too — an operator suppressing `--kv-transfer-config` on an External CR -// would un-wire the LMCache connector exactly the way it would on a -// managed CR, and the cache plane would silently stop routing through -// the operator's pre-existing cache. -func (adapter) ReservedArgs() []string { - return []string{"--kv-transfer-config"} -} - -// ReservedEnv returns the env var names the External adapter injects that -// the integration cannot function without. Same set as the managed -// vLLM+LMCache adapter; the rationale is identical (the engine must -// find the cache at the operator-supplied endpoint, run on the -// LMCache-targeting vLLM codepath, honor the fail-open contract, and pin -// the deterministic NONE_HASH so LMCache reload matches under TP>1). -func (adapter) ReservedEnv() []string { - return []string{ - enginewire.EnvLMCacheRemoteURL, - enginewire.EnvVLLMUseV1, - enginewire.EnvInferenceCacheFailOpen, - enginewire.EnvPythonHashSeed, - } -} - -// EngineContainerName returns the canonical name of the vLLM engine -// container the adapter mutates. The pod webhook uses this to scope -// engineOverrides edits to the same container InjectEngineConfig writes -// to — overrides land on the engine, not on user-attached sidecars. -func (adapter) EngineContainerName() string { return enginewire.EngineContainerName } - -// Compile-time assertion: the adapter implements the full C5 interface. -var _ runtimeadapter.KVCacheRuntimeAdapter = adapter{} diff --git a/pkg/adapters/runtime/external/external_test.go b/pkg/adapters/runtime/external/external_test.go deleted file mode 100644 index 5e7c6309..00000000 --- a/pkg/adapters/runtime/external/external_test.go +++ /dev/null @@ -1,294 +0,0 @@ -package external_test - -import ( - "strings" - "testing" - - corev1 "k8s.io/api/core/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - - cachev1alpha1 "github.com/cachebox-project/inference-cache/api/v1alpha1" - runtimeadapter "github.com/cachebox-project/inference-cache/pkg/adapters/runtime" - "github.com/cachebox-project/inference-cache/pkg/adapters/runtime/external" -) - -// externalBackend returns an External CacheBackend with a vLLM engine -// integration set. Used by every test that drives the adapter directly. -func externalBackend(endpoint string) *cachev1alpha1.CacheBackend { - return &cachev1alpha1.CacheBackend{ - ObjectMeta: metav1.ObjectMeta{Name: "ext-cache", Namespace: "default"}, - Spec: cachev1alpha1.CacheBackendSpec{ - Type: cachev1alpha1.CacheBackendTypeExternal, - Endpoint: endpoint, - Integration: &cachev1alpha1.CacheBackendIntegrationSpec{ - Engine: "vllm", - Role: cachev1alpha1.CacheBackendIntegrationRoleReadWrite, - }, - }, - } -} - -func TestSupports_AcceptsVLLMExternal(t *testing.T) { - a := external.NewAdapter() - cb := externalBackend("lm://cache.example:8200") - if !a.Supports(runtimeadapter.RuntimeVLLM, cb) { - t.Fatalf("External adapter must support (vllm, External); got false") - } -} - -func TestSupports_RejectsManagedTypes(t *testing.T) { - a := external.NewAdapter() - for _, bt := range []cachev1alpha1.CacheBackendType{ - cachev1alpha1.CacheBackendTypeLMCache, - cachev1alpha1.CacheBackendTypeMooncake, - cachev1alpha1.CacheBackendTypeAIBrix, - cachev1alpha1.CacheBackendTypeNIXL, - cachev1alpha1.CacheBackendTypeSGLangHiCache, - } { - cb := externalBackend("lm://x:1") - cb.Spec.Type = bt - if a.Supports(runtimeadapter.RuntimeVLLM, cb) { - t.Fatalf("External adapter must NOT support backend type %q", bt) - } - } -} - -func TestSupports_RejectsNonVLLMRuntime(t *testing.T) { - a := external.NewAdapter() - cb := externalBackend("lm://x:1") - if a.Supports(runtimeadapter.RuntimeSGLang, cb) { - t.Fatalf("External adapter must NOT support SGLang yet (engine wire is LMCache-shaped today)") - } -} - -func TestSupports_NilCache(t *testing.T) { - a := external.NewAdapter() - if a.Supports(runtimeadapter.RuntimeVLLM, nil) { - t.Fatalf("Supports must be false for nil cache") - } -} - -func TestResolveCacheServer_ReturnsNilTriple(t *testing.T) { - a := external.NewAdapter() - cb := externalBackend("lm://x:1") - pod, svc, err := runtimeadapter.ResolveLegacyCacheServer(a, cb) - if err != nil { - t.Fatalf("ResolveCacheServer must not error for a valid External CR: %v", err) - } - if pod != nil || svc != nil { - t.Fatalf("ResolveCacheServer must return nil pod + nil service for External; got pod=%v svc=%v", pod, svc) - } -} - -func TestResolveCacheServer_NilCacheErrors(t *testing.T) { - a := external.NewAdapter() - if _, _, err := runtimeadapter.ResolveLegacyCacheServer(a, nil); err == nil { - t.Fatalf("ResolveCacheServer must error on nil cache") - } -} - -func TestInjectEngineConfig_MatchesLMCacheWire(t *testing.T) { - // The External adapter must produce a byte-identical engine wire to the - // managed vLLM+LMCache adapter when both are pointed at the same - // endpoint and integration spec — that's the load-bearing invariant - // (the engine cannot tell the cache was operator-managed). - endpoint := "external-cache.example:8200" - cbExt := externalBackend(endpoint) - - cbLM := cbExt.DeepCopy() - cbLM.Spec.Type = cachev1alpha1.CacheBackendTypeLMCache - cbLM.Spec.Endpoint = "" - - pod := singleEnginePod() - if err := external.NewAdapter().InjectEngineConfig(&pod.Spec, endpoint, cbExt); err != nil { - t.Fatalf("External.InjectEngineConfig: %v", err) - } - - // Build the same pod via the managed adapter to compare. - lmReg := runtimeadapter.NewCoreRegistry() - lmAdapter, err := lmReg.Select(runtimeadapter.RuntimeVLLM, cbLM) - if err != nil { - t.Fatalf("NewCoreRegistry must select an adapter for vllm/LMCache: %v", err) - } - lmPod := singleEnginePod() - if err := lmAdapter.InjectEngineConfig(&lmPod.Spec, endpoint, cbLM); err != nil { - t.Fatalf("LMCache.InjectEngineConfig: %v", err) - } - - // Same env shape + values. - if !sameEnv(pod.Spec.Containers[0].Env, lmPod.Spec.Containers[0].Env) { - t.Fatalf("External engine env diverges from LMCache:\n external = %v\n lmcache = %v", - pod.Spec.Containers[0].Env, lmPod.Spec.Containers[0].Env) - } - // Same args ordering. - if !equalStrings(pod.Spec.Containers[0].Args, lmPod.Spec.Containers[0].Args) { - t.Fatalf("External engine args diverge from LMCache:\n external = %v\n lmcache = %v", - pod.Spec.Containers[0].Args, lmPod.Spec.Containers[0].Args) - } - - // Spot-check the operator-supplied endpoint is the one wired (not a - // controller-resolved Service DNS). - if got := envValue(pod.Spec.Containers[0].Env, runtimeadapter.EnvLMCacheRemoteURL); got != "lm://"+endpoint { - t.Fatalf("LMCACHE_REMOTE_URL = %q, want %q", got, "lm://"+endpoint) - } -} - -func TestInjectEngineConfig_IdempotentOnRepeatCall(t *testing.T) { - a := external.NewAdapter() - cb := externalBackend("lm://idem:1") - pod := singleEnginePod() - for i := 0; i < 3; i++ { - if err := a.InjectEngineConfig(&pod.Spec, "lm://idem:1", cb); err != nil { - t.Fatalf("InjectEngineConfig pass %d: %v", i, err) - } - } - // One LMCACHE_REMOTE_URL entry, not three. - count := 0 - for _, e := range pod.Spec.Containers[0].Env { - if e.Name == runtimeadapter.EnvLMCacheRemoteURL { - count++ - } - } - if count != 1 { - t.Fatalf("LMCACHE_REMOTE_URL count = %d after 3 injections, want 1", count) - } - // One --kv-transfer-config pair. - flagCount := 0 - for _, a := range pod.Spec.Containers[0].Args { - if a == "--kv-transfer-config" { - flagCount++ - } - } - if flagCount != 1 { - t.Fatalf("--kv-transfer-config count = %d after 3 injections, want 1", flagCount) - } -} - -func TestInjectEngineConfig_EmptyEndpointErrors(t *testing.T) { - a := external.NewAdapter() - pod := singleEnginePod() - cb := externalBackend("") - if err := a.InjectEngineConfig(&pod.Spec, "", cb); err == nil { - t.Fatalf("InjectEngineConfig must error on empty endpoint") - } else if !strings.Contains(err.Error(), "endpoint is empty") { - t.Fatalf("error message must name endpoint: %v", err) - } -} - -func TestInjectRouterConfig_NoOp(t *testing.T) { - a := external.NewAdapter() - pod := singleEnginePod() - before := deepCopyContainers(pod.Spec.Containers) - cb := externalBackend("lm://x:1") - if err := a.InjectRouterConfig(&pod.Spec, "lm://x:1", cb); err != nil { - t.Fatalf("InjectRouterConfig must be a no-op: %v", err) - } - if !sameContainers(before, pod.Spec.Containers) { - t.Fatalf("InjectRouterConfig mutated the pod:\n before = %v\n after = %v", before, pod.Spec.Containers) - } -} - -func TestObservationSidecar_ReturnsNil(t *testing.T) { - a := external.NewAdapter() - cb := externalBackend("lm://x:1") - pod := &corev1.Pod{ObjectMeta: metav1.ObjectMeta{Name: "p", Namespace: "default"}} - sidecar, err := a.ObservationSidecar(cb, pod) - if err != nil { - t.Fatalf("ObservationSidecar must not error: %v", err) - } - if sidecar != nil { - t.Fatalf("ObservationSidecar must return nil for External; got %+v", sidecar) - } -} - -func TestSupportedPairs_ListsExternal(t *testing.T) { - a := external.NewAdapter() - lister, ok := a.(runtimeadapter.PairLister) - if !ok { - t.Fatalf("External adapter must implement PairLister so admission error messages list it") - } - pairs := lister.SupportedPairs() - if len(pairs) != 1 { - t.Fatalf("SupportedPairs count = %d, want 1: %v", len(pairs), pairs) - } - if pairs[0].Runtime != runtimeadapter.RuntimeVLLM || pairs[0].Backend != cachev1alpha1.CacheBackendTypeExternal { - t.Fatalf("SupportedPairs[0] = %+v, want {vllm, External}", pairs[0]) - } -} - -// singleEnginePod returns a fresh single-container engine pod with the -// canonical container name, an existing user --model arg, and a user-set -// PRESERVE env entry. The injection contract must never drop or alter -// either of these. -func singleEnginePod() *corev1.Pod { - return &corev1.Pod{ - ObjectMeta: metav1.ObjectMeta{Name: "engine", Namespace: "default"}, - Spec: corev1.PodSpec{ - Containers: []corev1.Container{{ - Name: runtimeadapter.EngineContainerName, - Image: "vllm/vllm-openai:dev", - Args: []string{"--model", "Qwen/Qwen2.5-0.5B-Instruct"}, - Env: []corev1.EnvVar{{Name: "PRESERVE", Value: "yes"}}, - }}, - }, - } -} - -func sameEnv(a, b []corev1.EnvVar) bool { - if len(a) != len(b) { - return false - } - for i := range a { - if a[i].Name != b[i].Name || a[i].Value != b[i].Value { - return false - } - } - return true -} - -func envValue(env []corev1.EnvVar, name string) string { - for _, e := range env { - if e.Name == name { - return e.Value - } - } - return "" -} - -func equalStrings(a, b []string) bool { - if len(a) != len(b) { - return false - } - for i := range a { - if a[i] != b[i] { - return false - } - } - return true -} - -func deepCopyContainers(in []corev1.Container) []corev1.Container { - out := make([]corev1.Container, len(in)) - for i := range in { - out[i] = *in[i].DeepCopy() - } - return out -} - -func sameContainers(a, b []corev1.Container) bool { - if len(a) != len(b) { - return false - } - for i := range a { - if a[i].Name != b[i].Name || a[i].Image != b[i].Image { - return false - } - if !equalStrings(a[i].Args, b[i].Args) { - return false - } - if !sameEnv(a[i].Env, b[i].Env) { - return false - } - } - return true -} diff --git a/pkg/adapters/runtime/kernelcheck.go b/pkg/adapters/runtime/kernelcheck.go index 97242091..b58cdb97 100644 --- a/pkg/adapters/runtime/kernelcheck.go +++ b/pkg/adapters/runtime/kernelcheck.go @@ -2,120 +2,35 @@ package runtime import ( corev1 "k8s.io/api/core/v1" - "k8s.io/apimachinery/pkg/api/resource" cachev1alpha1 "github.com/cachebox-project/inference-cache/api/v1alpha1" ) -// Kernel-check wire contract. These constants are the single source of truth -// shared between the injecting adapter (writes the init container + script) -// and the C2 reconciler (reads the annotation + parses the termination -// message). The reconciler imports them so the two sides cannot drift. +// Kernel-check wire contract shared by the injecting built-in adapter and the +// controller that reads its annotation and termination message. const ( - // LMCacheKernelCheckContainerName is the init container the adapter - // injects into LMCache GPU engine pods. It force-loads the native - // lmcache c_ops extension to detect a CUDA-kernel/runtime mismatch - // (e.g. a cu13-built wheel on a cu12.9 image) that otherwise degrades - // T2 reload to a slow single-stream torch fallback, silently. LMCacheKernelCheckContainerName = "lmcache-kernel-check" + AnnotationLMCacheKernelCheck = "inferencecache.io/lmcache-kernel-check" - // AnnotationLMCacheKernelCheck selects the kernel-check mode, read off - // the bound CacheBackend. Values: KernelCheckMode*. Unset == auto. - AnnotationLMCacheKernelCheck = "inferencecache.io/lmcache-kernel-check" - - // Kernel-check modes (values of AnnotationLMCacheKernelCheck). - // auto — inject report-only iff the engine container requests a GPU. - // report-only — always inject, exit 0 even on failure (fail-open). - // strict — always inject, exit 1 on failure (engine pod stuck in Init). - // off — never inject. KernelCheckModeAuto = "auto" KernelCheckModeReportOnly = "report-only" KernelCheckModeStrict = "strict" KernelCheckModeOff = "off" - // Kernel-check termination-message contract. The init container writes - // exactly one of these prefixes to /dev/termination-log. The reconciler - // asserts a kernel mismatch ONLY on KernelCheckMsgFailPrefix; any other - // terminated message (or a non-zero exit with no message) is an - // indeterminate error, never a mismatch (avoids false alarms) and never - // healthy (avoids false greens). KernelCheckMsgOK = "OK" KernelCheckMsgFailPrefix = "FAIL:" - - // EnvKernelCheckStrict is the env var the adapter sets on the init - // container: "1" in strict mode, "0" otherwise (rendered explicitly in both - // modes so it overrides any value inherited from the engine's env/envFrom). - // The detector script and the controller both treat only "1" as strict. - EnvKernelCheckStrict = "KERNEL_CHECK_STRICT" - - // gpuResourceName is the extended resource an engine container requests - // when it wants a GPU. The kernel-check is GPU-only (c_ops/CUDA does not - // exist on a CPU build), so auto mode injects only when this is requested. - gpuResourceName = corev1.ResourceName("nvidia.com/gpu") + EnvKernelCheckStrict = "KERNEL_CHECK_STRICT" ) -// kernelCheckScript is the Python the init container runs against the engine -// image. It locates the package dir WITHOUT executing lmcache.__init__ (which -// swallows the c_ops failure into a WARNING and overrides -// sys.modules["lmcache.c_ops"] with a fallback shim, so a naive -// `import lmcache.c_ops` ALWAYS succeeds — a silent no-op). Instead it -// dlopens the native c_ops*.so from disk via ctypes.CDLL, which re-does the -// real dynamic load and raises on a missing/mismatched libcudart (empirically: -// "OSError: libcudart.so.13: cannot open shared object file"). torch MUST be -// imported first — the extension DT_NEEDs libtorch's libc10.so. -const kernelCheckScript = ` -import sys, os, glob, importlib.util, ctypes -STRICT = os.environ.get("KERNEL_CHECK_STRICT") == "1" -MSG = "/dev/termination-log" -def emit(s): - try: - with open(MSG, "w") as f: f.write(s[:3500]) - except Exception: - pass -def fail(s): - emit("FAIL: " + s) - sys.exit(1 if STRICT else 0) -try: - spec = importlib.util.find_spec("lmcache") - locs = list(spec.submodule_search_locations) if spec else [] - if not locs: - fail("lmcache not importable") - sos = sorted(glob.glob(os.path.join(locs[0], "c_ops*.so"))) - if not sos: - fail("no native c_ops extension present (pure-python/CPU build)") - import torch # required: c_ops.so DT_NEEDED libtorch (libc10.so) - # dlopen the native extension to force the dynamic loader to resolve every - # DT_NEEDED lib (libtorch, libcudart, ...). This is where a CUDA-kernel - # mismatch surfaces (e.g. a cu13 wheel on a cu12 image → "libcudart.so.13: - # cannot open shared object file"). ctypes.CDLL is used rather than - # importlib.exec_module on purpose: exec_module derives the C init symbol - # (PyInit_) from the spec name and would FAIL to find it for any - # name other than the extension's own, false-failing a HEALTHY engine. - # CDLL needs no init symbol — it tests exactly the dlopen/DT_NEEDED - # resolution where the kernel/CUDA mismatch lives. - ctypes.CDLL(sos[0]) - emit("OK") -except SystemExit: - raise -except BaseException as e: - fail("%s: %r" % (type(e).__name__, e)) -` - -// InitContainerProvider is the OPTIONAL interface an adapter implements when -// it injects a deploy-time init container into the engine pod. The pod -// webhook type-asserts the selected adapter to this interface (mirroring the -// PairLister optional-interface pattern), so adapters that have no init -// container (External passthrough, reference) need no change. Returning -// (nil, nil) means "no init container for this (cache, pod)". +// InitContainerProvider is the optional capability implemented by an adapter +// that renders an engine-pod init container. Returning nil means no check is +// required for the given cache and pod. type InitContainerProvider interface { KernelCheckInitContainer(cache *cachev1alpha1.CacheBackend, pod *corev1.Pod) (*corev1.Container, error) } -// IsValidKernelCheckMode reports whether s is an accepted value for the -// AnnotationLMCacheKernelCheck annotation. The empty string is accepted (the -// annotation is unset / treated as auto); any other unrecognized value is -// rejected by admission (see the CacheBackend validating webhook) so a typo -// like "strcit" can't silently relax strict enforcement back to report-only. +// IsValidKernelCheckMode reports whether s is an accepted annotation value. +// Empty means the default auto mode. func IsValidKernelCheckMode(s string) bool { switch s { case "", KernelCheckModeAuto, KernelCheckModeReportOnly, KernelCheckModeStrict, KernelCheckModeOff: @@ -124,85 +39,3 @@ func IsValidKernelCheckMode(s string) bool { return false } } - -// resolveKernelCheckMode returns the effective mode for a CacheBackend. -// Unrecognized values fall back to auto; admission rejects them before they -// reach here (IsValidKernelCheckMode), so in practice only the known values -// arrive — the fallback is a defense-in-depth default, not the typo guard. -func resolveKernelCheckMode(cache *cachev1alpha1.CacheBackend) string { - if cache == nil { - return KernelCheckModeAuto - } - switch cache.Annotations[AnnotationLMCacheKernelCheck] { - case KernelCheckModeReportOnly: - return KernelCheckModeReportOnly - case KernelCheckModeStrict: - return KernelCheckModeStrict - case KernelCheckModeOff: - return KernelCheckModeOff - default: - return KernelCheckModeAuto - } -} - -// engineContainerForKernelCheck resolves the engine container in pod whose -// image the init container reuses. Mirrors the adapter's documented -// convention: prefer the container named EngineContainerName; else, a -// single-container pod IS the engine; else (multi-container, no match) return -// nil so the caller skips. MUST be resolved before the webhook appends the -// observation sidecar (which would defeat the single-container fallback). -func engineContainerForKernelCheck(pod *corev1.Pod) *corev1.Container { - if pod == nil { - return nil - } - for i := range pod.Spec.Containers { - if pod.Spec.Containers[i].Name == EngineContainerName { - return &pod.Spec.Containers[i] - } - } - if len(pod.Spec.Containers) == 1 { - return &pod.Spec.Containers[0] - } - return nil -} - -// requestsGPU reports whether c requests an nvidia.com/gpu (limit or request -// with a positive quantity). -func requestsGPU(c *corev1.Container) bool { - if c == nil { - return false - } - for _, rl := range []corev1.ResourceList{c.Resources.Limits, c.Resources.Requests} { - if q, ok := rl[gpuResourceName]; ok && q.Sign() > 0 { - return true - } - } - return false -} - -// kernelCheckResources is the resource envelope for the init container: small -// CPU/memory requests, no limits, no nvidia.com/gpu. There is no resource shape -// that is fail-open under EVERY namespace policy (a ResourceQuota/LimitRange may -// REQUIRE per-container requests, while a LimitRange max may REJECT large -// ones); this is the most broadly-compatible compromise: -// - No nvidia.com/gpu: the missing-libcudart dlopen failure is caught at load -// time without a device. -// - Small requests (not none): a namespace with a `requests.*` ResourceQuota -// or a min-only LimitRange rejects a container that specifies no request, -// which would block the engine pod — so the check declares modest ones. The -// engine container (a GPU vLLM image needing GiB of RAM) requests far more, -// so these are below any per-container max it already satisfies AND are -// subsumed by it in the pod's effective request (init requests are max'd -// with, not summed onto, app requests) — no scheduling/quota footprint -// increase. -// - No limits: an explicit limit could exceed a LimitRange per-container max -// the engine still satisfies; omitting it lets any LimitRange default apply -// within bounds and leaves `import torch` bounded only by the pod/node. -func kernelCheckResources() corev1.ResourceRequirements { - return corev1.ResourceRequirements{ - Requests: corev1.ResourceList{ - corev1.ResourceCPU: resource.MustParse("50m"), - corev1.ResourceMemory: resource.MustParse("256Mi"), - }, - } -} diff --git a/pkg/adapters/runtime/kvevent_subscriber.go b/pkg/adapters/runtime/kvevent_subscriber.go index 7dca3a14..0ea488d6 100644 --- a/pkg/adapters/runtime/kvevent_subscriber.go +++ b/pkg/adapters/runtime/kvevent_subscriber.go @@ -38,7 +38,7 @@ type SubscriberSidecarParams struct { // RenderSubscriberSidecar renders the kvevent-subscriber sidecar the Pod webhook // appends to an engine pod so its KV-cache events flow to the policy server with // no out-of-band bring-up. It is shared by every adapter whose engine emits the -// vLLM-style ZMQ KV-event stream — the vLLM+LMCache and vLLM+Mooncake adapters +// vLLM-style ZMQ KV-event stream — the vLLM+LMCache adapter across its remote bindings // (HashScheme "vllm") and the SGLang+LMCache adapter (HashScheme "sglang") today // — because the stream is produced by the engine itself, independent of which L2 // store the engine offloads to. The engine dialect (HashScheme + EngineZMQPortStr) @@ -50,7 +50,7 @@ type SubscriberSidecarParams struct { // dials the engine over 127.0.0.1 (the ZMQ PUB endpoint on EngineZMQPortStr); // identity flags are derived from Cache + Pod (--replica-id from pod.Name via the // downward API, --tenant-id from pod.Namespace ditto, --model-id from -// spec.observation.modelID (or legacy backendConfig.model), --hash-scheme from +// spec.observation.modelID, --hash-scheme from // HashScheme) so the CR is the single source of truth. // // The flag surface here is deliberately the intersection of what the shipped diff --git a/pkg/adapters/runtime/provider_compat.go b/pkg/adapters/runtime/provider_compat.go deleted file mode 100644 index 1b7e848f..00000000 --- a/pkg/adapters/runtime/provider_compat.go +++ /dev/null @@ -1,32 +0,0 @@ -package runtime - -import ( - corev1 "k8s.io/api/core/v1" - - cachev1alpha1 "github.com/cachebox-project/inference-cache/api/v1alpha1" - provideradapter "github.com/cachebox-project/inference-cache/pkg/adapters/backend/provider" -) - -// ResolveLMCacheServer delegates to the storage-provider adapter. -// -// Deprecated: provider lifecycle belongs to -// pkg/adapters/backend/provider.ResolveLMCacheServer. -func ResolveLMCacheServer(cache *cachev1alpha1.CacheBackend) (*corev1.PodSpec, *corev1.Service, error) { - return provideradapter.ResolveLMCacheServer(cache) -} - -// ResolveRedisL2Server delegates to the storage-provider adapter. -// -// Deprecated: provider lifecycle belongs to -// pkg/adapters/backend/provider.ResolveRedisL2Server. -func ResolveRedisL2Server(cache *cachev1alpha1.CacheBackend) (*corev1.PodSpec, *corev1.Service, error) { - return provideradapter.ResolveRedisL2Server(cache) -} - -// ResolveMooncakeServer delegates to the storage-provider adapter. -// -// Deprecated: provider lifecycle belongs to -// pkg/adapters/backend/provider.ResolveMooncakeServer. -func ResolveMooncakeServer(cache *cachev1alpha1.CacheBackend) (*corev1.PodSpec, *corev1.Service, error) { - return provideradapter.ResolveMooncakeServer(cache) -} diff --git a/pkg/adapters/runtime/reference.go b/pkg/adapters/runtime/reference.go index 343f7875..e982c946 100644 --- a/pkg/adapters/runtime/reference.go +++ b/pkg/adapters/runtime/reference.go @@ -6,7 +6,7 @@ import ( corev1 "k8s.io/api/core/v1" cachev1alpha1 "github.com/cachebox-project/inference-cache/api/v1alpha1" - "github.com/cachebox-project/inference-cache/pkg/adapters/runtime/internal/enginewire" + backendadapter "github.com/cachebox-project/inference-cache/pkg/adapters/backend" ) // RuntimeReference is the [RuntimeID] the in-tree reference adapter matches. @@ -16,7 +16,7 @@ import ( const RuntimeReference RuntimeID = "reference" // EnvCacheEndpoint is the environment variable the reference adapter writes -// to every container in an engine pod, set to the endpoint argument of +// to every container in an engine pod, set to the binding endpoint passed to // InjectEngineConfig. It is exported so tests in this package and downstream // callers (admission validation, future adapter authors taking the reference // as a template) can assert on it. @@ -51,11 +51,11 @@ func (referenceAdapter) Supports(runtime RuntimeID, cache *cachev1alpha1.CacheBa return runtime == RuntimeReference } -// ResolveCacheServer renders no cache-server: the reference adapter wires -// engine/router pods directly to whatever endpoint the reconciler discovered, -// matching backends (such as LMCache) that colocate the cache with the engine. -func (referenceAdapter) ResolveCacheServer(*cachev1alpha1.CacheBackend) (*corev1.PodSpec, *corev1.Service, error) { - return nil, nil, nil +// SupportsBinding accepts any remote binding with a non-empty protocol. The +// reference adapter is protocol-neutral, but it is endpoint-backed rather than +// host-only, so nil is not accepted. +func (referenceAdapter) SupportsBinding(binding *backendadapter.Binding) bool { + return binding != nil && binding.Protocol != "" } // InjectEngineConfig sets [EnvCacheEndpoint] on every container in pod, @@ -63,14 +63,14 @@ func (referenceAdapter) ResolveCacheServer(*cachev1alpha1.CacheBackend) (*corev1 // updated in place (no duplicates), and unrelated entries are left untouched. // A nil or container-less pod is reported as an error so callers notice // caller-side bugs instead of silently producing a no-op. -func (referenceAdapter) InjectEngineConfig(pod *corev1.PodSpec, endpoint string, cache *cachev1alpha1.CacheBackend) error { - return injectEndpointEnv(pod, endpoint, cache, EnvCacheEndpoint, "engine") +func (referenceAdapter) InjectEngineConfig(pod *corev1.PodSpec, binding *backendadapter.Binding, cache *cachev1alpha1.CacheBackend) error { + return injectBindingEndpointEnv(pod, binding, cache, EnvCacheEndpoint, "engine") } // InjectRouterConfig sets [EnvRouterEndpoint] on every container in pod with // the same merge semantics as [referenceAdapter.InjectEngineConfig]. -func (referenceAdapter) InjectRouterConfig(pod *corev1.PodSpec, endpoint string, cache *cachev1alpha1.CacheBackend) error { - return injectEndpointEnv(pod, endpoint, cache, EnvRouterEndpoint, "router") +func (referenceAdapter) InjectRouterConfig(pod *corev1.PodSpec, binding *backendadapter.Binding, cache *cachev1alpha1.CacheBackend) error { + return injectBindingEndpointEnv(pod, binding, cache, EnvRouterEndpoint, "router") } // ObservationSidecar returns (nil, nil): the reference adapter is the @@ -103,25 +103,41 @@ func (referenceAdapter) ReservedEnv() []string { return nil } // integration. func (referenceAdapter) EngineContainerName() string { return "" } -// injectEndpointEnv is the shared implementation behind the reference +// injectBindingEndpointEnv is the shared implementation behind the reference // adapter's two inject paths. It is the worked example future adapters // should mirror: validate inputs, locate the role-specific containers, and // upsert (never blindly append) the env var that names the cache endpoint. -func injectEndpointEnv(pod *corev1.PodSpec, endpoint string, cache *cachev1alpha1.CacheBackend, envName, role string) error { +func injectBindingEndpointEnv(pod *corev1.PodSpec, binding *backendadapter.Binding, cache *cachev1alpha1.CacheBackend, envName, role string) error { if pod == nil { return fmt.Errorf("inject %s config: pod is nil", role) } if cache == nil { return fmt.Errorf("inject %s config: cache is nil", role) } - if endpoint == "" { + if binding == nil { + return fmt.Errorf("inject %s config: binding is nil", role) + } + if binding.Protocol == "" { + return fmt.Errorf("inject %s config: binding protocol is empty", role) + } + if binding.Endpoint == "" { return fmt.Errorf("inject %s config: endpoint is empty", role) } if len(pod.Containers) == 0 { return fmt.Errorf("inject %s config: pod has no containers", role) } for i := range pod.Containers { - pod.Containers[i].Env = enginewire.UpsertEnv(pod.Containers[i].Env, corev1.EnvVar{Name: envName, Value: endpoint}) + pod.Containers[i].Env = upsertEnv(pod.Containers[i].Env, corev1.EnvVar{Name: envName, Value: binding.Endpoint}) } return nil } + +func upsertEnv(env []corev1.EnvVar, want corev1.EnvVar) []corev1.EnvVar { + for i := range env { + if env[i].Name == want.Name { + env[i] = want + return env + } + } + return append(env, want) +} diff --git a/pkg/adapters/runtime/sglang/doc.go b/pkg/adapters/runtime/sglang/doc.go deleted file mode 100644 index 3bf726fc..00000000 --- a/pkg/adapters/runtime/sglang/doc.go +++ /dev/null @@ -1,27 +0,0 @@ -// Package sglang holds the controller-side runtime adapters that wire SGLang -// engine pods to LMCache or native HiCache. -// It is the SGLang sibling of the in-tree vLLM+LMCache adapter -// (pkg/adapters/runtime) and the External passthrough adapter -// (pkg/adapters/runtime/external): a separate package, gated on the SGLang -// runtime id, registered alongside the others by internal/adapters/builtin. -// -// Owner: the controller. Like external, this package imports its parent -// pkg/adapters/runtime for the [runtime.KVCacheRuntimeAdapter] interface and -// the [runtime.RuntimeID] constants, so it cannot be registered inside -// runtime.NewCoreRegistry without an import cycle. The built-in composition -// root adds them once for every production and nil-fallback path (see -// [NewAdapter] and [NewHiCacheAdapter]). -// -// SGLang adopted vLLM's KV-event wire wholesale: --kv-events-config drives a -// ZmqEventPublisher emitting the same msgspec array-like BlockStored / -// BlockRemoved / AllBlocksCleared tuples, so the shipped kvevent-subscriber -// binary decodes SGLang's stream unchanged — the only difference is the -// --hash-scheme=sglang tag that keeps SGLang prefixes in their own index -// domain (no cross-engine false hits against vLLM entries with identical -// prefix bytes). The engine-side LMCache *launch* surface differs from vLLM -// (--enable-lmcache + LMCACHE_USE_EXPERIMENTAL rather than -// --kv-transfer-config), and that wire lives in the internal enginewire -// package. Managed cache-server rendering belongs to -// pkg/adapters/backend/provider; subscriber-sidecar helpers remain shared with -// the vLLM adapter in pkg/adapters/runtime/lmcache_shared.go. -package sglang diff --git a/pkg/adapters/runtime/vllm_lmcache_kernelcheck.go b/pkg/adapters/runtime/vllm_lmcache_kernelcheck.go deleted file mode 100644 index f91f77c3..00000000 --- a/pkg/adapters/runtime/vllm_lmcache_kernelcheck.go +++ /dev/null @@ -1,113 +0,0 @@ -package runtime - -import ( - corev1 "k8s.io/api/core/v1" - - cachev1alpha1 "github.com/cachebox-project/inference-cache/api/v1alpha1" -) - -// KernelCheckInitContainer renders the lmcache-kernel-check init container for -// a vLLM+LMCache engine pod, or (nil, nil) when the gate does not apply. -// -// Decision table (mode from AnnotationLMCacheKernelCheck on the CacheBackend): -// -// off → nil (never inject) -// auto (default) + no GPU → nil (c_ops/CUDA is GPU-only; a CPU build has -// no c_ops and must not false-positive) -// auto + GPU → inject report-only -// report-only → inject report-only (even on CPU; operator forced) -// strict → inject strict (exit 1 on failure → pod stuck in Init) -// -// The init container reuses the resolved engine container's IMAGE, -// ImagePullPolicy, and SecurityContext so the check runs in the exact runtime -// that would load c_ops — no extra image pull (safe to default-on, unlike the -// subscriber sidecar), no skew between a cached check image and a freshly -// pulled serving image (mutable tags + Always), and the same security posture -// (so a pod valid under a restricted Pod Security Standard stays valid — the -// init container can't make an otherwise-admissible engine pod fail admission, -// preserving the fail-open contract). It requests no GPU (the missing-libcudart -// failure is caught at dlopen without a device). -// -// Returns (nil, nil) when the engine container can't be resolved (multi- -// container pod with no container named EngineContainerName) — emitting a -// container with no image to copy is worse than skipping; the webhook logs it. -func (vllmLMCacheAdapter) KernelCheckInitContainer(cache *cachev1alpha1.CacheBackend, pod *corev1.Pod) (*corev1.Container, error) { - if cache == nil || pod == nil { - return nil, nil - } - mode := resolveKernelCheckMode(cache) - if mode == KernelCheckModeOff { - return nil, nil - } - engine := engineContainerForKernelCheck(pod) - if engine == nil || engine.Image == "" { - return nil, nil - } - if mode == KernelCheckModeAuto && !requestsGPU(engine) { - return nil, nil - } - - strict := mode == KernelCheckModeStrict - - // Copy the engine's env so the check loads c_ops in the engine's actual - // environment (an operator-set PYTHONPATH / LD_LIBRARY_PATH that c_ops - // needs to dlopen would otherwise be absent and false-fail the check) — but - // strip any pre-existing KERNEL_CHECK_STRICT and set it explicitly per mode. - // Inheriting a stray KERNEL_CHECK_STRICT=1 from the engine container would - // otherwise flip report-only into a fail-CLOSED check and make the - // controller mis-classify the pod as strict. Setting it explicitly in - // container.Env also overrides any value sourced from the engine's - // EnvFrom (container.Env wins over envFrom for the same key). - strictVal := "0" - if strict { - strictVal = "1" - } - env := make([]corev1.EnvVar, 0, len(engine.Env)+1) - for _, e := range engine.Env { - if e.Name == EnvKernelCheckStrict { - continue - } - env = append(env, e) - } - env = append(env, corev1.EnvVar{Name: EnvKernelCheckStrict, Value: strictVal}) - - // Both modes invoke the engine image's own python3 directly; the script's - // KERNEL_CHECK_STRICT-keyed exit code is what makes report-only fail-open - // (always exit 0, even on a c_ops failure) and strict fail-closed (exit 1). - // python3 is the right entrypoint: it is the engine's OWN interpreter, so - // it is guaranteed present on any functioning vLLM/LMCache image — if it - // can't run, the Python engine itself is already broken, which is not a - // false serving outage caused by this check. (We deliberately do NOT wrap - // in /bin/sh to "guarantee" exit 0: a minimal/distroless image may lack a - // shell, which would reintroduce the very pod-block the wrapper was meant - // to avoid — and such an image lacks python3/lmcache too, so the check is - // moot there.) The residual block window — python3 truly cannot start, or - // an OOM during `import torch` — is left bounded only by the pod/node: the - // init container sets no memory limit (see kernelCheckResources), so a tight - // limit can't OOM it. Documented in cachebackend-api.md. - command := []string{"python3", "-c", kernelCheckScript} - - return &corev1.Container{ - Name: LMCacheKernelCheckContainerName, - Image: engine.Image, - // Copy (don't hard-code) the engine's pull policy, security context, - // env, env-from, mounts, and working dir so the check runs in the - // engine's exact image, security posture, and load environment: a - // mutable tag with ImagePullPolicy=Always must not let the check run a - // stale cached image; a restricted-PSA-compliant engine pod must stay - // admissible after the init container is appended; and an engine that - // depends on operator-supplied env/mounts to load c_ops must not be - // false-failed by a stripped-down checker. - ImagePullPolicy: engine.ImagePullPolicy, - SecurityContext: engine.SecurityContext.DeepCopy(), - WorkingDir: engine.WorkingDir, - Command: command, - Env: env, - EnvFrom: append([]corev1.EnvFromSource(nil), engine.EnvFrom...), - VolumeMounts: append([]corev1.VolumeMount(nil), engine.VolumeMounts...), - VolumeDevices: append([]corev1.VolumeDevice(nil), engine.VolumeDevices...), - Resources: kernelCheckResources(), - TerminationMessagePath: "/dev/termination-log", - TerminationMessagePolicy: corev1.TerminationMessageReadFile, - }, nil -} diff --git a/pkg/adapters/runtime/vllm_mooncake.go b/pkg/adapters/runtime/vllm_mooncake.go deleted file mode 100644 index da1f90f6..00000000 --- a/pkg/adapters/runtime/vllm_mooncake.go +++ /dev/null @@ -1,203 +0,0 @@ -package runtime - -import ( - corev1 "k8s.io/api/core/v1" - - cachev1alpha1 "github.com/cachebox-project/inference-cache/api/v1alpha1" - provideradapter "github.com/cachebox-project/inference-cache/pkg/adapters/backend/provider" - "github.com/cachebox-project/inference-cache/pkg/adapters/runtime/internal/enginewire" -) - -// vllmMooncakeAdapter wires vLLM engine pods to the Mooncake store that -// a provider adapter resolves. InjectEngineConfig adds the --kv-transfer-config -// arg and LMCACHE_* env vars to the vLLM container via the shared -// LMCache-connector wire (merging, never clobbering); -// ObservationSidecar returns the same kvevent-subscriber container the LMCache -// adapter does (the KV-event stream is engine-side, so the sidecar shape is -// identical) so the engine pod auto-attaches to the policy server with no -// out-of-band steps. -// -// Why the engine wire is the LMCache connector and not vLLM's native -// MooncakeStoreConnector: the native connector is configured exclusively -// through a MOONCAKE_CONFIG_PATH JSON file (it has no env-var surface for the -// master address), and the pod-mutating webhook can only inject env + args — -// it cannot write a file into a user-owned engine container. Routing the -// controller-resolved master endpoint through LMCACHE_REMOTE_URL= -// mooncakestore://… is the only path that lets status.endpoint reach the engine -// via injection alone, and it matches the locked design decision that Mooncake -// "fits the lm://-style RemoteBackend wire" (docs/design/lmcache-server-persistence.md). -// The native connector -// remains available to operators who pre-bake their own config file; this -// adapter targets the auto-wired path. -type vllmMooncakeAdapter struct { - // subscriberImage is the image the kvevent-subscriber sidecar runs. - // Empty (the default) disables sidecar auto-attach — ObservationSidecar - // returns nil — so an unconfigured controller install doesn't push - // engine pods into ImagePullBackOff on a nonexistent default image. - subscriberImage string - // policyServerGRPCAddress overrides the default in-cluster Service DNS - // the sidecar dials to ReportCacheState. Empty falls back to - // [DefaultPolicyServerGRPCAddress]. - policyServerGRPCAddress string -} - -// NewVLLMMooncakeAdapter returns the adapter that wires vLLM engine pods to a -// Mooncake CacheBackend. The optional [Option] helpers let the controller pin -// the subscriber sidecar's image + policy-server target (shared with the -// vLLM+LMCache adapter via [NewCoreRegistry]); the no-arg form reproduces the -// package defaults and keeps tests + the nil-Registry fallback paths working. -func NewVLLMMooncakeAdapter(opts ...Option) KVCacheRuntimeAdapter { - var cfg Options - for _, o := range opts { - o(&cfg) - } - return vllmMooncakeAdapter{ - subscriberImage: cfg.SubscriberImage, - policyServerGRPCAddress: cfg.PolicyServerGRPCAddress, - } -} - -// Supports matches vLLM runtimes against a Mooncake CacheBackend. Any other -// (runtime, backend) combination is left for another adapter; admission -// surfaces unsupported pairs as ErrNoAdapter. -func (vllmMooncakeAdapter) Supports(runtime RuntimeID, cache *cachev1alpha1.CacheBackend) bool { - if cache == nil { - return false - } - return runtime == RuntimeVLLM && cache.Spec.Type == cachev1alpha1.CacheBackendTypeMooncake -} - -// SupportedPairs lets the registry expose this adapter's canonical pair to -// admission error messages so a user who asked for an unsupported pair can -// see what they could have asked for instead. -func (vllmMooncakeAdapter) SupportedPairs() []SupportedPair { - return []SupportedPair{{Runtime: RuntimeVLLM, Backend: cachev1alpha1.CacheBackendTypeMooncake}} -} - -// ReservedArgs returns the leading flag tokens this adapter injects and that -// the integration cannot function without. Mooncake speaks the LMCache -// connector, so the reserved arg is the same as the vLLM+LMCache adapter's: -// -// - "--kv-transfer-config" is the LMCache connector configuration the engine -// reads at startup; suppressing it means no Mooncake wiring at all. -func (vllmMooncakeAdapter) ReservedArgs() []string { - return []string{defaultEngineKVTransferConfigArg} -} - -// ReservedEnv returns the env var names this adapter injects and that the -// integration cannot function without. Identical to the vLLM+LMCache adapter's -// set because Mooncake reuses the LMCache connector wire: -// -// - LMCACHE_REMOTE_URL is the mooncakestore:// address of the rendered -// Mooncake master; an override re-points the engine at a different store -// than the CR resolved to. -// - VLLM_USE_V1 selects the vLLM v1 codepath the LMCache connector targets. -// - INFERENCECACHE_FAIL_OPEN mirrors spec.integration.failOpen onto the pod; -// allowing an override would silently desync the pod from the CR contract. -// - PYTHONHASHSEED pins the deterministic NONE_HASH that seeds vLLM's -// prefix-cache block-hash chain across the scheduler + TP worker processes; -// an override re-randomizes it under TP>1 and reload silently 0-hits. -// -// Tunables (LMCACHE_CHUNK_SIZE / LMCACHE_REMOTE_SERDE / LMCACHE_LOCAL_CPU / -// LMCACHE_MAX_LOCAL_CPU_SIZE) are perf/mode knobs the operator may legitimately -// want to change and are deliberately NOT reserved. -func (vllmMooncakeAdapter) ReservedEnv() []string { - return []string{ - EnvLMCacheRemoteURL, - EnvVLLMUseV1, - EnvInferenceCacheFailOpen, - EnvPythonHashSeed, - } -} - -// EngineContainerName returns [EngineContainerName] — the canonical name the -// vLLM engine container carries on a pod the adapter mutates. The pod webhook -// resolves the override target via this method so admission overrides land on -// the same container [vllmMooncakeAdapter.InjectEngineConfig] modified. -func (vllmMooncakeAdapter) EngineContainerName() string { return EngineContainerName } - -// ResolveCacheServer is the pre-separation compatibility renderer. Production -// provider lifecycle resolves through pkg/adapters/backend/provider. -func (vllmMooncakeAdapter) ResolveCacheServer(cache *cachev1alpha1.CacheBackend) (*corev1.PodSpec, *corev1.Service, error) { - return provideradapter.ResolveMooncakeServer(cache) -} - -// InjectEngineConfig adds the LMCache connector arg and LMCACHE_* env to the -// vLLM container in pod, delegating to the shared engine-wire helper with the -// mooncakestore:// remote-URL scheme. The merge contract (preserve existing -// args/env, idempotent, sidecars untouched) is identical to the vLLM+LMCache -// path — see [enginewire.InjectVLLMMooncake]. -// -// spec.integration.role maps onto LMCache's kv_role exactly as for the LMCache -// adapter: ReadOnly → kv_consumer, WriteOnly → kv_producer, ReadWrite (and -// unset / unknown) → kv_both. -// -// When spec.integration.engineHostNetwork is set, the engine pod is also moved -// onto the host network. Mooncake's transfer engine is a peer-to-peer mesh: the -// master returns a directory pointer and the engine then dials a real node IP on -// a dynamically negotiated port, which a CNI overlay pod IP cannot reach. That -// move is gated on the operator's explicit opt-in rather than applied by default: -// hostNetwork is a privilege, and because mutating webhooks run BEFORE Pod -// Security validation, injecting it unasked would turn a working engine pod into -// one a "restricted" namespace rejects — with an error naming Pod Security rather -// than this controller. Until the operator opts in, admission warns that the -// backend will report Ready while transferring nothing. -func (vllmMooncakeAdapter) InjectEngineConfig(pod *corev1.PodSpec, endpoint string, cache *cachev1alpha1.CacheBackend) error { - if err := enginewire.InjectVLLMMooncake(pod, endpoint, cache); err != nil { - return err - } - injectMooncakeEngineHostNetwork(pod, cache) - return nil -} - -func injectMooncakeEngineHostNetwork(pod *corev1.PodSpec, cache *cachev1alpha1.CacheBackend) { - if EngineHostNetworkRequested(cache) { - pod.HostNetwork = true - // A hostNetwork pod otherwise inherits the node's resolver; keep cluster DNS - // so the master's Service name still resolves from the engine. - pod.DNSPolicy = corev1.DNSClusterFirstWithHostNet - } -} - -// EngineHostNetworkRequested reports whether the operator opted engine pods bound -// to this backend into host networking. Nil-safe: spec.integration is optional. -// Exported so admission can enforce that the opt-in only appears on a backend -// whose data plane actually needs it, rather than sitting inert. -func EngineHostNetworkRequested(cache *cachev1alpha1.CacheBackend) bool { - return cache != nil && cache.Spec.Integration != nil && cache.Spec.Integration.EngineHostNetwork -} - -// InjectRouterConfig is a no-op for Mooncake: the topology has no router -// component the controller needs to wire. Returning nil keeps the interface -// contract satisfied so a Registry caller can blindly invoke both Inject* paths -// per-pod without branching on backend type — per -// [KVCacheRuntimeAdapter.InjectRouterConfig]: "backends without a router -// component should return nil without touching pod." -func (vllmMooncakeAdapter) InjectRouterConfig(pod *corev1.PodSpec, endpoint string, cache *cachev1alpha1.CacheBackend) error { - _ = pod - _ = endpoint - _ = cache - return nil -} - -// ObservationSidecar returns the kvevent-subscriber container the Pod webhook -// appends to a vLLM engine pod. The subscriber observes vLLM's own ZMQ -// KV-event stream, which is independent of the L2 store, so the container is -// byte-identical to the vLLM+LMCache adapter's — both delegate to the shared -// [RenderSubscriberSidecar] with the vLLM engine dialect (--hash-scheme=vllm, -// the vLLM ZMQ PUB port). See that helper for the full contract (opt-in image -// gate, required model id, downward-API identity, --ignore-block-removed -// rationale for L2 tiers). -func (a vllmMooncakeAdapter) ObservationSidecar(cache *cachev1alpha1.CacheBackend, pod *corev1.Pod) (*corev1.Container, error) { - return RenderSubscriberSidecar(SubscriberSidecarParams{ - Image: a.subscriberImage, - ServerAddr: a.policyServerGRPCAddress, - Cache: cache, - Pod: pod, - HashScheme: subscriberHashScheme, - EngineZMQPortStr: defaultEngineZMQPortStr, - }) -} - -// Compile-time assertion: the adapter implements the full C5 interface. -var _ KVCacheRuntimeAdapter = vllmMooncakeAdapter{} diff --git a/pkg/adapters/runtime/vllm_mooncake_test.go b/pkg/adapters/runtime/vllm_mooncake_test.go deleted file mode 100644 index e01c1367..00000000 --- a/pkg/adapters/runtime/vllm_mooncake_test.go +++ /dev/null @@ -1,677 +0,0 @@ -package runtime - -import ( - "flag" - "io" - "strings" - "testing" - - corev1 "k8s.io/api/core/v1" - "k8s.io/apimachinery/pkg/api/resource" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - - cachev1alpha1 "github.com/cachebox-project/inference-cache/api/v1alpha1" -) - -// wantMooncakeRemoteURL is the LMCACHE_REMOTE_URL the Mooncake adapter must -// inject for a bare host:port endpoint — the mooncakestore:// analog of lm://. -const wantMooncakeRemoteURL = "mooncakestore://cache.ns1.svc.cluster.local:50051" - -func newMooncakeBackend(cfg map[string]string) *cachev1alpha1.CacheBackend { - cb := newCacheBackend(cachev1alpha1.CacheBackendTypeMooncake, "vllm") - cb.Spec.BackendConfig = cfg - return cb -} - -func TestVLLMMooncakeSupports(t *testing.T) { - a := NewVLLMMooncakeAdapter() - cases := []struct { - name string - runtime RuntimeID - cache *cachev1alpha1.CacheBackend - want bool - }{ - {"vllm+mooncake", RuntimeVLLM, newMooncakeBackend(nil), true}, - {"vllm+lmcache", RuntimeVLLM, newLMCacheBackend(nil), false}, - {"vllm+external", RuntimeVLLM, newCacheBackend(cachev1alpha1.CacheBackendTypeExternal, "vllm"), false}, - {"sglang+mooncake", RuntimeSGLang, newMooncakeBackend(nil), false}, - {"nil cache", RuntimeVLLM, nil, false}, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - if got := a.Supports(tc.runtime, tc.cache); got != tc.want { - t.Fatalf("Supports(%s, %v) = %v, want %v", tc.runtime, tc.cache, got, tc.want) - } - }) - } -} - -func TestVLLMMooncakeSupportedPairs(t *testing.T) { - a := NewVLLMMooncakeAdapter().(PairLister) - pairs := a.SupportedPairs() - if len(pairs) != 1 { - t.Fatalf("SupportedPairs len = %d, want 1: %v", len(pairs), pairs) - } - want := SupportedPair{Runtime: RuntimeVLLM, Backend: cachev1alpha1.CacheBackendTypeMooncake} - if pairs[0] != want { - t.Fatalf("SupportedPairs[0] = %v, want %v", pairs[0], want) - } -} - -func TestVLLMMooncakeResolveCacheServer(t *testing.T) { - a := NewVLLMMooncakeAdapter() - cb := newMooncakeBackend(nil) - - pod, svc, err := ResolveLegacyCacheServer(a, cb) - if err != nil { - t.Fatalf("ResolveCacheServer: %v", err) - } - if pod == nil || svc == nil { - t.Fatalf("ResolveCacheServer returned (pod=%v, svc=%v); want both non-nil", pod, svc) - } - if len(pod.Containers) != 1 { - t.Fatalf("pod containers = %d, want 1", len(pod.Containers)) - } - c := pod.Containers[0] - if c.Name != "mooncake-master" { - t.Fatalf("container name = %q, want mooncake-master", c.Name) - } - if c.Image != "docker.io/kvcacheai/mooncake:0.3.11.post1" { - t.Fatalf("image = %q, want pinned Mooncake default", c.Image) - } - if len(c.Command) != 1 || c.Command[0] != "mooncake_master" { - t.Fatalf("command = %v, want [mooncake_master]", c.Command) - } - for _, want := range []string{ - "--rpc_port=50051", - "--metrics_port=9003", - "--enable_http_metadata_server=true", - "--http_metadata_server_host=0.0.0.0", - "--http_metadata_server_port=8080", - } { - if !containsArg(c.Args, want) { - t.Fatalf("master args missing %q; args = %v", want, c.Args) - } - } - - // The RPC port MUST be the FIRST container port AND the FIRST Service port: - // the reconciler's serviceEndpoint helper formats status.endpoint from the - // Service's first port, and the engine must dial the master's RPC port - // (mooncakestore://), not the metadata port. - if len(c.Ports) == 0 || c.Ports[0].Name != "mooncake-rpc" || c.Ports[0].ContainerPort != 50051 { - t.Fatalf("first container port = %+v, want mooncake-rpc/50051", c.Ports) - } - if !hasContainerPort(c.Ports, "mooncake-meta", 8080) { - t.Fatalf("metadata container port missing; ports = %+v", c.Ports) - } - if !hasContainerPort(c.Ports, "metrics", 9003) { - t.Fatalf("metrics container port missing; ports = %+v", c.Ports) - } - - if c.ReadinessProbe == nil || c.ReadinessProbe.TCPSocket == nil || - c.ReadinessProbe.TCPSocket.Port.StrVal != "mooncake-rpc" { - t.Fatalf("readiness probe = %+v, want TCPSocket on mooncake-rpc", c.ReadinessProbe) - } - - if svc.Spec.Type != corev1.ServiceTypeClusterIP { - t.Fatalf("service type = %q, want ClusterIP", svc.Spec.Type) - } - if len(svc.Spec.Ports) == 0 || svc.Spec.Ports[0].Name != "mooncake-rpc" || - svc.Spec.Ports[0].Port != 50051 { - t.Fatalf("first service port = %+v, want mooncake-rpc/50051 (serviceEndpoint uses Ports[0])", - svc.Spec.Ports) - } - if svc.Spec.Ports[0].TargetPort.StrVal != "mooncake-rpc" { - t.Fatalf("first service targetPort = %v, want mooncake-rpc", svc.Spec.Ports[0].TargetPort) - } -} - -// TestVLLMMooncakeResolveCacheServerHostNetworkAndHeadless pins the two properties -// Mooncake's peer-to-peer transfer engine depends on. Without BOTH, the backend -// reconciles Ready and transfers zero KV — validated on a real cluster, where the -// master's key count never left 0. -// -// - hostNetwork: the master on :50051 only returns a directory pointer; the -// engine then dials a real node IP on a dynamically negotiated port. CNI -// overlay pod IPs are not reachable for that mesh. -// - headless Service: a virtual ClusterIP forwards only the ports declared on -// it, stranding those dynamic ports. clusterIP=None makes the Service DNS name -// (which serviceEndpoint publishes into status.endpoint) resolve straight to -// the master's node IP with every port reachable. -func TestVLLMMooncakeResolveCacheServerHostNetworkAndHeadless(t *testing.T) { - a := NewVLLMMooncakeAdapter() - pod, svc, err := ResolveLegacyCacheServer(a, newMooncakeBackend(nil)) - if err != nil { - t.Fatalf("ResolveCacheServer: %v", err) - } - if !pod.HostNetwork { - t.Fatal("pod.HostNetwork = false; mooncake's transfer engine cannot run on overlay pod IPs") - } - if pod.DNSPolicy != corev1.DNSClusterFirstWithHostNet { - t.Fatalf("pod.DNSPolicy = %q, want %q (a hostNetwork pod must keep cluster DNS)", - pod.DNSPolicy, corev1.DNSClusterFirstWithHostNet) - } - if svc.Spec.ClusterIP != corev1.ClusterIPNone { - t.Fatalf("svc.Spec.ClusterIP = %q, want %q (headless)", svc.Spec.ClusterIP, corev1.ClusterIPNone) - } - // Headless is still Type=ClusterIP; the type must not have drifted. - if svc.Spec.Type != corev1.ServiceTypeClusterIP { - t.Fatalf("svc.Spec.Type = %q, want %q", svc.Spec.Type, corev1.ServiceTypeClusterIP) - } -} - -func hasContainerPort(ports []corev1.ContainerPort, name string, port int32) bool { - for _, p := range ports { - if p.Name == name && p.ContainerPort == port { - return true - } - } - return false -} - -func TestVLLMMooncakeResolveCacheServerImageOverride(t *testing.T) { - a := NewVLLMMooncakeAdapter() - cb := newMooncakeBackend(map[string]string{"serverImage": "registry.example.com/mooncake@sha256:abc"}) - pod := resolvePod(t, a, cb) - if got := pod.Containers[0].Image; got != "registry.example.com/mooncake@sha256:abc" { - t.Fatalf("image override ignored: got %q", got) - } -} - -// TestVLLMMooncakeDefaultImageFullyQualified guards against a regression to a -// bare short name in the default Mooncake image. A CRI-O node without short-name -// resolution configured rejects short names ("short-name … did not resolve to an -// alias"), so the default MUST carry an explicit registry host (e.g. -// docker.io/...). containerd resolves short names by default, but a -// fully-qualified reference is safe on both. -func TestVLLMMooncakeDefaultImageFullyQualified(t *testing.T) { - pod := resolvePod(t, NewVLLMMooncakeAdapter(), newMooncakeBackend(nil)) - defaultImage := pod.Containers[0].Image - registry, rest, ok := strings.Cut(defaultImage, "/") - if !ok { - t.Fatalf("default image %q has no registry host (no %q separator)", defaultImage, "/") - } - // A reference is fully qualified when the segment before the first slash is a - // registry host: it contains a '.' or ':' (host[:port]) or is "localhost". - if !strings.ContainsAny(registry, ".:") && registry != "localhost" { - t.Fatalf("default image %q is a short name (registry segment %q is not a host, path %q); "+ - "CRI-O without short-name resolution configured rejects it — fully-qualify it (e.g. docker.io/...)", - defaultImage, registry, rest) - } -} - -func TestVLLMMooncakeResolveCacheServerCommandOverride(t *testing.T) { - a := NewVLLMMooncakeAdapter() - // Use a non-port flag for the override: the docs/godoc warn operators NOT - // to change the pinned RPC/metadata ports via serverCommand (the Service + - // status.endpoint are fixed to them), so the test must not normalize that - // footgun. A verbosity flag is a harmless, representative override. - cb := newMooncakeBackend(map[string]string{"serverCommand": "mooncake_master --v=1"}) - pod := resolvePod(t, a, cb) - c := pod.Containers[0] - if len(c.Command) != 1 || c.Command[0] != "mooncake_master" { - t.Fatalf("command = %v, want [mooncake_master]", c.Command) - } - if len(c.Args) != 1 || c.Args[0] != "--v=1" { - t.Fatalf("args = %v, want [--v=1]", c.Args) - } -} - -func TestVLLMMooncakeResolveCacheServerNilCache(t *testing.T) { - a := NewVLLMMooncakeAdapter() - if _, _, err := ResolveLegacyCacheServer(a, nil); err == nil { - t.Fatalf("expected error for nil cache") - } -} - -func TestVLLMMooncakeResolveCacheServerHonorsSpecResources(t *testing.T) { - a := NewVLLMMooncakeAdapter() - cb := newMooncakeBackend(nil) - cb.Spec.Resources = &corev1.ResourceRequirements{ - Requests: corev1.ResourceList{corev1.ResourceMemory: resource.MustParse("16Gi")}, - Limits: corev1.ResourceList{corev1.ResourceMemory: resource.MustParse("32Gi")}, - } - pod := resolvePod(t, a, cb) - got := pod.Containers[0].Resources - if got.Requests.Memory().String() != "16Gi" || got.Limits.Memory().String() != "32Gi" { - t.Fatalf("resources = %+v, want requests=16Gi limits=32Gi", got) - } -} - -// TestVLLMMooncakeInjectEngineConfigHostNetworkIsOptIn pins the opt-in contract for -// the one mutation that changes a customer pod's security posture. -// -// Mooncake's transfer engine is a peer-to-peer mesh, so an engine on an overlay pod -// IP cannot reach the master and the backend moves zero KV. But hostNetwork is a -// privilege, and mutating webhooks run BEFORE Pod Security validation: injecting it -// unasked would turn a working engine pod into one a "restricted" namespace rejects, -// with an error naming Pod Security rather than this controller. So the engine only -// moves when the operator asks, via spec.integration.engineHostNetwork. -func TestVLLMMooncakeInjectEngineConfigHostNetworkIsOptIn(t *testing.T) { - a := NewVLLMMooncakeAdapter() - enginePod := func() *corev1.PodSpec { - return &corev1.PodSpec{Containers: []corev1.Container{{Name: EngineContainerName}}} - } - - t.Run("NotInjectedByDefault", func(t *testing.T) { - pod := enginePod() - if err := a.InjectEngineConfig(pod, "cache.ns.svc.cluster.local:50051", newMooncakeBackend(nil)); err != nil { - t.Fatalf("InjectEngineConfig: %v", err) - } - if pod.HostNetwork { - t.Fatal("engine pod moved onto hostNetwork with no opt-in; a restricted namespace would then reject it") - } - if pod.DNSPolicy != "" { - t.Fatalf("dnsPolicy = %q, want unset when hostNetwork was not requested", pod.DNSPolicy) - } - }) - - t.Run("InjectedWhenOperatorOptsIn", func(t *testing.T) { - cb := newMooncakeBackend(nil) - if cb.Spec.Integration == nil { - cb.Spec.Integration = &cachev1alpha1.CacheBackendIntegrationSpec{} - } - cb.Spec.Integration.EngineHostNetwork = true - - pod := enginePod() - if err := a.InjectEngineConfig(pod, "cache.ns.svc.cluster.local:50051", cb); err != nil { - t.Fatalf("InjectEngineConfig: %v", err) - } - if !pod.HostNetwork { - t.Fatal("engine pod not moved onto hostNetwork despite the opt-in; it cannot reach the mesh from an overlay IP") - } - if pod.DNSPolicy != corev1.DNSClusterFirstWithHostNet { - t.Fatalf("dnsPolicy = %q, want %q (the master's Service name must still resolve)", - pod.DNSPolicy, corev1.DNSClusterFirstWithHostNet) - } - }) -} - -// TestVLLMLMCacheInjectEngineConfigNeverTouchesHostNetwork bounds the blast radius: -// the opt-in field is Mooncake-only (admission rejects it elsewhere), and the -// LMCache wire must never move a customer's engine onto the host network. -func TestVLLMLMCacheInjectEngineConfigNeverTouchesHostNetwork(t *testing.T) { - a := NewVLLMLMCacheAdapter() - cb := newLMCacheBackend(nil) - if cb.Spec.Integration == nil { - cb.Spec.Integration = &cachev1alpha1.CacheBackendIntegrationSpec{} - } - cb.Spec.Integration.EngineHostNetwork = true // rejected at admission; belt-and-braces here - - pod := &corev1.PodSpec{Containers: []corev1.Container{{Name: EngineContainerName}}} - if err := a.InjectEngineConfig(pod, "cache.ns.svc.cluster.local:65432", cb); err != nil { - t.Fatalf("InjectEngineConfig: %v", err) - } - if pod.HostNetwork { - t.Fatal("the LMCache adapter moved an engine pod onto hostNetwork; only Mooncake's mesh needs that") - } -} - -func TestVLLMMooncakeInjectEngineConfig(t *testing.T) { - a := NewVLLMMooncakeAdapter() - cb := newMooncakeBackend(nil) - pod := &corev1.PodSpec{ - Containers: []corev1.Container{ - { - Name: EngineContainerName, - Args: []string{"--enable-prefix-caching", "--max-model-len", "8192"}, - Env: []corev1.EnvVar{{Name: "HF_TOKEN", Value: "secret-token"}}, - }, - { - Name: "sidecar", - Env: []corev1.EnvVar{{Name: "SIDECAR_VAR", Value: "untouched"}}, - }, - }, - } - - if err := a.InjectEngineConfig(pod, "cache.ns1.svc.cluster.local:50051", cb); err != nil { - t.Fatalf("InjectEngineConfig: %v", err) - } - - engine := pod.Containers[0] - // The defining difference from LMCache: the remote URL carries the - // mooncakestore:// scheme, pointed at the master's RPC endpoint. - if url, ok := lookupEnv(engine.Env, EnvLMCacheRemoteURL); !ok || url != wantMooncakeRemoteURL { - t.Fatalf("%s = (%q, %v), want %q", EnvLMCacheRemoteURL, url, ok, wantMooncakeRemoteURL) - } - // The connector + invariants are the shared LMCache wire. - if v, _ := lookupEnv(engine.Env, EnvLMCacheRemoteSerde); v != "naive" { - t.Fatalf("%s = %q, want naive", EnvLMCacheRemoteSerde, v) - } - if v, _ := lookupEnv(engine.Env, EnvLMCacheChunkSize); v != "256" { - t.Fatalf("%s = %q, want 256", EnvLMCacheChunkSize, v) - } - if v, _ := lookupEnv(engine.Env, EnvVLLMUseV1); v != "1" { - t.Fatalf("%s = %q, want 1", EnvVLLMUseV1, v) - } - if v, ok := lookupEnv(engine.Env, EnvPythonHashSeed); !ok || v != "0" { - t.Fatalf("%s = (%q, %v), want 0", EnvPythonHashSeed, v, ok) - } - // Existing engine env/args are preserved (merge, not clobber). - if v, _ := lookupEnv(engine.Env, "HF_TOKEN"); v != "secret-token" { - t.Fatalf("HF_TOKEN was clobbered: got %q", v) - } - if !containsArg(engine.Args, "--enable-prefix-caching") { - t.Fatalf("--enable-prefix-caching was dropped: %v", engine.Args) - } - wantTransfer := kvTransferConfig(cachev1alpha1.CacheBackendIntegrationRoleReadWrite) - if !containsArgPair(engine.Args, defaultEngineKVTransferConfigArg, wantTransfer) { - t.Fatalf("connector args missing %s %s: %v", defaultEngineKVTransferConfigArg, wantTransfer, engine.Args) - } - - // The non-engine container is untouched. - sidecar := pod.Containers[1] - if _, ok := lookupEnv(sidecar.Env, EnvLMCacheRemoteURL); ok { - t.Fatalf("sidecar got cache env injected; adapter must target only the engine container") - } - if v, _ := lookupEnv(sidecar.Env, "SIDECAR_VAR"); v != "untouched" { - t.Fatalf("SIDECAR_VAR was clobbered: got %q", v) - } -} - -func TestVLLMMooncakeInjectEngineConfigIdempotent(t *testing.T) { - a := NewVLLMMooncakeAdapter() - cb := newMooncakeBackend(nil) - pod := &corev1.PodSpec{Containers: []corev1.Container{{Name: EngineContainerName}}} - - if err := a.InjectEngineConfig(pod, "first.svc:50051", cb); err != nil { - t.Fatalf("first InjectEngineConfig: %v", err) - } - if err := a.InjectEngineConfig(pod, "second.svc:50051", cb); err != nil { - t.Fatalf("second InjectEngineConfig: %v", err) - } - engine := pod.Containers[0] - - // Exactly one remote-URL env, holding the latest endpoint. - count := 0 - for _, e := range engine.Env { - if e.Name == EnvLMCacheRemoteURL { - count++ - } - } - if count != 1 { - t.Fatalf("%s appears %d times, want 1 (idempotent upsert)", EnvLMCacheRemoteURL, count) - } - if url, _ := lookupEnv(engine.Env, EnvLMCacheRemoteURL); url != "mooncakestore://second.svc:50051" { - t.Fatalf("%s = %q, want second endpoint", EnvLMCacheRemoteURL, url) - } - // Exactly one --kv-transfer-config flag. - flagCount := 0 - for _, arg := range engine.Args { - if arg == defaultEngineKVTransferConfigArg { - flagCount++ - } - } - if flagCount != 1 { - t.Fatalf("%s appears %d times, want 1", defaultEngineKVTransferConfigArg, flagCount) - } -} - -func TestVLLMMooncakeInjectEngineConfigMultiContainerWithoutVLLMNameErrors(t *testing.T) { - a := NewVLLMMooncakeAdapter() - cb := newMooncakeBackend(nil) - pod := &corev1.PodSpec{Containers: []corev1.Container{ - {Name: "engine", Env: []corev1.EnvVar{{Name: "EXISTING", Value: "x"}}}, - {Name: "sidecar", Env: []corev1.EnvVar{{Name: "SIDECAR_VAR", Value: "untouched"}}}, - }} - - if err := a.InjectEngineConfig(pod, "cache.ns1.svc.cluster.local:50051", cb); err == nil { - t.Fatalf("expected an error for multi-container pod without a vllm-named container") - } - // No partial mutation footprint. - for _, c := range pod.Containers { - if _, ok := lookupEnv(c.Env, EnvLMCacheRemoteURL); ok { - t.Fatalf("container %q got %s injected before the error", c.Name, EnvLMCacheRemoteURL) - } - } -} - -func TestVLLMMooncakeInjectEngineConfigPassesThroughMooncakeScheme(t *testing.T) { - // An endpoint already carrying the scheme is not doubled. - a := NewVLLMMooncakeAdapter() - cb := newMooncakeBackend(nil) - pod := &corev1.PodSpec{Containers: []corev1.Container{{Name: EngineContainerName}}} - - if err := a.InjectEngineConfig(pod, "mooncakestore://cache.ns1.svc.cluster.local:50051", cb); err != nil { - t.Fatalf("InjectEngineConfig: %v", err) - } - if url, _ := lookupEnv(pod.Containers[0].Env, EnvLMCacheRemoteURL); url != wantMooncakeRemoteURL { - t.Fatalf("%s = %q, want %q (scheme must not be doubled)", EnvLMCacheRemoteURL, url, wantMooncakeRemoteURL) - } -} - -func TestVLLMMooncakeInjectEngineConfigRoleMapping(t *testing.T) { - cases := []struct { - role cachev1alpha1.CacheBackendIntegrationRole - want string - }{ - {cachev1alpha1.CacheBackendIntegrationRoleReadOnly, kvTransferConfig(cachev1alpha1.CacheBackendIntegrationRoleReadOnly)}, - {cachev1alpha1.CacheBackendIntegrationRoleWriteOnly, kvTransferConfig(cachev1alpha1.CacheBackendIntegrationRoleWriteOnly)}, - {cachev1alpha1.CacheBackendIntegrationRoleReadWrite, kvTransferConfig(cachev1alpha1.CacheBackendIntegrationRoleReadWrite)}, - } - for _, tc := range cases { - t.Run(string(tc.role), func(t *testing.T) { - a := NewVLLMMooncakeAdapter() - cb := newMooncakeBackend(nil) - cb.Spec.Integration.Role = tc.role - pod := &corev1.PodSpec{Containers: []corev1.Container{{Name: EngineContainerName}}} - if err := a.InjectEngineConfig(pod, "cache.ns1.svc:50051", cb); err != nil { - t.Fatalf("InjectEngineConfig: %v", err) - } - if !containsArgPair(pod.Containers[0].Args, defaultEngineKVTransferConfigArg, tc.want) { - t.Fatalf("role %s: connector arg = %v, want pair value %q", tc.role, pod.Containers[0].Args, tc.want) - } - }) - } -} - -func TestVLLMMooncakeInjectEngineConfigBadInput(t *testing.T) { - a := NewVLLMMooncakeAdapter() - cb := newMooncakeBackend(nil) - good := &corev1.PodSpec{Containers: []corev1.Container{{Name: EngineContainerName}}} - cases := []struct { - name string - pod *corev1.PodSpec - endpoint string - cache *cachev1alpha1.CacheBackend - }{ - {"nil pod", nil, "x:50051", cb}, - {"nil cache", good, "x:50051", nil}, - {"empty endpoint", good, "", cb}, - {"no containers", &corev1.PodSpec{}, "x:50051", cb}, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - if err := a.InjectEngineConfig(tc.pod, tc.endpoint, tc.cache); err == nil { - t.Fatalf("expected error for %s", tc.name) - } - }) - } -} - -func TestVLLMMooncakeInjectRouterConfigIsNoop(t *testing.T) { - a := NewVLLMMooncakeAdapter() - cb := newMooncakeBackend(nil) - pod := &corev1.PodSpec{Containers: []corev1.Container{{Name: EngineContainerName, Env: []corev1.EnvVar{{Name: "KEEP", Value: "1"}}}}} - if err := a.InjectRouterConfig(pod, "cache.ns1.svc:50051", cb); err != nil { - t.Fatalf("InjectRouterConfig: %v", err) - } - // No router component: the pod is untouched. - if len(pod.Containers[0].Env) != 1 || pod.Containers[0].Env[0].Name != "KEEP" { - t.Fatalf("InjectRouterConfig mutated the pod: %+v", pod.Containers[0].Env) - } -} - -func TestVLLMMooncakeReservedArgs(t *testing.T) { - a := NewVLLMMooncakeAdapter() - got := a.ReservedArgs() - if len(got) != 1 || got[0] != defaultEngineKVTransferConfigArg { - t.Fatalf("ReservedArgs = %v, want [%s]", got, defaultEngineKVTransferConfigArg) - } -} - -func TestVLLMMooncakeReservedEnv(t *testing.T) { - a := NewVLLMMooncakeAdapter() - want := map[string]bool{ - EnvLMCacheRemoteURL: true, - EnvVLLMUseV1: true, - EnvInferenceCacheFailOpen: true, - EnvPythonHashSeed: true, - } - got := a.ReservedEnv() - if len(got) != len(want) { - t.Fatalf("ReservedEnv = %v, want %d entries", got, len(want)) - } - for _, name := range got { - if !want[name] { - t.Fatalf("ReservedEnv has unexpected entry %q; want %v", name, want) - } - } - // Tunables must NOT be reserved. - for _, name := range got { - if name == EnvLMCacheChunkSize || name == EnvLMCacheRemoteSerde { - t.Fatalf("ReservedEnv must not reserve tunable %q", name) - } - } -} - -func TestVLLMMooncakeEngineContainerName(t *testing.T) { - if got := NewVLLMMooncakeAdapter().EngineContainerName(); got != EngineContainerName { - t.Fatalf("EngineContainerName = %q, want %q", got, EngineContainerName) - } -} - -func TestVLLMMooncakeObservationSidecarShape(t *testing.T) { - a := NewVLLMMooncakeAdapter(WithSubscriberImage(DefaultSubscriberImage)) - cb := newMooncakeBackend(map[string]string{"model": "Qwen/Qwen2.5-0.5B-Instruct"}) - pod := &corev1.Pod{ObjectMeta: metav1.ObjectMeta{Name: "engine-a", Namespace: "engines"}} - - c, err := a.ObservationSidecar(cb, pod) - if err != nil { - t.Fatalf("ObservationSidecar: %v", err) - } - if c == nil { - t.Fatalf("ObservationSidecar returned nil for vLLM+Mooncake with a model + image set") - } - if c.Name != SubscriberContainerName { - t.Fatalf("container name = %q, want %q", c.Name, SubscriberContainerName) - } - if c.Image != DefaultSubscriberImage { - t.Fatalf("container image = %q, want %q", c.Image, DefaultSubscriberImage) - } - if !envHasFieldRef(c.Env, "POD_NAME", "metadata.name") { - t.Fatalf("env missing POD_NAME via downward API: %v", c.Env) - } - for _, want := range []string{ - "--engine-endpoint=tcp://127.0.0.1:5557", - "--server=" + DefaultPolicyServerGRPCAddress, - "--replica-id=$(POD_NAME)", - "--tenant-id=$(POD_NAMESPACE)", - "--model-id=Qwen/Qwen2.5-0.5B-Instruct", - // vLLM emits the same block-hash scheme regardless of the L2 store, so - // the subscriber tags events "vllm" for Mooncake exactly as for LMCache. - "--hash-scheme=vllm", - // Mooncake is an L2 remote store like LMCache, so block-removed events - // must be ignored to avoid dropping a still-resident routing hint. - "--ignore-block-removed=true", - } { - if !containsArg(c.Args, want) { - t.Fatalf("subscriber args missing %q; args = %v", want, c.Args) - } - } - if c.SecurityContext == nil || c.SecurityContext.RunAsNonRoot == nil || !*c.SecurityContext.RunAsNonRoot { - t.Fatalf("SecurityContext must run non-root; got %+v", c.SecurityContext) - } -} - -func TestVLLMMooncakeObservationSidecarSkipsWithoutModel(t *testing.T) { - a := NewVLLMMooncakeAdapter(WithSubscriberImage(DefaultSubscriberImage)) - cb := newMooncakeBackend(nil) - pod := &corev1.Pod{ObjectMeta: metav1.ObjectMeta{Name: "engine-a"}} - c, err := a.ObservationSidecar(cb, pod) - if err != nil { - t.Fatalf("ObservationSidecar: %v", err) - } - if c != nil { - t.Fatalf("expected nil sidecar when backendConfig.model is unset, got %+v", c) - } -} - -func TestVLLMMooncakeObservationSidecarSkipsWithoutImage(t *testing.T) { - a := NewVLLMMooncakeAdapter() // no image configured - cb := newMooncakeBackend(map[string]string{"model": "MyOrg/MyModel"}) - pod := &corev1.Pod{ObjectMeta: metav1.ObjectMeta{Name: "engine-a"}} - c, err := a.ObservationSidecar(cb, pod) - if err != nil { - t.Fatalf("ObservationSidecar: %v", err) - } - if c != nil { - t.Fatalf("expected nil sidecar when subscriber image is unconfigured, got %+v", c) - } -} - -func TestVLLMMooncakeObservationSidecarBadInput(t *testing.T) { - a := NewVLLMMooncakeAdapter(WithSubscriberImage(DefaultSubscriberImage)) - cb := newMooncakeBackend(map[string]string{"model": "m"}) - if _, err := a.ObservationSidecar(nil, &corev1.Pod{}); err == nil { - t.Fatalf("expected error for nil cache") - } - if _, err := a.ObservationSidecar(cb, nil); err == nil { - t.Fatalf("expected error for nil pod") - } -} - -func TestVLLMMooncakeObservationSidecarArgsParseAgainstSubscriberFlagSet(t *testing.T) { - // Same guard as the LMCache adapter: a rendered arg the kvevent-subscriber - // binary doesn't recognise crashes the sidecar at startup (Go's flag - // package exits on unknown flags). Parse through a FlagSet mirroring the - // binary's event-path flag surface. - a := NewVLLMMooncakeAdapter(WithSubscriberImage(DefaultSubscriberImage)) - cb := newMooncakeBackend(map[string]string{"model": "Qwen/Qwen2.5-0.5B-Instruct"}) - pod := &corev1.Pod{ObjectMeta: metav1.ObjectMeta{Name: "engine-a", Namespace: "engines"}} - c, err := a.ObservationSidecar(cb, pod) - if err != nil || c == nil { - t.Fatalf("ObservationSidecar: (%v, %v)", c, err) - } - - fs := flag.NewFlagSet("kvevent-subscriber", flag.ContinueOnError) - fs.SetOutput(io.Discard) - fs.String("engine-endpoint", "", "") - fs.String("topic", "", "") - fs.String("server", "", "") - fs.String("replica-id", "", "") - fs.String("model-id", "", "") - fs.String("tenant-id", "", "") - fs.String("hash-scheme", "", "") - fs.Duration("window", 0, "") - fs.Bool("ignore-block-removed", false, "") - if err := fs.Parse(c.Args); err != nil { - t.Fatalf("rendered sidecar args rejected by subscriber FlagSet: %v\nargs = %v", err, c.Args) - } -} - -func TestNewCoreRegistryResolvesVLLMMooncake(t *testing.T) { - r := NewCoreRegistry() - // Mooncake resolves to the Mooncake adapter. - a, err := r.Select(RuntimeVLLM, newMooncakeBackend(nil)) - if err != nil { - t.Fatalf("NewCoreRegistry().Select(vllm, Mooncake): %v", err) - } - if !a.Supports(RuntimeVLLM, newMooncakeBackend(nil)) { - t.Fatalf("resolved adapter does not Support (vllm, Mooncake)") - } - // Registering Mooncake must not have displaced LMCache. - if _, err := r.Select(RuntimeVLLM, newLMCacheBackend(nil)); err != nil { - t.Fatalf("NewCoreRegistry().Select(vllm, LMCache) regressed: %v", err) - } - // Mooncake must surface in the supported-pairs list (admission messages). - found := false - for _, p := range r.SupportedPairs() { - if p.Runtime == RuntimeVLLM && p.Backend == cachev1alpha1.CacheBackendTypeMooncake { - found = true - } - } - if !found { - t.Fatalf("NewCoreRegistry().SupportedPairs() missing vllm/Mooncake: %v", r.SupportedPairs()) - } -} diff --git a/pkg/adapters/runtime/wire_contract.go b/pkg/adapters/runtime/wire_contract.go new file mode 100644 index 00000000..eacea57b --- /dev/null +++ b/pkg/adapters/runtime/wire_contract.go @@ -0,0 +1,122 @@ +package runtime + +import ( + "fmt" + "strconv" + "strings" + "unicode" + + cachev1alpha1 "github.com/cachebox-project/inference-cache/api/v1alpha1" +) + +// Engine-side wire names are public so admission, controllers, tests, and +// out-of-tree adapters can share the exact protocol spellings without +// importing a built-in implementation. +const ( + EnvLMCacheRemoteURL = "LMCACHE_REMOTE_URL" + EnvLMCacheRemoteSerde = "LMCACHE_REMOTE_SERDE" + EnvLMCacheChunkSize = "LMCACHE_CHUNK_SIZE" + EnvLMCacheLocalCPU = "LMCACHE_LOCAL_CPU" + EnvLMCacheMaxLocalCPU = "LMCACHE_MAX_LOCAL_CPU_SIZE" + EnvVLLMUseV1 = "VLLM_USE_V1" + EnvInferenceCacheFailOpen = "INFERENCECACHE_FAIL_OPEN" + EnvPythonHashSeed = "PYTHONHASHSEED" + EngineContainerName = "vllm" +) + +// EngineHostNetworkRequested reports whether the operator opted an engine pod +// using a Mooncake remote binding into host networking. +func EngineHostNetworkRequested(cache *cachev1alpha1.CacheBackend) bool { + return cache != nil && cache.Spec.Integration != nil && cache.Spec.Integration.EngineHostNetwork +} + +// ValidateLMCacheEndpoint validates a bare host:port or lm://host:port. The +// port must be a decimal integer in the TCP range 1-65535. +func ValidateLMCacheEndpoint(value string) error { + raw := strings.TrimSpace(value) + if raw == "" { + return fmt.Errorf("endpoint is empty") + } + if strings.ContainsFunc(raw, func(r rune) bool { return unicode.IsSpace(r) || unicode.IsControl(r) }) { + return fmt.Errorf("endpoint must not contain whitespace or control characters within the host or port; use host:port or lm://host:port with no embedded spaces") + } + rest := raw + if i := strings.Index(raw, "://"); i >= 0 { + scheme := strings.ToLower(raw[:i]) + rest = raw[i+3:] + if scheme != "lm" { + return fmt.Errorf("endpoint scheme %q is not supported; use a bare host:port (the LMCache adapter adds the lm:// scheme) or an explicit lm://host:port URL", scheme) + } + } + if strings.ContainsAny(rest, "/?#") { + return fmt.Errorf("endpoint must be host:port (optionally prefixed lm://); paths/queries/fragments are not part of the LMCache wire and would be silently dropped") + } + host, port, ok := splitLMCacheHostPort(rest) + if !ok || host == "" || port == "" { + return fmt.Errorf("endpoint must be a non-empty host AND port (e.g. cache.example.com:8200 or lm://cache.example.com:8200); a scheme alone, a host with no port, an empty port, or a port with no host is not a valid LMCache endpoint") + } + if strings.IndexFunc(port, func(r rune) bool { return r < '0' || r > '9' }) >= 0 { + return fmt.Errorf("endpoint port %q must be an integer in 1-65535", port) + } + n, err := strconv.ParseUint(port, 10, 16) + if err != nil || n == 0 { + return fmt.Errorf("endpoint port %q must be an integer in 1-65535", port) + } + return nil +} + +func splitLMCacheHostPort(value string) (host, port string, hasPort bool) { + if value == "" { + return "", "", false + } + if strings.HasPrefix(value, "[") { + end := strings.Index(value, "]") + if end <= 1 { + return "", "", false + } + host = value[1:end] + tail := value[end+1:] + if tail == "" { + return host, "", false + } + if !strings.HasPrefix(tail, ":") || strings.Contains(tail[1:], ":") { + return "", "", false + } + return host, tail[1:], true + } + if strings.Count(value, ":") > 1 { + return "", "", false + } + if i := strings.LastIndex(value, ":"); i >= 0 { + return value[:i], value[i+1:], true + } + return value, "", false +} + +// ValidateExternalEndpoint validates an endpoint for the selected remote +// storage provider's engine-side protocol. +func ValidateExternalEndpoint(provider cachev1alpha1.CacheBackendRemoteStorageProvider, endpoint string) error { + trimmed := strings.TrimSpace(endpoint) + switch provider { + case cachev1alpha1.CacheBackendRemoteStorageProviderLMCacheServer: + return ValidateLMCacheEndpoint(trimmed) + case cachev1alpha1.CacheBackendRemoteStorageProviderRedis: + if scheme, _, ok := strings.Cut(trimmed, "://"); ok { + return fmt.Errorf("scheme %q is not supported for remoteStorage.provider=%s; use bare host:port", scheme, provider) + } + return ValidateLMCacheEndpoint(trimmed) + case cachev1alpha1.CacheBackendRemoteStorageProviderMooncake: + if scheme, address, ok := strings.Cut(trimmed, "://"); ok { + if !strings.EqualFold(scheme, "mooncakestore") { + return fmt.Errorf("scheme %q is not supported for remoteStorage.provider=%s; use bare host:port or mooncakestore://host:port", scheme, provider) + } + if strings.Contains(address, "://") { + return fmt.Errorf("nested endpoint schemes are not supported for remoteStorage.provider=%s; use mooncakestore://host:port", provider) + } + trimmed = address + } + return ValidateLMCacheEndpoint(trimmed) + default: + return fmt.Errorf("remote-storage provider %q has no endpoint protocol", provider) + } +} diff --git a/pkg/cli/doctor/checks/cachebackend.go b/pkg/cli/doctor/checks/cachebackend.go index 9abde0db..f5127fa3 100644 --- a/pkg/cli/doctor/checks/cachebackend.go +++ b/pkg/cli/doctor/checks/cachebackend.go @@ -21,8 +21,7 @@ const checkCacheBackendHealth = "CacheBackendHealth" // axis emits its own finding so the operator sees exactly what is wrong; a // backend that passes every applicable axis emits a single OK. // -// Three health models coexist, derived from EffectiveRemoteStorage so canonical -// and legacy resources are classified identically: +// Three health models coexist, derived from EffectiveRemoteStorage: // - Managed remote storage: every axis applies. // - External remote storage: only Ready + endpoint reachability apply. // - Host-only caching: engine/index axes apply, but endpoint checks do not. diff --git a/pkg/cli/doctor/checks/checks_test.go b/pkg/cli/doctor/checks/checks_test.go index f472d50a..1977db8d 100644 --- a/pkg/cli/doctor/checks/checks_test.go +++ b/pkg/cli/doctor/checks/checks_test.go @@ -91,6 +91,13 @@ func healthyBackend(now time.Time) *cachev1alpha1.CacheBackend { return &cachev1alpha1.CacheBackend{ ObjectMeta: metav1.ObjectMeta{Name: "good", Namespace: "ns1"}, Spec: cachev1alpha1.CacheBackendSpec{ + Runtime: cachev1alpha1.CacheBackendRuntimeVLLM, + Type: cachev1alpha1.CacheBackendTypeLMCache, + RemoteStorage: &cachev1alpha1.CacheBackendRemoteStorageSpec{ + Provider: cachev1alpha1.CacheBackendRemoteStorageProviderLMCacheServer, + Ownership: cachev1alpha1.CacheBackendRemoteStorageOwnershipManaged, + LMCacheServer: &cachev1alpha1.LMCacheServerRemoteStorageSpec{}, + }, EngineSelector: &cachev1alpha1.CacheBackendEngineSelector{MatchLabels: map[string]string{"app": "engine"}}, }, Status: cachev1alpha1.CacheBackendStatus{ @@ -390,6 +397,13 @@ func TestCacheBackendHealth(t *testing.T) { cb := &cachev1alpha1.CacheBackend{ ObjectMeta: metav1.ObjectMeta{Name: "bad", Namespace: "ns1"}, Spec: cachev1alpha1.CacheBackendSpec{ + Runtime: cachev1alpha1.CacheBackendRuntimeVLLM, + Type: cachev1alpha1.CacheBackendTypeLMCache, + RemoteStorage: &cachev1alpha1.CacheBackendRemoteStorageSpec{ + Provider: cachev1alpha1.CacheBackendRemoteStorageProviderLMCacheServer, + Ownership: cachev1alpha1.CacheBackendRemoteStorageOwnershipManaged, + LMCacheServer: &cachev1alpha1.LMCacheServerRemoteStorageSpec{}, + }, EngineSelector: &cachev1alpha1.CacheBackendEngineSelector{MatchLabels: map[string]string{"app": "missing"}}, }, } @@ -510,7 +524,15 @@ func TestCacheBackendHealthMessageBranches(t *testing.T) { // External backends skip the managed axes entirely, including the probe. cb := &cachev1alpha1.CacheBackend{ ObjectMeta: metav1.ObjectMeta{Name: "ext", Namespace: "ns1"}, - Spec: cachev1alpha1.CacheBackendSpec{Type: cachev1alpha1.CacheBackendTypeExternal, Endpoint: "h:1"}, + Spec: cachev1alpha1.CacheBackendSpec{ + Runtime: cachev1alpha1.CacheBackendRuntimeVLLM, + Type: cachev1alpha1.CacheBackendTypeLMCache, + RemoteStorage: &cachev1alpha1.CacheBackendRemoteStorageSpec{ + Provider: cachev1alpha1.CacheBackendRemoteStorageProviderLMCacheServer, + Ownership: cachev1alpha1.CacheBackendRemoteStorageOwnershipExternal, + Endpoint: "h:1", + }, + }, Status: cachev1alpha1.CacheBackendStatus{ Endpoint: "h:1", Conditions: []metav1.Condition{readyCond(metav1.ConditionTrue, "EndpointAccepted", "ok")}, @@ -588,8 +610,13 @@ func TestCacheBackendHealthMessageBranches(t *testing.T) { cb := &cachev1alpha1.CacheBackend{ ObjectMeta: metav1.ObjectMeta{Name: "ext", Namespace: "ns1"}, Spec: cachev1alpha1.CacheBackendSpec{ - Type: cachev1alpha1.CacheBackendTypeExternal, - Endpoint: "cache.example.com:8200", + Runtime: cachev1alpha1.CacheBackendRuntimeVLLM, + Type: cachev1alpha1.CacheBackendTypeLMCache, + RemoteStorage: &cachev1alpha1.CacheBackendRemoteStorageSpec{ + Provider: cachev1alpha1.CacheBackendRemoteStorageProviderLMCacheServer, + Ownership: cachev1alpha1.CacheBackendRemoteStorageOwnershipExternal, + Endpoint: "cache.example.com:8200", + }, }, Status: cachev1alpha1.CacheBackendStatus{ Endpoint: "cache.example.com:8200", @@ -633,6 +660,7 @@ func TestCacheBackendHealthMessageBranches(t *testing.T) { cb := healthyBackend(now) cb.Name = "host-only" cb.Spec.Runtime = cachev1alpha1.CacheBackendRuntimeVLLM + cb.Spec.RemoteStorage = nil cb.Status.Endpoint = "" fs := CacheBackendHealth(ctx, fakeClient(t, cb), "", now, DefaultStaleWindow, okDial) if len(fs) != 1 || fs[0].Code != doctor.CodeBackendHealthy { diff --git a/site/content/en/docs/concepts/architecture.md b/site/content/en/docs/concepts/architecture.md index d6e49e3f..56b1969b 100644 --- a/site/content/en/docs/concepts/architecture.md +++ b/site/content/en/docs/concepts/architecture.md @@ -28,8 +28,9 @@ The controller-runtime manager. It: does both engine-config injection and sidecar injection. - **Runs the bridge** to the server (see below). -Owns the `pkg/adapters/runtime` adapters that render the cache-server pod/Service and the -engine-side pod configuration. +Composes the shipping adapters under `internal/adapters/builtin`: storage providers +render managed backend workloads, while runtime adapters inject engine-side pod +configuration. The supported build-time extension contracts remain under `pkg/adapters`. ### `inferencecache-server` diff --git a/site/content/en/docs/concepts/cachebackend.md b/site/content/en/docs/concepts/cachebackend.md index 11b8062f..3ce53592 100644 --- a/site/content/en/docs/concepts/cachebackend.md +++ b/site/content/en/docs/concepts/cachebackend.md @@ -17,7 +17,7 @@ backend and the engine-integration policy that uses it. Applying one: 2. **Binds** to inference-engine pods by label (`spec.engineSelector`). The mutating Pod webhook injects the KV-connector configuration into matching pods. It also injects the observation sidecar when the controller has `--kvevent-subscriber-image` configured and - `backendConfig.model` is set. + `spec.observation.modelID` is set. 3. **Makes the engine's KV cache reusable** — offloaded to the backend (tier 2) and, when subscriber reporting is enabled, surfaced to routing (tier 1) so a warm prefix skips prefill. @@ -32,41 +32,46 @@ metadata: name: llama3-cache namespace: serving spec: + runtime: VLLM type: LMCache integration: - engine: vllm # runtime ID, not the adapter name mode: Offload role: ReadWrite engineSelector: matchLabels: app: llama3-vllm - backendConfig: - model: meta-llama/Llama-3.1-8B-Instruct - resources: - requests: - memory: 4Gi - limits: - memory: 8Gi + observation: + modelID: meta-llama/Llama-3.1-8B-Instruct + remoteStorage: + provider: LMCacheServer + ownership: Managed + lmCacheServer: + resources: + requests: + memory: 4Gi + limits: + memory: 8Gi ``` ## Backend types (`spec.type`) -`spec.type` selects the backing implementation. The default is `LMCache`. +`spec.type` is the engine-side cache implementation. It is a CRD enum and +defaults to `LMCache`. | Type | What it is | |---|---| -| **`LMCache`** (default) | An in-memory `lm://` LMCache server, provisioned by the controller as a Deployment + ClusterIP Service. The simple, node-agnostic default. Not durable. | -| **`Mooncake`** | A durable, shared, peer-to-peer transfer-engine store. Requires host networking (see below). | -| **`External`** | You provide `spec.endpoint`; the controller skips all provisioning and only wires the engine side. | -| `SGLangHiCache`, `AIBrix`, `NIXL` | Reserved for future adapters. | +| **`LMCache`** (default) | LMCache engine integration. `spec.remoteStorage` independently selects an optional remote provider. | +| **`SGLangHiCache`** | SGLang's native engine-local host cache; it accepts no remote-storage binding. | -The runtime + backend **pair** selects the adapter — `(vllm, LMCache)`, `(vllm, Mooncake)`, -`(vllm, External)`, `(sglang, LMCache)`. Admission rejects unsupported pairs. +The runtime + cache-type **pair** selects the engine adapter. Remote provider +technology (`Redis`, `LMCacheServer`, or `Mooncake`) and lifecycle ownership +(`Managed` or `External`) are selected under `spec.remoteStorage`. Admission +rejects unsupported combinations. {{% alert title="LMCache durability" color="info" %}} The `lm://` LMCache server is **in-memory only.** Durability is a *backend choice*, not a generic volume knob — there is no per-`CacheBackend` PVC field. If you need a durable or -shared store, use `type: Mooncake`. +shared store, use `remoteStorage.provider: Mooncake`. {{% /alert %}} ### Mooncake needs host networking @@ -86,11 +91,9 @@ affects bandwidth. | Field | Values | Meaning | |---|---|---| -| `engine` | `vllm` (default), `sglang` | The **runtime ID** — not the adapter package name. Writing the adapter name (e.g. `vllm-lmcache`) is rejected. | | `mode` | `Offload` (default), `EventsOnly` | `Offload` = routing + tier-2 offload + a provisioned server. `EventsOnly` = routing only, no server, no KV connector. | | `role` | `ReadOnly`, `WriteOnly`, `ReadWrite` (default) | Maps to the LMCache `kv_role` (`kv_consumer` / `kv_producer` / `kv_both`). | | `failOpen` | `true` (default) | The engine falls back to local prefill when the cache is unreachable. `false` fails closed (and emits a Warning Event). | -| `firstEventTimeout` | `5m` (default) | How long readiness waits for the first KV event before reporting degraded. | | `engineOverrides` | — | Fine-grained control over injected args/env (see below). | | `engineHostNetwork` | `false` (default) | Opt-in host networking for Mooncake engine pods. | diff --git a/site/content/en/docs/developer-guide/_index.md b/site/content/en/docs/developer-guide/_index.md index 4a8aaf87..80f4a0ed 100644 --- a/site/content/en/docs/developer-guide/_index.md +++ b/site/content/en/docs/developer-guide/_index.md @@ -30,7 +30,8 @@ The repository is one operator split across two binaries plus the CRDs. In short | gRPC handlers, server wiring | `pkg/server/` | | Cache-state index logic | `pkg/index/` | | Mutable-slot rendering | `pkg/render/` | -| Engine / runtime adapters | `pkg/adapters/{engine,runtime}/` | +| Built-in runtime / storage adapters | `internal/adapters/builtin/{runtime,storage}/` | +| Public adapter extension contracts | `pkg/adapters/{runtime,backend}/` | | The gRPC contract | `proto/` → `make proto-gen` | Generated code (`config/crd/`, `config/rbac/role.yaml`, `zz_generated*.go`, diff --git a/site/content/en/docs/reference/crd-api.md b/site/content/en/docs/reference/crd-api.md index f51b3436..83c6e23d 100644 --- a/site/content/en/docs/reference/crd-api.md +++ b/site/content/en/docs/reference/crd-api.md @@ -17,11 +17,10 @@ All CRDs are in the API group **`inferencecache.io`**, version **`v1alpha1`**. | `PromptTemplate` | Namespaced | `pt` | Declarative | Cache-aware prompt template + stable/mutable slots. | | `PDTopology` | Namespaced | `pdt` | Declarative | Prefill/decode topology for disaggregated serving. | -{{% alert title="v1alpha1 compatibility" color="info" %}} -Although the API is still evolving, existing `v1alpha1` objects must remain valid. Schema -changes are additive or otherwise backward-compatible; removals and incompatible validation -changes require a new version and migration path. The gRPC/proto contract follows the same -backward-compatibility rule for its external consumers. +{{% alert title="v1alpha1 stability" color="info" %}} +`v1alpha1` is not compatibility-frozen before the first production deployment. The CRD may +make breaking schema corrections while the API shape is being finalized. The gRPC/proto +contract follows its own compatibility policy for external consumers. {{% /alert %}} ## CacheBackend @@ -30,22 +29,22 @@ backward-compatibility rule for its external consumers. | Field | Type / values | Default | Notes | |---|---|---|---| -| `type` | `LMCache`, `Mooncake`, `External`, … | `LMCache` | Backing implementation. | +| `runtime` | `VLLM`, `SGLang` | — | Inference runtime identity. | +| `type` | `LMCache`, `SGLangHiCache` | `LMCache` | Engine-side cache implementation. | +| `lmCache` | object | — | Engine-side LMCache configuration. | +| `remoteStorage` | object | — | Optional provider (`Redis`, `LMCacheServer`, `Mooncake`), ownership (`Managed`, `External`), and external endpoint. | +| `observation` | object | — | Model identity and first-event timeout. | | `deploymentKind` | `Deployment`, `StatefulSet` | `Deployment` | `StatefulSet` reserved/no-op. | | `replicas` | int32 | `1` | Min 0. | | `autoscaling` | object | — | `minReplicas`, `maxReplicas` (required), `targetCPUUtilizationPercent` (default 80). | -| `integration.engine` | `vllm`, `sglang` | `vllm` | Runtime ID, not the adapter name. | | `integration.mode` | `Offload`, `EventsOnly` | `Offload` | Events-only = routing only. | | `integration.role` | `ReadOnly`, `WriteOnly`, `ReadWrite` | `ReadWrite` | Maps to LMCache `kv_role`. | | `integration.failOpen` | bool | `true` | `false` fails closed. | -| `integration.firstEventTimeout` | duration | `5m` | KV-event readiness window. | | `integration.engineOverrides` | object | — | `args` / `suppressArgs` / `env` / `suppressEnv`. | | `integration.engineHostNetwork` | bool | `false` | Opt-in for Mooncake engine pods. | | `engineSelector.matchLabels` | map | — | Equality selector over engine pod labels. | -| `backendConfig` | map[string]string | — | e.g. `model`, plus backend tunables. | | `template` | object | — | Narrow pod-level overrides (no containers). | -| `resources` | ResourceRequirements | `requests.memory 4Gi` / `limits.memory 8Gi` | For the managed cache-server container. | -| `endpoint` | string | — | Required for `External`, rejected otherwise. | +| `remoteStorage..resources` | ResourceRequirements | renderer default: `requests.memory 4Gi` / `limits.memory 8Gi` | Resources for the selected managed provider container. | | `allowCrossNamespace` | bool | `false` | Opt-in cross-namespace endpoints. | **Key `status` fields:** `endpoint`, `matchedEnginePods` (`*int32`), diff --git a/site/content/en/docs/tasks/bind-an-engine.md b/site/content/en/docs/tasks/bind-an-engine.md index f49b2e02..448f26cf 100644 --- a/site/content/en/docs/tasks/bind-an-engine.md +++ b/site/content/en/docs/tasks/bind-an-engine.md @@ -27,7 +27,7 @@ wiring into them. Three actors participate: 3. **The webhook claims matching pods** — but only once `status.endpoint` is populated. It injects the LMCache env, the `--kv-transfer-config` arg, and stamps `inferencecache.io/injected-by: /`. When the controller runs with - `--kvevent-subscriber-image` set **and** the backend has `backendConfig.model`, it also + `--kvevent-subscriber-image` set **and** the backend has `spec.observation.modelID`, it also appends the subscriber sidecar. 4. **KV events flow** (when the sidecar is present) into the server's index and surface in `CacheBackend.status`. @@ -48,15 +48,15 @@ kind: CacheBackend metadata: name: qwen-demo-cache # CR name — must differ from the engine Deployment name spec: + runtime: VLLM type: LMCache integration: - engine: vllm role: ReadWrite engineSelector: matchLabels: app: qwen-demo # selector key/value (1 of 2) - backendConfig: - model: Qwen/Qwen2.5-0.5B-Instruct + observation: + modelID: Qwen/Qwen2.5-0.5B-Instruct --- apiVersion: apps/v1 kind: Deployment @@ -105,14 +105,8 @@ Always injected (reserved — not overridable): - `PYTHONHASHSEED=0` — a correctness invariant (pins the engine's hash seed so LMCache reloads match under tensor parallelism) -Tunable via `backendConfig` (not overrides): - -| `backendConfig` key | Env var | Default | -|---|---|---| -| `chunkSize` | `LMCACHE_CHUNK_SIZE` | 256 | -| `remoteSerde` | `LMCACHE_REMOTE_SERDE` | naive | -| `localCPU` | `LMCACHE_LOCAL_CPU` | False | -| `maxLocalCPU` | `LMCACHE_MAX_LOCAL_CPU_SIZE` | 20 GiB | +Typed LMCache tunables live under `spec.lmCache`: `chunkSizeTokens`, +`remoteSerde`, and `hostMemory.capacity`. ### SGLang + LMCache diff --git a/site/content/en/docs/tasks/deploy-a-cache-backend.md b/site/content/en/docs/tasks/deploy-a-cache-backend.md index 53598545..b4f3d815 100644 --- a/site/content/en/docs/tasks/deploy-a-cache-backend.md +++ b/site/content/en/docs/tasks/deploy-a-cache-backend.md @@ -22,18 +22,17 @@ kind: CacheBackend metadata: name: my-cache spec: + runtime: VLLM type: LMCache # backing cache implementation - integration: - engine: vllm # optional — defaults to vllm engineSelector: matchLabels: app: my-engine # must match your engine pods' labels - backendConfig: - model: Qwen/Qwen2.5-0.5B-Instruct + observation: + modelID: Qwen/Qwen2.5-0.5B-Instruct ``` Everything else is defaulted: `spec.replicas` becomes `1`, the readiness gate's -`firstEventTimeout` becomes `5m`, and `integration.failOpen` is treated as `true`. +`observation.firstEventTimeout` becomes `5m`, and `integration.failOpen` is treated as `true`. {{% alert title="One label does the binding" color="warning" %}} The value under `engineSelector.matchLabels` must also appear on your engine pods' template