dynamo/deploy/operator/internal/webhook/validation/dynamographdeployment.go

519 lines
18 KiB
Go

/*
* SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
* SPDX-License-Identifier: Apache-2.0
*
* 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 validation
import (
"context"
"errors"
"fmt"
"sort"
"strings"
semver "github.com/Masterminds/semver/v3"
nvidiacomv1alpha1 "github.com/ai-dynamo/dynamo/deploy/operator/api/v1alpha1"
"github.com/ai-dynamo/dynamo/deploy/operator/internal/consts"
internalwebhook "github.com/ai-dynamo/dynamo/deploy/operator/internal/webhook"
authenticationv1 "k8s.io/api/authentication/v1"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
)
const (
// maxCombinedResourceNameLength is the maximum allowed combined length for Grove resource names.
// This constraint comes from Grove's PodCliqueSet webhook validation which enforces a 45-character
// limit on the combined length of PodCliqueSet name + PodCliqueScalingGroup name + PodClique name.
// Pod names follow formats like: <pcs-name>-<pcs-index>-<pcsg-name>-<pcsg-index>-<pclq-name>-<random>
// The random string and hyphens consume additional characters, leaving 45 for the resource names.
maxCombinedResourceNameLength = 45
)
// DynamoGraphDeploymentValidator validates DynamoGraphDeployment resources.
// This validator can be used by both webhooks and controllers for consistent validation.
type DynamoGraphDeploymentValidator struct {
deployment *nvidiacomv1alpha1.DynamoGraphDeployment
mgr ctrl.Manager // Optional: for API group detection via discovery client
}
// NewDynamoGraphDeploymentValidator creates a new validator for DynamoGraphDeployment.
func NewDynamoGraphDeploymentValidator(deployment *nvidiacomv1alpha1.DynamoGraphDeployment) *DynamoGraphDeploymentValidator {
return &DynamoGraphDeploymentValidator{
deployment: deployment,
mgr: nil,
}
}
// NewDynamoGraphDeploymentValidatorWithManager creates a validator with a manager for API group detection.
func NewDynamoGraphDeploymentValidatorWithManager(deployment *nvidiacomv1alpha1.DynamoGraphDeployment, mgr ctrl.Manager) *DynamoGraphDeploymentValidator {
return &DynamoGraphDeploymentValidator{
deployment: deployment,
mgr: mgr,
}
}
// Validate performs stateless validation on the DynamoGraphDeployment.
// Context is required for operations that may need to query the cluster (e.g., CRD checks).
// Returns warnings and error.
func (v *DynamoGraphDeploymentValidator) Validate(ctx context.Context) (admission.Warnings, error) {
// Validate that at least one service is specified
if len(v.deployment.Spec.Services) == 0 {
return nil, fmt.Errorf("spec.services must have at least one service")
}
// Validate annotations
if err := v.validateAnnotations(); err != nil {
return nil, err
}
// Validate PVCs
if err := v.validatePVCs(); err != nil {
return nil, err
}
// Validate restart
if err := v.validateRestart(); err != nil {
return nil, err
}
var allWarnings admission.Warnings
// Validate each service
for serviceName, service := range v.deployment.Spec.Services {
warnings, err := v.validateService(ctx, serviceName, service)
if err != nil {
return nil, err
}
allWarnings = append(allWarnings, warnings...)
}
return allWarnings, nil
}
// ValidateUpdate performs stateful validation comparing old and new DynamoGraphDeployment.
// userInfo is used for identity-based validation (replica protection).
// If userInfo is nil, replica changes for DGDSA-enabled services are rejected (fail closed).
// Returns warnings and error.
func (v *DynamoGraphDeploymentValidator) ValidateUpdate(old *nvidiacomv1alpha1.DynamoGraphDeployment, userInfo *authenticationv1.UserInfo) (admission.Warnings, error) {
var warnings admission.Warnings
// Validate immutable fields
if err := v.validateImmutableFields(old, &warnings); err != nil {
return warnings, err
}
// Validate service topology is unchanged (service names must remain the same)
if err := v.validateServiceTopology(old); err != nil {
return warnings, err
}
// Validate replicas changes for services with scaling adapter enabled
// Pass userInfo (may be nil - will fail closed for DGDSA-enabled services)
if err := v.validateReplicasChanges(old, userInfo); err != nil {
return warnings, err
}
// Validate no restart.id change during active rolling update
if err := v.validateNoRestartDuringRollingUpdate(old); err != nil {
return warnings, err
}
return warnings, nil
}
// validateImmutableFields checks that immutable fields have not been changed.
// Appends warnings to the provided slice.
func (v *DynamoGraphDeploymentValidator) validateImmutableFields(old *nvidiacomv1alpha1.DynamoGraphDeployment, warnings *admission.Warnings) error {
var errs []error
if v.deployment.Spec.BackendFramework != old.Spec.BackendFramework {
*warnings = append(*warnings, "Changing spec.backendFramework may cause unexpected behavior")
errs = append(errs, fmt.Errorf("spec.backendFramework is immutable and cannot be changed after creation"))
}
// Validate that node topology (single-node vs multi-node) is not changed for each service.
for serviceName, newService := range v.deployment.Spec.Services {
// Get old service (if exists)
oldService, exists := old.Spec.Services[serviceName]
if !exists {
// New service, no comparison needed
continue
}
if oldService.IsMultinode() != newService.IsMultinode() {
errs = append(errs, fmt.Errorf(
"spec.services[%s] cannot change node topology (between single-node and multi-node) after creation",
serviceName,
))
}
}
return errors.Join(errs...)
}
// validateServiceTopology ensures the set of service names remains unchanged.
// Users can modify service specifications, but cannot add or remove services.
// This maintains graph topology immutability while allowing configuration updates.
func (v *DynamoGraphDeploymentValidator) validateServiceTopology(old *nvidiacomv1alpha1.DynamoGraphDeployment) error {
oldServices := getServiceNames(old.Spec.Services)
newServices := getServiceNames(v.deployment.Spec.Services)
added := difference(newServices, oldServices)
removed := difference(oldServices, newServices)
// Fast path: no changes
if len(added) == 0 && len(removed) == 0 {
return nil
}
// Sort for deterministic error messages
sort.Strings(added)
sort.Strings(removed)
// Build descriptive error message
var errMsg string
switch {
case len(added) > 0 && len(removed) > 0:
errMsg = fmt.Sprintf(
"service topology is immutable and cannot be modified after creation: "+
"services added: %v, services removed: %v",
added, removed)
case len(added) > 0:
errMsg = fmt.Sprintf(
"service topology is immutable and cannot be modified after creation: "+
"services added: %v",
added)
case len(removed) > 0:
errMsg = fmt.Sprintf(
"service topology is immutable and cannot be modified after creation: "+
"services removed: %v",
removed)
}
return errors.New(errMsg)
}
// validateReplicasChanges checks if replicas were changed for services with scaling adapter enabled.
// Only authorized service accounts (operator controller, planner) can modify these fields.
// If userInfo is nil, all replica changes for DGDSA-enabled services are rejected (fail closed).
func (v *DynamoGraphDeploymentValidator) validateReplicasChanges(old *nvidiacomv1alpha1.DynamoGraphDeployment, userInfo *authenticationv1.UserInfo) error {
// If the request comes from an authorized service account, allow the change
if userInfo != nil && internalwebhook.CanModifyDGDReplicas(*userInfo) {
return nil
}
var errs []error
for serviceName, newService := range v.deployment.Spec.Services {
// Check if scaling adapter is enabled for this service (disabled by default)
scalingAdapterEnabled := newService.ScalingAdapter != nil && newService.ScalingAdapter.Enabled
if !scalingAdapterEnabled {
// Scaling adapter is not enabled, users can modify replicas directly
continue
}
// Get old service (if exists)
oldService, exists := old.Spec.Services[serviceName]
if !exists {
// New service, no comparison needed
continue
}
// Check if replicas changed
oldReplicas := int32(1) // default
if oldService.Replicas != nil {
oldReplicas = *oldService.Replicas
}
newReplicas := int32(1) // default
if newService.Replicas != nil {
newReplicas = *newService.Replicas
}
if oldReplicas != newReplicas {
errs = append(errs, fmt.Errorf(
"spec.services[%s].replicas cannot be modified directly when scaling adapter is enabled; "+
"scale or update the related DynamoGraphDeploymentScalingAdapter instead",
serviceName))
}
}
return errors.Join(errs...)
}
// validateService validates a single service configuration using SharedSpecValidator.
// Returns warnings and error.
func (v *DynamoGraphDeploymentValidator) validateService(ctx context.Context, serviceName string, service *nvidiacomv1alpha1.DynamoComponentDeploymentSharedSpec) (admission.Warnings, error) {
// Validate service name length constraints for Grove PodCliqueSet naming
// Only validate when Grove pathway may be in use
if v.isGrovePathway() {
if err := v.validateServiceNameLength(serviceName, service); err != nil {
return nil, err
}
}
// Use SharedSpecValidator to validate service spec (which is a DynamoComponentDeploymentSharedSpec)
fieldPath := fmt.Sprintf("spec.services[%s]", serviceName)
calculatedNamespace := v.deployment.GetDynamoNamespaceForService(service)
var sharedValidator *SharedSpecValidator
if v.mgr != nil {
sharedValidator = NewSharedSpecValidatorWithManager(service, fieldPath, calculatedNamespace, v.mgr)
} else {
sharedValidator = NewSharedSpecValidator(service, fieldPath, calculatedNamespace)
}
return sharedValidator.Validate(ctx)
}
// validateServiceNameLength validates that the service name combined with the DGD name
// won't exceed Grove's 45-character limit for resource naming.
//
// Grove generates PodCliqueSet resources with the following naming patterns:
// - PodCliqueSet name: DGD name (e.g., "vllm-agg")
// - For multinode services:
// - PodCliqueScalingGroup name: lowercase(serviceName) (e.g., "vllmprefillworker")
// - PodClique names: lowercase(serviceName + "-ldr") and lowercase(serviceName + "-wkr")
//
// - For single-node services:
// - PodClique name: lowercase(serviceName)
//
// The combined length of these names must not exceed 45 characters.
func (v *DynamoGraphDeploymentValidator) validateServiceNameLength(serviceName string, service *nvidiacomv1alpha1.DynamoComponentDeploymentSharedSpec) error {
dgdName := v.deployment.Name
lowerServiceName := strings.ToLower(serviceName)
// Check if this is a multinode service
isMultinode := service.GetNumberOfNodes() > 1
if isMultinode {
// For multinode: PodCliqueSet name + PodCliqueScalingGroup name + PodClique name (with leader suffix)
// The PodClique name is serviceName + "-ldr" (using GroveRoleSuffixLeader)
leaderPodCliqueName := lowerServiceName + "-" + consts.GroveRoleSuffixLeader
combinedLength := len(dgdName) + len(lowerServiceName) + len(leaderPodCliqueName)
if combinedLength > maxCombinedResourceNameLength {
return fmt.Errorf("spec.services[%s]: combined resource name length %d exceeds %d-character limit required for pod naming. "+
"Consider shortening the DynamoGraphDeployment name '%s' (length %d) or service name '%s' (length %d). "+
"For multinode services, the combined length of DGD name + service name + service name with role suffix (e.g., '%s-ldr') must not exceed %d characters",
serviceName, combinedLength, maxCombinedResourceNameLength,
dgdName, len(dgdName), serviceName, len(serviceName),
lowerServiceName, maxCombinedResourceNameLength)
}
} else {
// For single-node: PodCliqueSet name + PodClique name
combinedLength := len(dgdName) + len(lowerServiceName)
if combinedLength > maxCombinedResourceNameLength {
return fmt.Errorf("spec.services[%s]: combined resource name length %d exceeds %d-character limit required for pod naming. "+
"Consider shortening the DynamoGraphDeployment name '%s' (length %d) or service name '%s' (length %d). "+
"The combined length of DGD name + service name must not exceed %d characters",
serviceName, combinedLength, maxCombinedResourceNameLength,
dgdName, len(dgdName), serviceName, len(serviceName),
maxCombinedResourceNameLength)
}
}
return nil
}
// isGrovePathway determines if Grove pathway may be used for this deployment.
// Grove is used when the nvidia.com/enable-grove annotation is NOT explicitly set to "false".
// This is a conservative check - if Grove might be used, we validate the name length constraints.
func (v *DynamoGraphDeploymentValidator) isGrovePathway() bool {
return v.deployment.Annotations == nil ||
strings.ToLower(v.deployment.Annotations[consts.KubeAnnotationEnableGrove]) != consts.KubeLabelValueFalse
}
// validatePVCs validates the PVC configurations.
func (v *DynamoGraphDeploymentValidator) validatePVCs() error {
for i, pvc := range v.deployment.Spec.PVCs {
if err := v.validatePVC(i, &pvc); err != nil {
return err
}
}
return nil
}
// validatePVC validates a single PVC configuration.
func (v *DynamoGraphDeploymentValidator) validatePVC(index int, pvc *nvidiacomv1alpha1.PVC) error {
var err error
// Validate name is not nil
if pvc.Name == nil || *pvc.Name == "" {
err = errors.Join(err, fmt.Errorf("spec.pvcs[%d].name is required", index))
}
// Check if create is true
if pvc.Create != nil && *pvc.Create {
// Validate required fields when create is true
if pvc.StorageClass == "" {
err = errors.Join(err, fmt.Errorf("spec.pvcs[%d].storageClass is required when create is true", index))
}
if pvc.Size.IsZero() {
err = errors.Join(err, fmt.Errorf("spec.pvcs[%d].size is required when create is true", index))
}
if pvc.VolumeAccessMode == "" {
err = errors.Join(err, fmt.Errorf("spec.pvcs[%d].volumeAccessMode is required when create is true", index))
}
}
return err
}
func (v *DynamoGraphDeploymentValidator) validateRestart() error {
if v.deployment.Spec.Restart == nil {
return nil
}
restart := v.deployment.Spec.Restart
var err error
if restart.ID == "" {
err = errors.Join(err, fmt.Errorf("spec.restart.id is required"))
}
return errors.Join(err, v.validateRestartStrategyOrder())
}
func (v *DynamoGraphDeploymentValidator) validateRestartStrategyOrder() error {
restart := v.deployment.Spec.Restart
if restart.Strategy == nil || len(restart.Strategy.Order) == 0 {
return nil
}
if restart.Strategy.Type == nvidiacomv1alpha1.RestartStrategyTypeParallel {
return errors.New("spec.restart.strategy.order cannot be specified when strategy is parallel")
}
var err error
uniqueOrder := getUnique(restart.Strategy.Order)
if len(uniqueOrder) != len(restart.Strategy.Order) {
err = errors.Join(err, fmt.Errorf("spec.restart.strategy.order must be unique"))
}
if len(uniqueOrder) != len(v.deployment.Spec.Services) {
err = errors.Join(err, fmt.Errorf("spec.restart.strategy.order must have the same number of unique services as the deployment"))
}
for _, serviceName := range uniqueOrder {
if _, exists := v.deployment.Spec.Services[serviceName]; !exists {
err = errors.Join(err, fmt.Errorf("spec.restart.strategy.order contains unknown service: %s", serviceName))
}
}
return err
}
// validateAnnotations validates known DGD annotations have valid values.
func (v *DynamoGraphDeploymentValidator) validateAnnotations() error {
annotations := v.deployment.GetAnnotations()
if annotations == nil {
return nil
}
var errs []error
// Validate operator origin version is valid semver (if present)
if value, exists := annotations[consts.KubeAnnotationDynamoOperatorOriginVersion]; exists {
if _, err := semver.NewVersion(value); err != nil {
errs = append(errs, fmt.Errorf("annotation %s has invalid value %q: must be valid semver",
consts.KubeAnnotationDynamoOperatorOriginVersion, value))
}
}
// Validate vLLM distributed executor backend override
if value, exists := annotations[consts.KubeAnnotationVLLMDistributedExecutorBackend]; exists {
switch strings.ToLower(value) {
case "mp", "ray":
// valid
default:
errs = append(errs, fmt.Errorf("annotation %s has invalid value %q: must be \"mp\" or \"ray\"",
consts.KubeAnnotationVLLMDistributedExecutorBackend, value))
}
}
return errors.Join(errs...)
}
func getUnique[T comparable](slice []T) []T {
seen := make(map[T]struct{}, len(slice))
uniqueSlice := make([]T, 0, len(slice))
for _, element := range slice {
if _, exists := seen[element]; !exists {
seen[element] = struct{}{}
uniqueSlice = append(uniqueSlice, element)
}
}
return uniqueSlice
}
// getServiceNames extracts service names from a services map.
// Returns a set-like map for efficient lookup and comparison.
func getServiceNames(services map[string]*nvidiacomv1alpha1.DynamoComponentDeploymentSharedSpec) map[string]struct{} {
names := make(map[string]struct{}, len(services))
for name := range services {
names[name] = struct{}{}
}
return names
}
// difference returns elements in set a that are not in set b (a - b).
// This is used to find added or removed services.
func difference(a, b map[string]struct{}) []string {
var result []string
for name := range a {
if _, exists := b[name]; !exists {
result = append(result, name)
}
}
return result
}
// validateNoRestartDuringRollingUpdate rejects restart.id changes while a rolling update is active.
func (v *DynamoGraphDeploymentValidator) validateNoRestartDuringRollingUpdate(old *nvidiacomv1alpha1.DynamoGraphDeployment) error {
// Check if a rolling update is active (Pending or InProgress)
if old.Status.RollingUpdate == nil {
return nil
}
phase := old.Status.RollingUpdate.Phase
if phase != nvidiacomv1alpha1.RollingUpdatePhasePending && phase != nvidiacomv1alpha1.RollingUpdatePhaseInProgress {
return nil
}
// Compare restart IDs
oldID := ""
if old.Spec.Restart != nil {
oldID = old.Spec.Restart.ID
}
newID := ""
if v.deployment.Spec.Restart != nil {
newID = v.deployment.Spec.Restart.ID
}
if oldID != newID {
return fmt.Errorf("spec.restart.id cannot be changed while a rolling update is %s", phase)
}
return nil
}