Files
2026-07-13 12:24:33 +08:00

246 lines
9.2 KiB
Go

/*
Copyright 2026.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
// Package webhook implements the pod-mutating admission webhooks that wire
// opted-in vLLM pods to an engine (CacheBlend or LMCache). This file holds the
// machinery shared by both injectors: the annotation key set, the opt-in /
// idempotency gate, the connection-ConfigMap + target-container resolution, and
// the skip / stamp / patch responses. The two injectors differ only in which
// engine they read and which mutations they apply (cacheblend_pod_injector.go,
// lmcache_pod_injector.go).
package webhook
import (
"context"
"encoding/json"
"net/http"
"strings"
corev1 "k8s.io/api/core/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/types"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
"github.com/LMCache/LMCache/internal/resources"
)
// valueTrue is the boolean-true string stamped on the injected guard and used as
// the opt-in value of each injector's inject label.
const valueTrue = "true"
// kvTransferConfigDataKey is the key within the <engine>-connection ConfigMap's
// Data map that holds the kv-transfer-config JSON. It must match the key written
// by resources.buildConnectionConfigMapCore.
const kvTransferConfigDataKey = "kv-transfer-config.json"
// Skip-reason values shared by both injectors (stamped on the per-injector
// skip-reason annotation; design §8). CacheBlend adds SkipReasonPayloadImageUnset.
const (
// SkipReasonEngineNotFound is stamped when the named engine's connection
// ConfigMap (or, for CacheBlend, the engine CR) does not exist (fail-open).
SkipReasonEngineNotFound = "engine-not-found"
// SkipReasonCommandOverride is stamped when the target container overrides
// command, so appended args may never reach `vllm serve`.
SkipReasonCommandOverride = "command-override"
// SkipReasonKVTransferConfigPresent is stamped when the user already supplies
// --kv-transfer-config; the webhook does not clobber their structured JSON.
SkipReasonKVTransferConfigPresent = "kv-transfer-config-present"
// SkipReasonTargetContainerNotFound is stamped when the requested target
// container names a container that does not exist on the pod.
SkipReasonTargetContainerNotFound = "target-container-not-found"
)
// injectionKeys is one injector's annotation key set. It mirrors the same four
// concerns for each injector (CacheBlend / LMCache) so the shared gate, skip,
// and stamp helpers are key-set agnostic.
type injectionKeys struct {
// engine binds a pod to an engine in the same namespace. Its presence is the
// opt-in signal; its value is the engine name.
engine string
// container optionally names the target vLLM container (empty = first).
container string
// injected is the idempotency guard stamped after a successful injection.
injected string
// skipReason records why injection was skipped (fail-open).
skipReason string
}
// gate decodes the pod and applies the shared idempotency + opt-in gates. When
// handled is true the caller must return resp unchanged: either a decode error,
// an unchanged Allow for an already-injected or non-opted-in pod. Otherwise it
// returns the decoded pod, the bound engine name, and the lookup namespace
// (req.Namespace, falling back to pod.Namespace which may be empty on CREATE).
func (k injectionKeys) gate(
decoder admission.Decoder,
req admission.Request,
) (pod *corev1.Pod, engineName, namespace string, resp admission.Response, handled bool) {
pod = &corev1.Pod{}
if err := decoder.Decode(req, pod); err != nil {
return nil, "", "", admission.Errored(http.StatusBadRequest, err), true
}
if pod.Annotations[k.injected] == valueTrue {
return pod, "", "", admission.Allowed("already injected"), true
}
engineName = strings.TrimSpace(pod.Annotations[k.engine])
if engineName == "" {
return pod, "", "", admission.Allowed("not opted in"), true
}
namespace = req.Namespace
if namespace == "" {
namespace = pod.Namespace
}
return pod, engineName, namespace, admission.Response{}, false
}
// prepareInjection performs the resolution shared by both injectors: read the
// engine's <engine>-connection ConfigMap (existence gate, fail-open), resolve
// the target container (annotation override > specDefault > first), and apply
// the command-override gate. When ok is false the caller must return resp (a
// skip + stamp, or an internal error). Otherwise it returns the kv-transfer
// JSON and the target container index.
//
// Parameters:
// - specDefault: the engine's default target container name (nil = first); the
// per-pod container annotation overrides it.
func prepareInjection(
ctx context.Context,
c client.Client,
req admission.Request,
pod *corev1.Pod,
keys injectionKeys,
engineName, namespace string,
specDefault *string,
) (kvJSON string, targetIdx int, resp admission.Response, ok bool) {
log := ctrl.LoggerFrom(ctx)
connCM := &corev1.ConfigMap{}
connName := resources.ConnectionConfigMapName(engineName)
if err := c.Get(ctx, types.NamespacedName{Name: connName, Namespace: namespace}, connCM); err != nil {
if apierrors.IsNotFound(err) {
log.Info("Skipped injection: connection ConfigMap not found",
"engine", engineName, "configMap", connName, "namespace", namespace)
return "", 0, keys.skip(req, pod, SkipReasonEngineNotFound), false
}
return "", 0, admission.Errored(http.StatusInternalServerError, err), false
}
kvJSON = connCM.Data[kvTransferConfigDataKey]
idx, found := resolveTargetContainer(pod, specDefault, pod.Annotations[keys.container])
if !found {
log.Info("Skipped injection: target container not found",
"engine", engineName, "annotationContainer", pod.Annotations[keys.container])
return "", 0, keys.skip(req, pod, SkipReasonTargetContainerNotFound), false
}
if len(pod.Spec.Containers[idx].Command) > 0 {
log.Info("Skipped injection: target container overrides command",
"engine", engineName, "container", pod.Spec.Containers[idx].Name)
return "", 0, keys.skip(req, pod, SkipReasonCommandOverride), false
}
return kvJSON, idx, admission.Response{}, true
}
// stampInjected stamps the idempotency guard (and the kv-transfer-config-present
// skip reason when the user supplied their own --kv-transfer-config), then
// returns the success patch response. Callers apply their mutations first.
func (k injectionKeys) stampInjected(
req admission.Request,
pod *corev1.Pod,
userHasKVTransferConfig bool,
) admission.Response {
if pod.Annotations == nil {
pod.Annotations = map[string]string{}
}
pod.Annotations[k.injected] = valueTrue
if userHasKVTransferConfig {
pod.Annotations[k.skipReason] = SkipReasonKVTransferConfigPresent
}
return patchResponse(req, pod)
}
// skip stamps the given skip reason on the pod (without injecting) and returns
// an Allowed patch response. The pod is still admitted (fail-open).
func (k injectionKeys) skip(req admission.Request, pod *corev1.Pod, reason string) admission.Response {
if pod.Annotations == nil {
pod.Annotations = map[string]string{}
}
pod.Annotations[k.skipReason] = reason
return patchResponse(req, pod)
}
// patchResponse marshals the mutated pod and returns a JSON patch response
// against the original raw object (req.Object.Raw).
func patchResponse(req admission.Request, pod *corev1.Pod) admission.Response {
marshaled, err := json.Marshal(pod)
if err != nil {
return admission.Errored(http.StatusInternalServerError, err)
}
return admission.PatchResponseFromRaw(req.Object.Raw, marshaled)
}
// resolveTargetContainer returns the index of the container to inject into and
// whether one was found. The per-pod annotation override (annotationName) takes
// precedence over the engine's default; an empty selection falls back to the
// first container. A non-empty name that matches no container yields ok=false.
//
// Parameters:
// - pod: the decoded pod.
// - specDefault: the engine's default target container (nil/"" = first).
// - annotationName: the per-pod container-override annotation value.
func resolveTargetContainer(
pod *corev1.Pod,
specDefault *string,
annotationName string,
) (int, bool) {
if len(pod.Spec.Containers) == 0 {
return 0, false
}
name := strings.TrimSpace(annotationName)
if name == "" && specDefault != nil {
name = strings.TrimSpace(*specDefault)
}
if name == "" {
return 0, true
}
for i := range pod.Spec.Containers {
if pod.Spec.Containers[i].Name == name {
return i, true
}
}
return 0, false
}
// argsHasFlag reports whether args already carries the given flag in either the
// two-token "--flag value" form or the single-token "--flag=value" form.
func argsHasFlag(args []string, flag string) bool {
eqPrefix := flag + "="
for _, a := range args {
if a == flag {
return true
}
if strings.HasPrefix(a, eqPrefix) {
return true
}
}
return false
}