Files

625 lines
25 KiB
Go

/*
Copyright 2025 The KServe Authors.
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 llmisvc
import (
"context"
"fmt"
"strconv"
kedav1alpha1 "github.com/kedacore/keda/v2/apis/keda/v1alpha1"
wvav1alpha1 "github.com/llm-d/llm-d-workload-variant-autoscaler/api/v1alpha1"
autoscalingv2 "k8s.io/api/autoscaling/v2"
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/api/equality"
apierrors "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/api/resource"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/utils/ptr"
"knative.dev/pkg/kmeta"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/log"
lwsapi "sigs.k8s.io/lws/api/leaderworkerset/v1"
"github.com/kserve/kserve/pkg/apis/serving/v1alpha2"
"github.com/kserve/kserve/pkg/constants"
"github.com/kserve/kserve/pkg/utils"
)
const (
wvaDesiredReplicasMetricName = "wva_desired_replicas"
variantNameLabelKey = "variant_name"
acceleratorNameLabelKey = "inference.optimization/acceleratorName"
)
// reconcileScaling manages the autoscaling resources (VariantAutoscaling + HPA or KEDA ScaledObject)
// for the LLM workload. When scaling is configured, it creates a VariantAutoscaling CR for WVA to
// compute desired replicas and an actuator (HPA or KEDA ScaledObject) to enforce them.
// When scaling is removed (or the workload is stopped), it cleans up any existing autoscaling resources.
//
// A missing scaling CRD (NoMatchError) is treated as a hard failure: the LLMInferenceService is
// misconfigured and reconciliation of remaining resources is blocked until the CRD is installed.
func (r *LLMISVCReconciler) reconcileScaling(ctx context.Context, llmSvc *v1alpha2.LLMInferenceService, config *Config) error {
logger := log.FromContext(ctx).WithName("reconcileScaling")
ctx = log.IntoContext(ctx, logger)
if err := r.reconcileWorkloadScaling(ctx, llmSvc, config, mainWorkloadScalingParams(llmSvc)); err != nil {
return fmt.Errorf("failed to reconcile main workload scaling: %w", err)
}
if err := r.reconcileWorkloadScaling(ctx, llmSvc, config, prefillWorkloadScalingParams(llmSvc)); err != nil {
return fmt.Errorf("failed to reconcile prefill workload scaling: %w", err)
}
return nil
}
// workloadScalingParams captures the per-workload differences between main and prefill
// scaling so that reconcileWorkloadScaling can handle both without duplication.
type workloadScalingParams struct {
name string
scaling *v1alpha2.ScalingSpec
scaleTargetRef autoscalingv2.CrossVersionObjectReference
vaName string
hpaName string
scaledObjectName string
workloadLabels map[string]string
markReady func()
markNotReady func(reason, messageFormat string, messageA ...interface{})
markUnset func()
}
func mainWorkloadScalingParams(llmSvc *v1alpha2.LLMInferenceService) workloadScalingParams {
return workloadScalingParams{
name: "main",
scaling: llmSvc.Spec.Scaling,
scaleTargetRef: mainScaleTargetRef(llmSvc),
vaName: mainVAName(llmSvc),
hpaName: mainHPAName(llmSvc),
scaledObjectName: mainScaledObjectName(llmSvc),
workloadLabels: llmSvc.Spec.Labels,
markReady: llmSvc.MarkScalingReady,
markNotReady: llmSvc.MarkScalingNotReady,
markUnset: llmSvc.MarkScalingUnset,
}
}
func prefillWorkloadScalingParams(llmSvc *v1alpha2.LLMInferenceService) workloadScalingParams {
var scaling *v1alpha2.ScalingSpec
var labels map[string]string
if llmSvc.Spec.Prefill != nil {
scaling = llmSvc.Spec.Prefill.Scaling
labels = llmSvc.Spec.Prefill.Labels
}
return workloadScalingParams{
name: "prefill",
scaling: scaling,
scaleTargetRef: prefillScaleTargetRef(llmSvc),
vaName: prefillVAName(llmSvc),
hpaName: prefillHPAName(llmSvc),
scaledObjectName: prefillScaledObjectName(llmSvc),
workloadLabels: labels,
markReady: llmSvc.MarkPrefillScalingReady,
markNotReady: llmSvc.MarkPrefillScalingNotReady,
markUnset: llmSvc.MarkPrefillScalingUnset,
}
}
// reconcileWorkloadScaling reconciles all scaling resources (VA, actuator) and propagates
// status for a single workload (main or prefill). The workloadScalingParams struct captures
// all per-workload differences so this method does not need to know which workload it is handling.
func (r *LLMISVCReconciler) reconcileWorkloadScaling(ctx context.Context, llmSvc *v1alpha2.LLMInferenceService, config *Config, p workloadScalingParams) error {
if err := r.reconcileVA(ctx, llmSvc, p.scaling, p.scaleTargetRef, p.vaName, p.workloadLabels); err != nil {
return fmt.Errorf("failed to reconcile %s VA: %w", p.name, err)
}
if err := r.reconcileActuator(ctx, llmSvc, p.scaling, config, p.scaleTargetRef, p.vaName, p.hpaName, p.scaledObjectName); err != nil {
return fmt.Errorf("failed to reconcile %s actuator: %w", p.name, err)
}
return r.propagateScalingStatus(ctx, llmSvc, p.scaling, p.hpaName, p.scaledObjectName, p.markReady, p.markNotReady, p.markUnset)
}
// reconcileHPA creates or updates an HPA for the workload, or deletes it when not needed.
// The HPA reads wva_desired_replicas via the Kubernetes external metrics API, which requires
// a Prometheus Adapter to be pre-installed in the cluster. The controller cannot validate
// whether the Prometheus Adapter is present or correctly configured — if it is missing,
// the HPA will silently enter an Unknown state and stop scaling.
func (r *LLMISVCReconciler) reconcileHPA(ctx context.Context, llmSvc *v1alpha2.LLMInferenceService, scaling *v1alpha2.ScalingSpec, isStopped bool, scaleTargetRef autoscalingv2.CrossVersionObjectReference, vaName, hpaName string) error {
if scaling == nil || scaling.WVA == nil || isStopped || scaling.WVA.HPA == nil {
return r.deleteHPAIfExists(ctx, llmSvc, hpaName)
}
expected := expectedHPA(llmSvc, scaling, scaleTargetRef, vaName, hpaName)
return Reconcile(ctx, r, llmSvc, &autoscalingv2.HorizontalPodAutoscaler{}, expected, semanticHPAIsEqual)
}
// deleteHPAIfExists deletes the HPA if it exists.
func (r *LLMISVCReconciler) deleteHPAIfExists(ctx context.Context, llmSvc *v1alpha2.LLMInferenceService, hpaName string) error {
hpa := &autoscalingv2.HorizontalPodAutoscaler{
ObjectMeta: metav1.ObjectMeta{
Name: hpaName,
Namespace: llmSvc.GetNamespace(),
},
}
return Delete(ctx, r, llmSvc, hpa)
}
// expectedHPA constructs the desired HPA resource from the LLMISVC scaling spec.
// vaName is used as the metric selector label because WVA emits wva_desired_replicas keyed by VA name.
//
// The HPA uses an external metric (wva_desired_replicas) with target=1 so that it acts as a
// direct actuator for WVA's decisions rather than an independent scaling algorithm.
// WVA computes and publishes the desired replica count; HPA reads it and enforces it on the workload.
func expectedHPA(llmSvc *v1alpha2.LLMInferenceService, scaling *v1alpha2.ScalingSpec, scaleTargetRef autoscalingv2.CrossVersionObjectReference, vaName, hpaName string) *autoscalingv2.HorizontalPodAutoscaler {
labels := scalingLabels(llmSvc)
minReplicas := ptr.To(ptr.Deref(scaling.MinReplicas, 1))
hpa := &autoscalingv2.HorizontalPodAutoscaler{
ObjectMeta: metav1.ObjectMeta{
Name: hpaName,
Namespace: llmSvc.GetNamespace(),
Labels: labels,
OwnerReferences: []metav1.OwnerReference{
*metav1.NewControllerRef(llmSvc, v1alpha2.LLMInferenceServiceGVK),
},
},
Spec: autoscalingv2.HorizontalPodAutoscalerSpec{
ScaleTargetRef: scaleTargetRef,
MinReplicas: minReplicas,
MaxReplicas: scaling.MaxReplicas,
Metrics: []autoscalingv2.MetricSpec{
{
Type: autoscalingv2.ExternalMetricSourceType,
External: &autoscalingv2.ExternalMetricSource{
Metric: autoscalingv2.MetricIdentifier{
Name: wvaDesiredReplicasMetricName,
Selector: &metav1.LabelSelector{
MatchLabels: map[string]string{
variantNameLabelKey: vaName,
},
},
},
// Target is set to 1 with ValueMetricType so HPA computes:
// desired_replicas = ceil(wva_desired_replicas / 1) = wva_desired_replicas
// This makes HPA a pass-through: it blindly follows the absolute replica count
// emitted by WVA, rather than doing its own scaling arithmetic.
// WVA is the sole source of scaling decisions; HPA is purely the enforcement layer.
Target: autoscalingv2.MetricTarget{
Type: autoscalingv2.ValueMetricType,
Value: resource.NewQuantity(1, resource.DecimalSI),
},
},
},
},
},
}
if scaling.WVA != nil && scaling.WVA.HPA != nil && scaling.WVA.HPA.Behavior != nil {
hpa.Spec.Behavior = scaling.WVA.HPA.Behavior
}
return hpa
}
// reconcileActuator reconciles the scaling actuators (HPA, KEDA ScaledObject) for the workload.
func (r *LLMISVCReconciler) reconcileActuator(ctx context.Context, llmSvc *v1alpha2.LLMInferenceService, scaling *v1alpha2.ScalingSpec, config *Config, scaleTargetRef autoscalingv2.CrossVersionObjectReference, vaName, hpaName, scaledObjectName string) error {
isStopped := utils.GetForceStopRuntime(llmSvc)
if err := r.reconcileKEDAScaledObject(ctx, llmSvc, scaling, isStopped, config, scaleTargetRef, vaName, scaledObjectName); err != nil {
return err
}
return r.reconcileHPA(ctx, llmSvc, scaling, isStopped, scaleTargetRef, vaName, hpaName)
}
// propagateScalingStatus determines which actuator is active and propagates its status.
// When no scaling is configured (or the service is stopped), the condition is cleared.
func (r *LLMISVCReconciler) propagateScalingStatus(ctx context.Context, llmSvc *v1alpha2.LLMInferenceService, scaling *v1alpha2.ScalingSpec, hpaName, scaledObjectName string, ready func(), notReady func(reason, messageFormat string, messageA ...interface{}), unset func()) error {
isStopped := utils.GetForceStopRuntime(llmSvc)
if scaling == nil || scaling.WVA == nil || isStopped {
unset()
return nil
}
if scaling.WVA.HPA != nil {
expected := &autoscalingv2.HorizontalPodAutoscaler{
ObjectMeta: metav1.ObjectMeta{
Name: hpaName,
Namespace: llmSvc.GetNamespace(),
},
}
return r.propagateHPAStatus(ctx, expected, ready, notReady)
}
if scaling.WVA.KEDA != nil {
expected := &kedav1alpha1.ScaledObject{
ObjectMeta: metav1.ObjectMeta{
Name: scaledObjectName,
Namespace: llmSvc.GetNamespace(),
},
}
return r.propagateScaledObjectStatus(ctx, expected, ready, notReady)
}
unset()
return nil
}
// propagateHPAStatus reads the live HPA status and maps its conditions to a ScalingReady
// condition on the LLMInferenceService. AbleToScale=False or ScalingActive=False means the
// metrics pipeline is broken and sets ScalingReady=False.
func (r *LLMISVCReconciler) propagateHPAStatus(ctx context.Context, expected *autoscalingv2.HorizontalPodAutoscaler, ready func(), notReady func(reason, messageFormat string, messageA ...interface{})) error {
curr := &autoscalingv2.HorizontalPodAutoscaler{}
if err := r.Get(ctx, client.ObjectKeyFromObject(expected), curr); err != nil {
if apierrors.IsNotFound(err) {
notReady("HPAProgressing", "HPA not yet visible in cache")
return nil
}
return fmt.Errorf("failed to get current HPA %s/%s: %w", expected.GetNamespace(), expected.GetName(), err)
}
foundAny := false
for _, cond := range curr.Status.Conditions {
switch cond.Type {
case autoscalingv2.AbleToScale, autoscalingv2.ScalingActive:
foundAny = true
if cond.Status == corev1.ConditionFalse {
notReady(cond.Reason, cond.Message)
return nil
}
}
}
if !foundAny {
notReady("HPAProgressing", "HPA conditions not yet available")
return nil
}
ready()
return nil
}
// propagateScaledObjectStatus reads the live KEDA ScaledObject status and maps its conditions
// to a ScalingReady condition on the LLMInferenceService. Ready=False means a trigger/config
// issue and sets ScalingReady=False.
func (r *LLMISVCReconciler) propagateScaledObjectStatus(ctx context.Context, expected *kedav1alpha1.ScaledObject, ready func(), notReady func(reason, messageFormat string, messageA ...interface{})) error {
curr := &kedav1alpha1.ScaledObject{}
if err := r.Get(ctx, client.ObjectKeyFromObject(expected), curr); err != nil {
if apierrors.IsNotFound(err) {
notReady("ScaledObjectProgressing", "ScaledObject not yet visible in cache")
return nil
}
return fmt.Errorf("failed to get current ScaledObject %s/%s: %w", expected.GetNamespace(), expected.GetName(), err)
}
conditions := curr.Status.Conditions
if conditions == nil || !conditions.AreInitialized() {
notReady("ScaledObjectProgressing", "ScaledObject conditions not yet available")
return nil
}
readyCond := conditions.GetReadyCondition()
if readyCond.Status == metav1.ConditionFalse {
notReady(readyCond.Reason, readyCond.Message)
return nil
}
if readyCond.Status != metav1.ConditionTrue {
notReady("ScaledObjectProgressing", "ScaledObject is not yet ready")
return nil
}
ready()
return nil
}
// validateAutoscalingConfig checks that the WVAAutoscalingConfig is valid for use with KEDA.
// It returns an error if the config is nil, if prometheus.url is missing, or if the auth
// fields are only partially configured (both prometheus.authModes and prometheus.triggerAuthName
// must be set together or both left empty).
func validateAutoscalingConfig(cfg *WVAAutoscalingConfig) error {
if cfg == nil || cfg.Prometheus.URL == "" {
return fmt.Errorf("%s.prometheus.url is required in inferenceservice-config when using KEDA", autoscalingConfigName)
}
if (cfg.Prometheus.TriggerAuthName == "") != (cfg.Prometheus.AuthModes == "") {
return fmt.Errorf("%s.prometheus.authModes and %s.prometheus.triggerAuthName must both be set or both be empty", autoscalingConfigName, autoscalingConfigName)
}
return nil
}
// reconcileKEDAScaledObject creates or updates a KEDA ScaledObject, or deletes it when not needed.
func (r *LLMISVCReconciler) reconcileKEDAScaledObject(ctx context.Context, llmSvc *v1alpha2.LLMInferenceService, scaling *v1alpha2.ScalingSpec, isStopped bool, config *Config, scaleTargetRef autoscalingv2.CrossVersionObjectReference, vaName, scaledObjectName string) error {
if scaling == nil || scaling.WVA == nil || isStopped || scaling.WVA.KEDA == nil {
return r.deleteScaledObjectIfExists(ctx, llmSvc, scaledObjectName)
}
if err := validateAutoscalingConfig(config.WVAAutoscalingConfig); err != nil {
return err
}
expected := expectedScaledObject(llmSvc, scaling, config, scaleTargetRef, vaName, scaledObjectName)
return Reconcile(ctx, r, llmSvc, &kedav1alpha1.ScaledObject{}, expected, semanticScaledObjectIsEqual,
PreserveKEDAManagedMetadata(),
)
}
// deleteScaledObjectIfExists deletes the ScaledObject if it exists.
func (r *LLMISVCReconciler) deleteScaledObjectIfExists(ctx context.Context, llmSvc *v1alpha2.LLMInferenceService, scaledObjectName string) error {
so := &kedav1alpha1.ScaledObject{
ObjectMeta: metav1.ObjectMeta{
Name: scaledObjectName,
Namespace: llmSvc.GetNamespace(),
},
}
return Delete(ctx, r, llmSvc, so)
}
// expectedScaledObject constructs the desired KEDA ScaledObject from the LLMISVC scaling spec.
// The Prometheus server address and TLS settings come from the controller config (inferenceservice-config ConfigMap).
func expectedScaledObject(llmSvc *v1alpha2.LLMInferenceService, scaling *v1alpha2.ScalingSpec, config *Config, scaleTargetRef autoscalingv2.CrossVersionObjectReference, vaName, scaledObjectName string) *kedav1alpha1.ScaledObject {
labels := scalingLabels(llmSvc)
keda := scaling.WVA.KEDA
minReplicas := ptr.To(ptr.Deref(scaling.MinReplicas, 1))
// variant_name matches the VariantAutoscaling CR name, which WVA uses as a label when emitting wva_desired_replicas.
// exported_namespace is used instead of namespace because Prometheus renames the namespace label emitted by WVA
// to exported_namespace during scraping — this happens because namespace is a Prometheus-reserved label that
// Prometheus itself sets to the scrape target's namespace (the WVA controller namespace). WVA's original
// namespace label (the workload namespace) is therefore preserved under the exported_namespace name.
// The VariantAutoscaling CR always lives in the same namespace as the LLMInferenceService, so llmSvc.GetNamespace()
// is the correct value to filter on.
query := fmt.Sprintf(`wva_desired_replicas{variant_name="%s",exported_namespace="%s"}`, vaName, llmSvc.GetNamespace())
so := &kedav1alpha1.ScaledObject{
ObjectMeta: metav1.ObjectMeta{
Name: scaledObjectName,
Namespace: llmSvc.GetNamespace(),
Labels: labels,
OwnerReferences: []metav1.OwnerReference{
*metav1.NewControllerRef(llmSvc, v1alpha2.LLMInferenceServiceGVK),
},
},
Spec: kedav1alpha1.ScaledObjectSpec{
ScaleTargetRef: &kedav1alpha1.ScaleTarget{
APIVersion: scaleTargetRef.APIVersion,
Kind: scaleTargetRef.Kind,
Name: scaleTargetRef.Name,
},
MinReplicaCount: minReplicas,
MaxReplicaCount: &scaling.MaxReplicas,
PollingInterval: keda.PollingInterval,
CooldownPeriod: keda.CooldownPeriod,
IdleReplicaCount: keda.IdleReplicaCount,
Fallback: keda.Fallback,
Advanced: keda.Advanced,
InitialCooldownPeriod: keda.InitialCooldownPeriod,
Triggers: []kedav1alpha1.ScaleTriggers{
prometheusTrigger(config.WVAAutoscalingConfig, query),
},
},
}
return so
}
// prometheusTrigger builds the KEDA ScaleTriggers entry for the Prometheus scaler.
// Authentication is optional: when Prometheus.AuthModes and Prometheus.TriggerAuthName are
// set in the config, the trigger will carry authModes metadata and an AuthenticationRef
// pointing to the pre-existing TriggerAuthentication or ClusterTriggerAuthentication CR.
func prometheusTrigger(cfg *WVAAutoscalingConfig, query string) kedav1alpha1.ScaleTriggers {
prom := &cfg.Prometheus
trigger := kedav1alpha1.ScaleTriggers{
Type: "prometheus",
Name: "wva-desired-replicas",
Metadata: map[string]string{
"serverAddress": prom.URL,
"query": query,
"threshold": "1",
"unsafeSsl": strconv.FormatBool(prom.TLSInsecureSkipVerify),
},
}
if prom.AuthModes != "" {
trigger.Metadata["authModes"] = prom.AuthModes
}
if prom.TriggerAuthName != "" {
trigger.AuthenticationRef = &kedav1alpha1.AuthenticationRef{
Name: prom.TriggerAuthName,
Kind: prom.TriggerAuthKind,
}
}
return trigger
}
// reconcileVA creates, updates, or deletes a VariantAutoscaling CR based on the scaling configuration.
// The VA tells the WVA controller to compute wva_desired_replicas for this workload.
// workloadLabels are the labels from the WorkloadSpec for the specific workload (decode or prefill).
func (r *LLMISVCReconciler) reconcileVA(ctx context.Context, llmSvc *v1alpha2.LLMInferenceService, scaling *v1alpha2.ScalingSpec, scaleTargetRef autoscalingv2.CrossVersionObjectReference, vaName string, workloadLabels map[string]string) error {
isStopped := utils.GetForceStopRuntime(llmSvc)
if scaling == nil || scaling.WVA == nil || isStopped {
return r.deleteVAIfExists(ctx, llmSvc, vaName)
}
expected := expectedVA(llmSvc, scaling, scaleTargetRef, vaName, workloadLabels)
return Reconcile(ctx, r, llmSvc, &wvav1alpha1.VariantAutoscaling{}, expected, semanticVAIsEqual)
}
// deleteVAIfExists deletes the VariantAutoscaling if it exists.
func (r *LLMISVCReconciler) deleteVAIfExists(ctx context.Context, llmSvc *v1alpha2.LLMInferenceService, vaName string) error {
va := &wvav1alpha1.VariantAutoscaling{
ObjectMeta: metav1.ObjectMeta{
Name: vaName,
Namespace: llmSvc.GetNamespace(),
},
}
return Delete(ctx, r, llmSvc, va)
}
// expectedVA constructs the desired VariantAutoscaling resource from the LLMISVC spec.
// workloadLabels are the labels from the WorkloadSpec for the specific workload (decode or prefill).
// If the workload labels contain inference.optimization/acceleratorName, that value is propagated
// to the VA label. Otherwise the label is set to "unknown".
func expectedVA(llmSvc *v1alpha2.LLMInferenceService, scaling *v1alpha2.ScalingSpec, scaleTargetRef autoscalingv2.CrossVersionObjectReference, vaName string, workloadLabels map[string]string) *wvav1alpha1.VariantAutoscaling {
labels := scalingLabels(llmSvc)
accelerator := "unknown"
if val, ok := workloadLabels[acceleratorNameLabelKey]; ok && val != "" {
accelerator = val
}
labels[acceleratorNameLabelKey] = accelerator
modelID := llmSvc.Spec.Model.URI.String()
if llmSvc.Spec.Model.Name != nil {
modelID = *llmSvc.Spec.Model.Name
}
minReplicas := ptr.To(ptr.Deref(scaling.MinReplicas, 1))
va := &wvav1alpha1.VariantAutoscaling{
ObjectMeta: metav1.ObjectMeta{
Name: vaName,
Namespace: llmSvc.GetNamespace(),
Labels: labels,
OwnerReferences: []metav1.OwnerReference{
*metav1.NewControllerRef(llmSvc, v1alpha2.LLMInferenceServiceGVK),
},
},
Spec: wvav1alpha1.VariantAutoscalingSpec{
ScaleTargetRef: scaleTargetRef,
ModelID: modelID,
MinReplicas: minReplicas,
MaxReplicas: scaling.MaxReplicas,
VariantAutoscalingConfigSpec: wvav1alpha1.VariantAutoscalingConfigSpec{
VariantCost: scaling.WVA.VariantCost,
},
},
}
return va
}
func semanticVAIsEqual(expected, curr *wvav1alpha1.VariantAutoscaling) bool {
return equality.Semantic.DeepEqual(expected.Spec, curr.Spec) &&
equality.Semantic.DeepEqual(expected.Labels, curr.Labels) &&
equality.Semantic.DeepEqual(expected.Annotations, curr.Annotations)
}
func semanticHPAIsEqual(expected, curr *autoscalingv2.HorizontalPodAutoscaler) bool {
return equality.Semantic.DeepEqual(expected.Spec, curr.Spec) &&
equality.Semantic.DeepEqual(expected.Labels, curr.Labels) &&
equality.Semantic.DeepEqual(expected.Annotations, curr.Annotations)
}
func semanticScaledObjectIsEqual(expected, curr *kedav1alpha1.ScaledObject) bool {
return equality.Semantic.DeepEqual(expected.Spec, curr.Spec) &&
equality.Semantic.DeepEqual(expected.Labels, curr.Labels) &&
equality.Semantic.DeepEqual(expected.Annotations, curr.Annotations)
}
// PreserveKEDAManagedMetadata returns an AfterDryRun hook that copies KEDA-managed
// metadata from the live object into expected before the real update is issued.
//
// It preserves two things:
// 1. The KEDA-injected label (scaledobject.keda.sh/name) — so that DeepEqual does
// not trigger spurious updates when KEDA adds this label after creation.
// 2. Finalizers — because the Update call is a full PUT (not a patch), which would
// otherwise wipe any finalizers that KEDA added (e.g. finalizer.keda.sh) whenever
// a spec/label change triggers an update. Copying finalizers from curr ensures they
// are preserved in the PUT body.
func PreserveKEDAManagedMetadata() UpdateOption[*kedav1alpha1.ScaledObject] {
return AfterDryRun(func(expected, _ /* expectedGiven */, curr *kedav1alpha1.ScaledObject) {
if v, ok := curr.Labels[kedav1alpha1.ScaledObjectOwnerAnnotation]; ok {
if expected.Labels == nil {
expected.Labels = make(map[string]string)
}
expected.Labels[kedav1alpha1.ScaledObjectOwnerAnnotation] = v
}
if len(curr.Finalizers) > 0 {
expected.Finalizers = curr.Finalizers
}
})
}
func scalingLabels(llmSvc *v1alpha2.LLMInferenceService) map[string]string {
return map[string]string{
constants.KubernetesComponentLabelKey: constants.LLMComponentWorkload,
constants.KubernetesAppNameLabelKey: llmSvc.GetName(),
constants.KubernetesPartOfLabelKey: constants.LLMInferenceServicePartOfValue,
}
}
func mainScaleTargetRef(llmSvc *v1alpha2.LLMInferenceService) autoscalingv2.CrossVersionObjectReference {
if llmSvc.Spec.Worker != nil {
return autoscalingv2.CrossVersionObjectReference{
APIVersion: lwsapi.GroupVersion.String(),
Kind: "LeaderWorkerSet",
Name: mainLWSName(llmSvc),
}
}
return autoscalingv2.CrossVersionObjectReference{
APIVersion: "apps/v1",
Kind: "Deployment",
Name: mainDeploymentName(llmSvc),
}
}
func prefillScaleTargetRef(llmSvc *v1alpha2.LLMInferenceService) autoscalingv2.CrossVersionObjectReference {
if llmSvc.Spec.Prefill != nil && llmSvc.Spec.Prefill.Worker != nil {
return autoscalingv2.CrossVersionObjectReference{
APIVersion: lwsapi.GroupVersion.String(),
Kind: "LeaderWorkerSet",
Name: prefillLWSName(llmSvc),
}
}
return autoscalingv2.CrossVersionObjectReference{
APIVersion: "apps/v1",
Kind: "Deployment",
Name: prefillDeploymentName(llmSvc),
}
}
func mainHPAName(llmSvc *v1alpha2.LLMInferenceService) string {
return kmeta.ChildName(llmSvc.GetName(), "-kserve-hpa")
}
func prefillHPAName(llmSvc *v1alpha2.LLMInferenceService) string {
return kmeta.ChildName(llmSvc.GetName(), "-kserve-prefill-hpa")
}
func mainVAName(llmSvc *v1alpha2.LLMInferenceService) string {
return kmeta.ChildName(llmSvc.GetName(), "-kserve-va")
}
func prefillVAName(llmSvc *v1alpha2.LLMInferenceService) string {
return kmeta.ChildName(llmSvc.GetName(), "-kserve-prefill-va")
}
func mainScaledObjectName(llmSvc *v1alpha2.LLMInferenceService) string {
return kmeta.ChildName(llmSvc.GetName(), "-kserve-keda")
}
func prefillScaledObjectName(llmSvc *v1alpha2.LLMInferenceService) string {
return kmeta.ChildName(llmSvc.GetName(), "-kserve-prefill-keda")
}