mirror of
https://github.com/hanzo-ml/kserve.git
synced 2026-10-30 20:53:49 +00:00
617 lines
24 KiB
Go
617 lines
24 KiB
Go
/*
|
|
Copyright 2024 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.
|
|
*/
|
|
|
|
// +kubebuilder:rbac:groups=serving.kserve.io,resources=localmodelnodegroups,verbs=get;list;watch
|
|
// +kubebuilder:rbac:groups=serving.kserve.io,resources=clusterstoragecontainers,verbs=get;list;watch
|
|
// +kubebuilder:rbac:groups=serving.kserve.io,resources=localmodelnodes,verbs=get;list;watch;create;update;patch;delete
|
|
// +kubebuilder:rbac:groups=serving.kserve.io,resources=localmodelnodes/status,verbs=get;update;patch
|
|
// +kubebuilder:rbac:groups=core,resources=configmaps,verbs=get
|
|
// +kubebuilder:rbac:groups=core,resources=nodes,verbs=get;list;watch
|
|
// +kubebuilder:rbac:groups=core,resources=nodes/status,verbs=get;watch
|
|
// +kubebuilder:rbac:groups=core,resources=secrets,verbs=get;list;watch
|
|
// +kubebuilder:rbac:groups=core,resources=serviceaccounts,verbs=get;list;watch
|
|
// +kubebuilder:rbac:groups=batch,resources=jobs,verbs=get;list;watch;create;update;patch;delete
|
|
// +kubebuilder:rbac:groups=batch,resources=jobs/status,verbs=get
|
|
package localmodelnode
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"maps"
|
|
"os"
|
|
"path/filepath"
|
|
"time"
|
|
|
|
"github.com/go-logr/logr"
|
|
batchv1 "k8s.io/api/batch/v1"
|
|
corev1 "k8s.io/api/core/v1"
|
|
"k8s.io/apimachinery/pkg/api/errors"
|
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
|
"k8s.io/apimachinery/pkg/runtime"
|
|
"k8s.io/apimachinery/pkg/types"
|
|
"k8s.io/client-go/kubernetes"
|
|
ctrl "sigs.k8s.io/controller-runtime"
|
|
"sigs.k8s.io/controller-runtime/pkg/builder"
|
|
"sigs.k8s.io/controller-runtime/pkg/client"
|
|
"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
|
|
"sigs.k8s.io/controller-runtime/pkg/predicate"
|
|
"sigs.k8s.io/controller-runtime/pkg/reconcile"
|
|
|
|
"github.com/kserve/kserve/pkg/apis/serving/v1alpha1"
|
|
"github.com/kserve/kserve/pkg/apis/serving/v1beta1"
|
|
"github.com/kserve/kserve/pkg/constants"
|
|
"github.com/kserve/kserve/pkg/controller/v1alpha1/utils"
|
|
"github.com/kserve/kserve/pkg/credentials"
|
|
pkgtypes "github.com/kserve/kserve/pkg/types"
|
|
)
|
|
|
|
type ensureModelRootFolderResult struct {
|
|
Result ctrl.Result
|
|
Continue bool
|
|
}
|
|
|
|
type LocalModelNodeReconciler struct {
|
|
client.Client
|
|
Clientset *kubernetes.Clientset
|
|
Log logr.Logger
|
|
Scheme *runtime.Scheme
|
|
CredentialBuilder *credentials.CredentialBuilder
|
|
IsvcConfigMap *corev1.ConfigMap
|
|
}
|
|
|
|
const (
|
|
DownloadContainerName = "kserve-localmodel-download"
|
|
PvcSourceMountName = "kserve-pvc-source"
|
|
)
|
|
|
|
var (
|
|
defaultJobImage = "kserve/storage-initializer:latest" // Can be overwritten by the value in the configmap
|
|
FSGroup *int64
|
|
jobNamespace string
|
|
jobTTLSecondsAfterFinished int32 = 3600 // One hour. Can be overwritten by the value in the configmap
|
|
reconcilationFreqency time.Duration = time.Minute // Reconcile every one minute to check if model folders exist. Can be overwritten by the value in configmap
|
|
nodeName = os.Getenv("NODE_NAME") // Name of current node, passed as an env variable via downward API
|
|
modelsRootFolder = filepath.Join(MountPath, "models")
|
|
fsHelper FileSystemInterface
|
|
storageInitializerConfig *pkgtypes.StorageInitializerConfig
|
|
)
|
|
|
|
// Returns the first matching nodegroup for a node.
|
|
// NOTE: A node may match multiple nodegroups with overlapping affinity.
|
|
// This is only used as a fallback when modelInfo.NodeGroup is not set.
|
|
func (c *LocalModelNodeReconciler) getNodeGroupFromNode(ctx context.Context, nodeName string) (*v1alpha1.LocalModelNodeGroup, error) {
|
|
node := &corev1.Node{}
|
|
if err := c.Get(ctx, types.NamespacedName{Name: nodeName}, node); err != nil {
|
|
return nil, err
|
|
}
|
|
nodeGroups := &v1alpha1.LocalModelNodeGroupList{}
|
|
if err := c.List(ctx, nodeGroups); err != nil {
|
|
return nil, err
|
|
}
|
|
for _, nodeGroup := range nodeGroups.Items {
|
|
matches, err := utils.CheckNodeAffinity(&nodeGroup.Spec.PersistentVolumeSpec, *node)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if matches {
|
|
return &nodeGroup, nil
|
|
}
|
|
}
|
|
|
|
return nil, fmt.Errorf("did not find matching nodegroup for node: %s", nodeName)
|
|
}
|
|
|
|
func (c *LocalModelNodeReconciler) launchJob(ctx context.Context, localModelNode v1alpha1.LocalModelNode, modelInfo v1alpha1.LocalModelInfo) (*batchv1.Job, error) {
|
|
jobName := modelInfo.ModelName + "-" + localModelNode.Name
|
|
|
|
// Use NodeGroup from modelInfo if set, otherwise fall back to getNodeGroupFromNode
|
|
nodeGroupName := modelInfo.NodeGroup
|
|
if nodeGroupName == "" {
|
|
nodeGroup, err := c.getNodeGroupFromNode(ctx, nodeName)
|
|
if nodeGroup == nil {
|
|
c.Log.Error(err, "Failed to get node group for current node", "node name", nodeName)
|
|
return nil, err
|
|
}
|
|
nodeGroupName = nodeGroup.Name
|
|
}
|
|
|
|
pvcName := modelInfo.ModelName + "-" + nodeGroupName
|
|
if modelInfo.Namespace != "" {
|
|
pvcName += "-" + modelInfo.Namespace + "-download"
|
|
}
|
|
c.Log.Info("Using PVC name to create download job", "current node", nodeName, "node group", nodeGroupName, "PVC name", pvcName)
|
|
|
|
// First, try to get container spec from ClusterStorageContainer for backward compatibility
|
|
container, err := c.getContainerSpecForStorageUri(ctx, modelInfo.SourceModelUri)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// If no ClusterStorageContainer match, use StorageInitializerConfig
|
|
if container == nil {
|
|
container = c.getContainerSpecFromConfig(storageInitializerConfig)
|
|
}
|
|
|
|
// Use hash-based folder path for storage deduplication
|
|
storageKey := v1alpha1.GetStorageKey(modelInfo.SourceModelUri)
|
|
container.Args = []string{modelInfo.SourceModelUri, MountPath}
|
|
container.VolumeMounts = []corev1.VolumeMount{
|
|
{
|
|
MountPath: MountPath,
|
|
Name: PvcSourceMountName,
|
|
ReadOnly: false,
|
|
SubPath: filepath.Join("models", storageKey),
|
|
},
|
|
}
|
|
|
|
volumes := []corev1.Volume{
|
|
{
|
|
Name: PvcSourceMountName,
|
|
VolumeSource: corev1.VolumeSource{
|
|
PersistentVolumeClaim: &corev1.PersistentVolumeClaimVolumeSource{
|
|
ClaimName: pvcName,
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
jobNs := jobNamespace
|
|
|
|
// Only inject if credentials are explicitly configured in LocalModelCache
|
|
if modelInfo.ServiceAccountName != "" || modelInfo.Storage != nil {
|
|
if err := c.injectCredentials(ctx, container, &volumes, modelInfo, jobNs); err != nil {
|
|
c.Log.Error(err, "Failed to inject credentials", "model", modelInfo.ModelName)
|
|
// Don't fail the job creation, continue with whatever credentials were injected
|
|
}
|
|
}
|
|
|
|
// Note: statusKey (namespace/modelName) cannot be used as a label value since labels
|
|
// cannot contain '/'. We store namespace separately and reconstruct statusKey when needed.
|
|
jobLabels := map[string]string{
|
|
"model": modelInfo.ModelName,
|
|
"node": localModelNode.Name,
|
|
}
|
|
if modelInfo.Namespace != "" {
|
|
jobLabels["modelNamespace"] = modelInfo.Namespace
|
|
}
|
|
|
|
job := &batchv1.Job{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
GenerateName: jobName,
|
|
Namespace: jobNs,
|
|
Labels: jobLabels,
|
|
},
|
|
Spec: batchv1.JobSpec{
|
|
TTLSecondsAfterFinished: &jobTTLSecondsAfterFinished,
|
|
Template: corev1.PodTemplateSpec{
|
|
Spec: corev1.PodSpec{
|
|
NodeSelector: map[string]string{"kubernetes.io/hostname": nodeName},
|
|
Containers: []corev1.Container{*container},
|
|
RestartPolicy: corev1.RestartPolicyNever,
|
|
Volumes: volumes,
|
|
SecurityContext: &corev1.PodSecurityContext{
|
|
FSGroup: FSGroup,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
if err := c.enhanceDownloadJob(ctx, job, storageKey); err != nil {
|
|
c.Log.Error(err, "Failed to enhance download job", "name", modelInfo.ModelName)
|
|
return nil, err
|
|
}
|
|
if err := controllerutil.SetControllerReference(&localModelNode, job, c.Scheme); err != nil {
|
|
c.Log.Error(err, "Failed to set controller reference", "name", modelInfo.ModelName)
|
|
return nil, err
|
|
}
|
|
jobs := c.Clientset.BatchV1().Jobs(jobNs)
|
|
createdJob, err := jobs.Create(ctx, job, metav1.CreateOptions{})
|
|
if err != nil {
|
|
c.Log.Error(err, "Failed to create job.", "name", job.Name)
|
|
return nil, err
|
|
}
|
|
c.Log.Info("Created job", "name", createdJob.Name, "namespace", createdJob.Namespace,
|
|
"model", modelInfo.ModelName, "storageKey", storageKey)
|
|
return createdJob, err
|
|
}
|
|
|
|
func (c *LocalModelNodeReconciler) getContainerSpecFromConfig(config *pkgtypes.StorageInitializerConfig) *corev1.Container {
|
|
image := defaultJobImage
|
|
if config != nil && config.Image != "" {
|
|
image = config.Image
|
|
}
|
|
|
|
container := &corev1.Container{
|
|
Name: DownloadContainerName,
|
|
Image: image,
|
|
TerminationMessagePolicy: corev1.TerminationMessageFallbackToLogsOnError,
|
|
}
|
|
|
|
return container
|
|
}
|
|
|
|
// injectCredentials injects storage credentials into the download container.
|
|
func (c *LocalModelNodeReconciler) injectCredentials(ctx context.Context, container *corev1.Container,
|
|
volumes *[]corev1.Volume, modelInfo v1alpha1.LocalModelInfo, jobNs string,
|
|
) error {
|
|
if c.CredentialBuilder == nil {
|
|
c.Log.Info("CredentialBuilder not initialized, skipping credential injection")
|
|
return nil
|
|
}
|
|
|
|
// If storage spec with key is provided, use storage spec credentials
|
|
if modelInfo.Storage != nil && modelInfo.Storage.StorageKey != nil {
|
|
var params map[string]string
|
|
if modelInfo.Storage.Parameters != nil {
|
|
params = *modelInfo.Storage.Parameters
|
|
}
|
|
c.Log.Info("Injecting storage spec credentials", "storageKey", *modelInfo.Storage.StorageKey)
|
|
return c.CredentialBuilder.CreateStorageSpecSecretEnvs(
|
|
ctx, jobNs, nil, *modelInfo.Storage.StorageKey, params, container)
|
|
}
|
|
|
|
// Use service account credentials
|
|
serviceAccountName := modelInfo.ServiceAccountName
|
|
if serviceAccountName == "" {
|
|
serviceAccountName = "default"
|
|
}
|
|
c.Log.Info("Injecting service account credentials", "serviceAccountName", serviceAccountName)
|
|
return c.CredentialBuilder.CreateSecretVolumeAndEnv(
|
|
ctx, jobNs, nil, serviceAccountName, container, volumes)
|
|
}
|
|
|
|
// Fetches container spec for model download container, use the default KServe image if not found
|
|
// This function is kept for backward compatibility with ClusterStorageContainer
|
|
func (c *LocalModelNodeReconciler) getContainerSpecForStorageUri(ctx context.Context, storageUri string) (*corev1.Container, error) {
|
|
storageContainers := &v1alpha1.ClusterStorageContainerList{}
|
|
if err := c.List(ctx, storageContainers); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
for _, sc := range storageContainers.Items {
|
|
if sc.IsDisabled() {
|
|
continue
|
|
}
|
|
if sc.Spec.WorkloadType != v1alpha1.LocalModelDownloadJob {
|
|
continue
|
|
}
|
|
supported, err := sc.Spec.IsStorageUriSupported(storageUri)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("error checking storage container %s: %w", sc.Name, err)
|
|
}
|
|
if supported {
|
|
return &sc.Spec.Container, nil
|
|
}
|
|
}
|
|
|
|
return nil, nil
|
|
}
|
|
|
|
func (c *LocalModelNodeReconciler) getLatestJob(ctx context.Context, modelInfo v1alpha1.LocalModelInfo, nodeName string) (*batchv1.Job, int, error) {
|
|
jobList := &batchv1.JobList{}
|
|
labelSelector := map[string]string{
|
|
"model": modelInfo.ModelName,
|
|
"node": nodeName,
|
|
}
|
|
// For namespace-scoped models, also filter by namespace label
|
|
if modelInfo.Namespace != "" {
|
|
labelSelector["modelNamespace"] = modelInfo.Namespace
|
|
}
|
|
|
|
if err := c.List(ctx, jobList, client.InNamespace(jobNamespace), client.MatchingLabels(labelSelector)); err != nil {
|
|
if errors.IsNotFound(err) {
|
|
c.Log.Info("Job not found", "model", modelInfo.ModelName, "namespace", modelInfo.Namespace)
|
|
return nil, 0, nil
|
|
}
|
|
return nil, 0, err
|
|
}
|
|
c.Log.Info("Found jobs", "model", modelInfo.ModelName, "namespace", modelInfo.Namespace, "num of jobs", len(jobList.Items))
|
|
var latestJob *batchv1.Job
|
|
for i, job := range jobList.Items {
|
|
if latestJob == nil || job.CreationTimestamp.After(latestJob.CreationTimestamp.Time) {
|
|
latestJob = &jobList.Items[i]
|
|
}
|
|
}
|
|
return latestJob, len(jobList.Items), nil
|
|
}
|
|
|
|
func getModelStatusFromJobStatus(jobStatus batchv1.JobStatus) v1alpha1.ModelStatus {
|
|
switch {
|
|
case jobStatus.Succeeded > 0:
|
|
return v1alpha1.ModelDownloaded
|
|
case jobStatus.Failed > 0:
|
|
return v1alpha1.ModelDownloadError
|
|
case jobStatus.Ready != nil && *jobStatus.Ready > 0:
|
|
return v1alpha1.ModelDownloading
|
|
default:
|
|
return v1alpha1.ModelDownloadPending
|
|
}
|
|
}
|
|
|
|
// Create jobs to download models if the model is not present locally
|
|
// Update the status of the LocalModelNode CR
|
|
// Uses hash-based folders for storage deduplication while tracking each CR individually in status
|
|
func (c *LocalModelNodeReconciler) downloadModels(ctx context.Context, localModelNode *v1alpha1.LocalModelNode) error {
|
|
c.Log.Info("Downloading models to", "node", localModelNode.Name)
|
|
|
|
newStatus := map[string]v1alpha1.ModelStatus{}
|
|
// Track which storage keys (URI hashes) have been processed for download deduplication
|
|
processedStorageKeys := map[string]v1alpha1.ModelStatus{}
|
|
|
|
for _, modelInfo := range localModelNode.Spec.LocalModels {
|
|
statusKey := modelInfo.GetStatusKey()
|
|
storageKey := v1alpha1.GetStorageKey(modelInfo.SourceModelUri)
|
|
c.Log.Info("checking model from spec", "model", modelInfo.ModelName, "namespace", modelInfo.Namespace, "statusKey", statusKey, "storageKey", storageKey)
|
|
|
|
// Check if another CR with the same URI has already been processed
|
|
// If so, reuse its status (storage deduplication - same folder on disk)
|
|
if status, exists := processedStorageKeys[storageKey]; exists {
|
|
c.Log.Info("Reusing status from another CR with same URI", "statusKey", statusKey, "storageKey", storageKey, "status", status)
|
|
newStatus[statusKey] = status
|
|
continue
|
|
}
|
|
|
|
var job *batchv1.Job
|
|
folderExists, err := fsHelper.hasModelFolder(storageKey)
|
|
if err != nil {
|
|
c.Log.Error(err, "Failed to check model folder", "model", modelInfo.ModelName, "storageKey", storageKey)
|
|
return err
|
|
}
|
|
if folderExists {
|
|
c.Log.Info("Model folder found", "model", modelInfo.ModelName, "storageKey", storageKey)
|
|
// If folder exists and the job has been successfully completed, do nothing
|
|
// If the job is cleaned up, no new job is created because the status is already set to ModelDownloaded
|
|
if status, ok := localModelNode.Status.ModelStatus[statusKey]; ok {
|
|
if status == v1alpha1.ModelDownloaded {
|
|
newStatus[statusKey] = v1alpha1.ModelDownloaded
|
|
processedStorageKeys[storageKey] = v1alpha1.ModelDownloaded
|
|
continue
|
|
}
|
|
}
|
|
job, _, err = c.getLatestJob(ctx, modelInfo, nodeName)
|
|
if err != nil {
|
|
c.Log.Error(err, "Failed to getLatestJob", "model", modelInfo.ModelName, "node", nodeName)
|
|
return err
|
|
}
|
|
// If job is not found, create a new one. Because download could be incomplete.
|
|
if job == nil {
|
|
c.Log.Info("Model folder exists, creating download job", "model", modelInfo.ModelName, "storageKey", storageKey)
|
|
job, err = c.launchJob(ctx, *localModelNode, modelInfo)
|
|
if err != nil {
|
|
c.Log.Error(err, "Failed to create Job", "model", modelInfo.ModelName, "node", nodeName)
|
|
return err
|
|
}
|
|
}
|
|
status := getModelStatusFromJobStatus(job.Status)
|
|
newStatus[statusKey] = status
|
|
processedStorageKeys[storageKey] = status
|
|
c.Log.Info("model downloading status:", "model", modelInfo.ModelName, "statusKey", statusKey,
|
|
"node", localModelNode.Name, "status", status)
|
|
} else {
|
|
// Folder does not exist
|
|
c.Log.Info("Model folder not found", "model", modelInfo.ModelName, "storageKey", storageKey)
|
|
job, jobCount, err := c.getLatestJob(ctx, modelInfo, nodeName)
|
|
if err != nil {
|
|
c.Log.Error(err, "Failed to getLatestJob", "model", modelInfo.ModelName, "node", nodeName)
|
|
return err
|
|
}
|
|
if job != nil {
|
|
c.Log.Info("model status from latest job", "model", modelInfo.ModelName, "status", getModelStatusFromJobStatus(job.Status))
|
|
}
|
|
// Recreate job if it has been terminated because the model is missing locally
|
|
// If the job has failed, we do not retry here because there are retries on the job.
|
|
// To retry the download, users can manually fix the issue and delete the failed job.
|
|
// Add the job count check for protection to ensure not creating more than 2 jobs including the previous one.
|
|
if job == nil || (job.Status.Succeeded > 0 && jobCount < 2) {
|
|
job, err = c.launchJob(ctx, *localModelNode, modelInfo)
|
|
if err != nil {
|
|
c.Log.Error(err, "Failed to create job", "model", modelInfo.ModelName, "node", nodeName)
|
|
return err
|
|
}
|
|
}
|
|
status := getModelStatusFromJobStatus(job.Status)
|
|
newStatus[statusKey] = status
|
|
processedStorageKeys[storageKey] = status
|
|
c.Log.Info("model downloading status:", "model", modelInfo.ModelName, "statusKey", statusKey,
|
|
"node", localModelNode.Name, "status", status)
|
|
}
|
|
}
|
|
|
|
// Skip update if no changes to status
|
|
if maps.Equal(localModelNode.Status.ModelStatus, newStatus) {
|
|
return nil
|
|
}
|
|
|
|
localModelNode.Status.ModelStatus = newStatus
|
|
if err := c.Status().Update(ctx, localModelNode); err != nil {
|
|
c.Log.Error(err, "Update local model cache status error", "name", localModelNode.Name)
|
|
return err
|
|
}
|
|
c.Log.Info("status updated", "name", localModelNode.Name, "num of models in status", len(localModelNode.Status.ModelStatus))
|
|
|
|
return nil
|
|
}
|
|
|
|
// Delete models that are not in the spec
|
|
// Uses hash-based folder names (storageKey) for storage deduplication
|
|
func (c *LocalModelNodeReconciler) deleteModels(localModelNode v1alpha1.LocalModelNode) error {
|
|
// 1. Scan model dir and get a list of existing folders representing downloaded models
|
|
foldersToRemove := map[string]struct{}{}
|
|
entries, err := fsHelper.getModelFolders()
|
|
if err != nil {
|
|
c.Log.Error(err, "Failed to list model folder")
|
|
return err
|
|
}
|
|
for _, entry := range entries {
|
|
// Models could only exist in sub dir
|
|
if entry.IsDir() {
|
|
foldersToRemove[entry.Name()] = struct{}{}
|
|
}
|
|
}
|
|
|
|
// 2. Compare with list of models from LocalModelNode CR using storage keys (URI hashes)
|
|
for _, localModelInfo := range localModelNode.Spec.LocalModels {
|
|
// Remove expected models from local model set using storage key
|
|
storageKey := v1alpha1.GetStorageKey(localModelInfo.SourceModelUri)
|
|
delete(foldersToRemove, storageKey)
|
|
}
|
|
// 3. Models not in LocalModelNode CR spec should be deleted
|
|
if len(foldersToRemove) != 0 {
|
|
c.Log.Info("Found model(s) to remove", "num of models", len(foldersToRemove))
|
|
for storageKey := range foldersToRemove {
|
|
c.Log.Info("Removing model folder", "storageKey", storageKey)
|
|
if err := fsHelper.removeModel(storageKey); err != nil {
|
|
c.Log.Error(err, "Failed to remove model directory", "storageKey", storageKey)
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (c *LocalModelNodeReconciler) cleanupJobs(ctx context.Context, localModelNode v1alpha1.LocalModelNode) error {
|
|
// Build a set of status keys that are in the spec
|
|
statusKeysInSpec := map[string]struct{}{}
|
|
for _, modelInfo := range localModelNode.Spec.LocalModels {
|
|
statusKeysInSpec[modelInfo.GetStatusKey()] = struct{}{}
|
|
}
|
|
|
|
for _, ns := range []string{jobNamespace} {
|
|
jobs := &batchv1.JobList{}
|
|
labelSelector := map[string]string{"node": localModelNode.Name}
|
|
if err := c.List(ctx, jobs, client.InNamespace(ns), client.MatchingLabels(labelSelector)); err != nil {
|
|
c.Log.Error(err, "Failed to list jobs", "node", localModelNode.Name, "namespace", ns)
|
|
return err
|
|
}
|
|
|
|
// Delete jobs that are not in the spec
|
|
for i := range jobs.Items {
|
|
job := jobs.Items[i]
|
|
// Reconstruct statusKey from labels
|
|
// statusKey format: "modelName" for cluster-scoped, "namespace/modelName" for namespace-scoped
|
|
modelName := job.Labels["model"]
|
|
if modelName == "" {
|
|
c.Log.Info("Job does not have model label", "job", job.Name)
|
|
continue
|
|
}
|
|
statusKey := modelName
|
|
if modelNamespace, ok := job.Labels["modelNamespace"]; ok && modelNamespace != "" {
|
|
statusKey = modelNamespace + "/" + modelName
|
|
}
|
|
if _, ok := statusKeysInSpec[statusKey]; !ok {
|
|
c.Log.Info("Deleting job", "job", job.Name, "statusKey", statusKey, "namespace", ns)
|
|
propagationPolicy := metav1.DeletePropagationBackground
|
|
if err := c.Delete(ctx, &job, &client.DeleteOptions{PropagationPolicy: &propagationPolicy}); err != nil {
|
|
c.Log.Error(err, "Failed to delete job", "job", job.Name)
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (c *LocalModelNodeReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
|
|
if req.Name != nodeName {
|
|
c.Log.Info("Skipping LocalModelNode because it is not for current node", "name", req.Name, "current node", nodeName)
|
|
return reconcile.Result{}, nil
|
|
}
|
|
|
|
c.Log.Info("Agent reconciling LocalModelNode", "name", req.Name, "node", nodeName)
|
|
|
|
// 1. Load config
|
|
isvcConfigMap, err := v1beta1.GetInferenceServiceConfigMap(ctx, c.Clientset)
|
|
if err != nil {
|
|
c.Log.Error(err, "unable to get configmap", "name", constants.InferenceServiceConfigMapName, "namespace", constants.KServeNamespace)
|
|
return reconcile.Result{}, err
|
|
}
|
|
c.IsvcConfigMap = isvcConfigMap
|
|
localModelConfig, err := v1beta1.NewLocalModelConfig(isvcConfigMap)
|
|
if err != nil {
|
|
c.Log.Error(err, "Failed to get local model config")
|
|
return reconcile.Result{}, err
|
|
}
|
|
jobNamespace = localModelConfig.JobNamespace
|
|
FSGroup = localModelConfig.FSGroup
|
|
if localModelConfig.ReconcilationFrequencyInSecs != nil {
|
|
reconcilationFreqency = time.Duration(*localModelConfig.ReconcilationFrequencyInSecs) * time.Second
|
|
}
|
|
if localModelConfig.JobTTLSecondsAfterFinished != nil {
|
|
jobTTLSecondsAfterFinished = *localModelConfig.JobTTLSecondsAfterFinished
|
|
}
|
|
|
|
storageInitializerConfig, err = v1beta1.GetStorageInitializerConfigs(isvcConfigMap)
|
|
if err != nil {
|
|
c.Log.Error(err, "Failed to get storage initializer config, using defaults")
|
|
// Don't fail, just use defaults
|
|
}
|
|
|
|
if c.CredentialBuilder == nil {
|
|
c.CredentialBuilder = credentials.NewCredentialBuilder(c.Client, c.Clientset, isvcConfigMap)
|
|
}
|
|
|
|
// 2. Init fsHelper and ensure model root folder exists and is writable
|
|
if fsHelper == nil {
|
|
fsHelper = NewFileSystemHelper(modelsRootFolder)
|
|
}
|
|
|
|
folderResult, err := c.ensureModelRootFolderExistsAndIsWritable(ctx, localModelConfig)
|
|
if err != nil || !folderResult.Continue {
|
|
if folderResult != nil {
|
|
return folderResult.Result, err
|
|
}
|
|
return ctrl.Result{}, err
|
|
}
|
|
|
|
// 3. Get LocalModelNode CR
|
|
localModelNode := v1alpha1.LocalModelNode{}
|
|
if err := c.Get(ctx, req.NamespacedName, &localModelNode); err != nil {
|
|
c.Log.Error(err, "Error getting LocalModelNode", "name", req.Name)
|
|
return reconcile.Result{}, client.IgnoreNotFound(err)
|
|
}
|
|
|
|
// 4. Cleanup jobs for models that are not in the spec
|
|
if err := c.cleanupJobs(ctx, localModelNode); err != nil {
|
|
c.Log.Error(err, "Job cleanup err")
|
|
return reconcile.Result{}, err
|
|
}
|
|
|
|
// 5. Download models not present locally
|
|
if err := c.downloadModels(ctx, &localModelNode); err != nil {
|
|
c.Log.Error(err, "Model download err")
|
|
return reconcile.Result{}, err
|
|
}
|
|
|
|
// 6. Delete models that are not in the spec
|
|
if err := c.deleteModels(localModelNode); err != nil {
|
|
c.Log.Error(err, "Model deletion err")
|
|
return reconcile.Result{}, err
|
|
}
|
|
|
|
// Requeue to check local folders periodically
|
|
return reconcile.Result{RequeueAfter: reconcilationFreqency}, nil
|
|
}
|
|
|
|
func (c *LocalModelNodeReconciler) SetupWithManager(mgr ctrl.Manager) error {
|
|
return ctrl.NewControllerManagedBy(mgr).
|
|
// Do not reconcile on status change, when a job is created and the status is updated, the next reconcile is triggered immediately and
|
|
// there is a chance that the job is not returned when we list jobs, causing the same job to be created twice.
|
|
// Keep AnnotationChangedPredicate because we use it to trigger reconciliation in the test
|
|
For(&v1alpha1.LocalModelNode{}, builder.WithPredicates(predicate.Or(predicate.GenerationChangedPredicate{}, predicate.AnnotationChangedPredicate{}))).
|
|
Owns(&batchv1.Job{}).
|
|
Complete(c)
|
|
}
|