dynamo/deploy/operator/internal/controller/dynamo_model_controller_tes...

378 lines
11 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 controller
import (
"context"
"time"
"github.com/ai-dynamo/dynamo/deploy/operator/api/v1alpha1"
"github.com/ai-dynamo/dynamo/deploy/operator/internal/consts"
"github.com/ai-dynamo/dynamo/deploy/operator/internal/dynamo"
"github.com/ai-dynamo/dynamo/deploy/operator/internal/modelendpoint"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
corev1 "k8s.io/api/core/v1"
discoveryv1 "k8s.io/api/discovery/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/types"
"k8s.io/client-go/tools/record"
"sigs.k8s.io/controller-runtime/pkg/reconcile"
)
var _ = Describe("DynamoModel Controller", func() {
const (
timeout = time.Second * 10
interval = time.Millisecond * 250
)
var (
reconciler *DynamoModelReconciler
recorder *record.FakeRecorder
)
BeforeEach(func() {
recorder = record.NewFakeRecorder(100)
reconciler = &DynamoModelReconciler{
Client: k8sClient,
Recorder: recorder,
EndpointClient: modelendpoint.NewClient(),
}
})
Context("When reconciling LoRA model", func() {
It("Should discover endpoints and set conditions", func() {
ctx := context.Background()
namespace := defaultNamespace
modelName := "test-lora-model"
baseModelName := "base-model-lora"
// Create the DynamoModel
model := &v1alpha1.DynamoModel{
ObjectMeta: metav1.ObjectMeta{
Name: modelName,
Namespace: namespace,
},
Spec: v1alpha1.DynamoModelSpec{
ModelName: modelName,
BaseModelName: baseModelName,
ModelType: "lora",
Source: &v1alpha1.ModelSource{
URI: "s3://bucket/model",
},
},
}
Expect(k8sClient.Create(ctx, model)).Should(Succeed())
defer func() { _ = k8sClient.Delete(ctx, model) }()
// Create EndpointSlice with ready Pod endpoints
trueVal := true
modelHash := dynamo.HashModelName(baseModelName)
endpointSlice := &discoveryv1.EndpointSlice{
ObjectMeta: metav1.ObjectMeta{
Name: "test-lora-endpoints",
Namespace: namespace,
Labels: map[string]string{
consts.KubeLabelDynamoBaseModelHash: modelHash,
discoveryv1.LabelServiceName: "test-service",
},
},
AddressType: discoveryv1.AddressTypeIPv4,
Endpoints: []discoveryv1.Endpoint{
{
Addresses: []string{"10.0.1.5"},
Conditions: discoveryv1.EndpointConditions{
Ready: &trueVal,
},
TargetRef: &corev1.ObjectReference{
Kind: "Pod",
Name: "worker-0",
},
},
{
Addresses: []string{"10.0.1.6"},
Conditions: discoveryv1.EndpointConditions{
Ready: &trueVal,
},
TargetRef: &corev1.ObjectReference{
Kind: "Pod",
Name: "worker-1",
},
},
},
Ports: []discoveryv1.EndpointPort{
{
Port: func() *int32 { p := int32(9090); return &p }(),
},
},
}
Expect(k8sClient.Create(ctx, endpointSlice)).Should(Succeed())
defer func() { _ = k8sClient.Delete(ctx, endpointSlice) }()
// Reconcile
_, err := reconciler.Reconcile(ctx, reconcile.Request{
NamespacedName: types.NamespacedName{
Name: modelName,
Namespace: namespace,
},
})
Expect(err).NotTo(HaveOccurred())
// Verify endpoints were discovered
Eventually(func() int {
var updated v1alpha1.DynamoModel
_ = k8sClient.Get(ctx, types.NamespacedName{Name: modelName, Namespace: namespace}, &updated)
return updated.Status.TotalEndpoints
}, timeout, interval).Should(Equal(2))
// Verify condition is set (will be False since LoRA load will fail without real service)
Eventually(func() bool {
var updated v1alpha1.DynamoModel
_ = k8sClient.Get(ctx, types.NamespacedName{Name: modelName, Namespace: namespace}, &updated)
for _, cond := range updated.Status.Conditions {
if cond.Type == ConditionTypeEndpointsReady {
return true
}
}
return false
}, timeout, interval).Should(BeTrue())
})
})
Context("When reconciling with non-Pod endpoints", func() {
It("Should skip endpoints without Pod TargetRef", func() {
ctx := context.Background()
namespace := defaultNamespace
modelName := "test-non-pod-model"
baseModelName := "base-model-non-pod"
// Create the DynamoModel
model := &v1alpha1.DynamoModel{
ObjectMeta: metav1.ObjectMeta{
Name: modelName,
Namespace: namespace,
},
Spec: v1alpha1.DynamoModelSpec{
ModelName: modelName,
BaseModelName: baseModelName,
ModelType: "base",
},
}
Expect(k8sClient.Create(ctx, model)).Should(Succeed())
defer func() { _ = k8sClient.Delete(ctx, model) }()
// Create EndpointSlice with mixed endpoints (some Pod, some not)
trueVal := true
modelHash := dynamo.HashModelName(baseModelName)
endpointSlice := &discoveryv1.EndpointSlice{
ObjectMeta: metav1.ObjectMeta{
Name: "test-mixed-endpoints",
Namespace: namespace,
Labels: map[string]string{
consts.KubeLabelDynamoBaseModelHash: modelHash,
},
},
AddressType: discoveryv1.AddressTypeIPv4,
Endpoints: []discoveryv1.Endpoint{
{
Addresses: []string{"10.0.1.7"},
Conditions: discoveryv1.EndpointConditions{
Ready: &trueVal,
},
TargetRef: &corev1.ObjectReference{
Kind: "Pod",
Name: "worker-0",
},
},
{
Addresses: []string{"10.0.1.8"},
Conditions: discoveryv1.EndpointConditions{
Ready: &trueVal,
},
TargetRef: &corev1.ObjectReference{
Kind: "Node", // Not a Pod - should be skipped
Name: "node-1",
},
},
{
Addresses: []string{"10.0.1.9"},
Conditions: discoveryv1.EndpointConditions{
Ready: &trueVal,
},
TargetRef: nil, // Nil TargetRef - should be skipped
},
},
Ports: []discoveryv1.EndpointPort{
{
Port: func() *int32 { p := int32(9090); return &p }(),
},
},
}
Expect(k8sClient.Create(ctx, endpointSlice)).Should(Succeed())
defer func() { _ = k8sClient.Delete(ctx, endpointSlice) }()
// Reconcile
_, err := reconciler.Reconcile(ctx, reconcile.Request{
NamespacedName: types.NamespacedName{
Name: modelName,
Namespace: namespace,
},
})
Expect(err).NotTo(HaveOccurred())
// Should only discover 1 endpoint (the Pod), not the Node or nil TargetRef
Eventually(func() int {
var updated v1alpha1.DynamoModel
_ = k8sClient.Get(ctx, types.NamespacedName{Name: modelName, Namespace: namespace}, &updated)
return updated.Status.TotalEndpoints
}, timeout, interval).Should(Equal(1))
// Verify only the Pod endpoint was included
Eventually(func() string {
var updated v1alpha1.DynamoModel
_ = k8sClient.Get(ctx, types.NamespacedName{Name: modelName, Namespace: namespace}, &updated)
if len(updated.Status.Endpoints) > 0 {
return updated.Status.Endpoints[0].PodName
}
return ""
}, timeout, interval).Should(Equal("worker-0"))
})
})
Context("When reconciling base model", func() {
It("Should set EndpointsReady=True when endpoints exist", func() {
ctx := context.Background()
namespace := defaultNamespace
modelName := "test-base-model"
baseModelName := "base-model-base"
// Create the DynamoModel
model := &v1alpha1.DynamoModel{
ObjectMeta: metav1.ObjectMeta{
Name: modelName,
Namespace: namespace,
},
Spec: v1alpha1.DynamoModelSpec{
ModelName: modelName,
BaseModelName: baseModelName,
ModelType: "base",
},
}
Expect(k8sClient.Create(ctx, model)).Should(Succeed())
defer func() { _ = k8sClient.Delete(ctx, model) }()
// Create EndpointSlice
trueVal := true
modelHash := dynamo.HashModelName(baseModelName)
endpointSlice := &discoveryv1.EndpointSlice{
ObjectMeta: metav1.ObjectMeta{
Name: "test-base-endpoints",
Namespace: namespace,
Labels: map[string]string{
consts.KubeLabelDynamoBaseModelHash: modelHash,
},
},
AddressType: discoveryv1.AddressTypeIPv4,
Endpoints: []discoveryv1.Endpoint{
{
Addresses: []string{"10.0.1.10"},
Conditions: discoveryv1.EndpointConditions{
Ready: &trueVal,
},
TargetRef: &corev1.ObjectReference{
Kind: "Pod",
Name: "worker-0",
},
},
},
Ports: []discoveryv1.EndpointPort{
{
Port: func() *int32 { p := int32(9090); return &p }(),
},
},
}
Expect(k8sClient.Create(ctx, endpointSlice)).Should(Succeed())
defer func() { _ = k8sClient.Delete(ctx, endpointSlice) }()
// Reconcile
_, err := reconciler.Reconcile(ctx, reconcile.Request{
NamespacedName: types.NamespacedName{
Name: modelName,
Namespace: namespace,
},
})
Expect(err).NotTo(HaveOccurred())
// For base models, EndpointsReady should be True when endpoints exist
Eventually(func() bool {
var updated v1alpha1.DynamoModel
_ = k8sClient.Get(ctx, types.NamespacedName{Name: modelName, Namespace: namespace}, &updated)
for _, cond := range updated.Status.Conditions {
if cond.Type == ConditionTypeEndpointsReady {
return cond.Status == metav1.ConditionTrue && cond.Reason == ReasonEndpointsDiscovered
}
}
return false
}, timeout, interval).Should(BeTrue())
})
It("Should set EndpointsReady=False when no endpoints exist", func() {
ctx := context.Background()
namespace := defaultNamespace
modelName := "test-base-model-no-endpoints"
baseModelName := "base-model-none"
// Create the DynamoModel
model := &v1alpha1.DynamoModel{
ObjectMeta: metav1.ObjectMeta{
Name: modelName,
Namespace: namespace,
},
Spec: v1alpha1.DynamoModelSpec{
ModelName: modelName,
BaseModelName: baseModelName,
ModelType: "base",
},
}
Expect(k8sClient.Create(ctx, model)).Should(Succeed())
defer func() { _ = k8sClient.Delete(ctx, model) }()
// Reconcile (no endpoints created)
_, err := reconciler.Reconcile(ctx, reconcile.Request{
NamespacedName: types.NamespacedName{
Name: modelName,
Namespace: namespace,
},
})
Expect(err).NotTo(HaveOccurred())
// Should have condition set to False with NoEndpoints reason
Eventually(func() bool {
var updated v1alpha1.DynamoModel
_ = k8sClient.Get(ctx, types.NamespacedName{Name: modelName, Namespace: namespace}, &updated)
for _, cond := range updated.Status.Conditions {
if cond.Type == ConditionTypeEndpointsReady {
return cond.Status == metav1.ConditionFalse && cond.Reason == ReasonNoEndpoints
}
}
return false
}, timeout, interval).Should(BeTrue())
})
})
})