managedmodel_controller.go 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310
  1. /*
  2. Copyright 2026 LocoStack.
  3. Licensed under the Apache License, Version 2.0 (the "License");
  4. you may not use this file except in compliance with the License.
  5. You may obtain a copy of the License at
  6. http://www.apache.org/licenses/LICENSE-2.0
  7. Unless required by applicable law or agreed to in writing, software
  8. distributed under the License is distributed on an "AS IS" BASIS,
  9. WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  10. See the License for the specific language governing permissions and
  11. limitations under the License.
  12. */
  13. package controller
  14. import (
  15. "context"
  16. "fmt"
  17. "maps"
  18. batchv1 "k8s.io/api/batch/v1"
  19. corev1 "k8s.io/api/core/v1"
  20. apierrors "k8s.io/apimachinery/pkg/api/errors"
  21. apimeta "k8s.io/apimachinery/pkg/api/meta"
  22. metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
  23. "k8s.io/apimachinery/pkg/runtime"
  24. ctrl "sigs.k8s.io/controller-runtime"
  25. "sigs.k8s.io/controller-runtime/pkg/client"
  26. "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
  27. logf "sigs.k8s.io/controller-runtime/pkg/log"
  28. "github.com/LocoStack/loco-operator/api/v1alpha1"
  29. "github.com/LocoStack/loco-operator/internal/reconciler"
  30. "github.com/LocoStack/loco-operator/pkg/templates"
  31. llamacpp "github.com/LocoStack/loco-operator/pkg/templates/llama_cpp"
  32. )
  33. // ManagedModelReconciler reconciles a ManagedModel object
  34. type ManagedModelReconciler struct {
  35. client.Client
  36. Scheme *runtime.Scheme
  37. }
  38. // +kubebuilder:rbac:groups=locostack.com,resources=managedmodels,verbs=get;list;watch;create;update;patch;delete
  39. // +kubebuilder:rbac:groups=locostack.com,resources=managedmodels/status,verbs=get;update;patch
  40. // +kubebuilder:rbac:groups=locostack.com,resources=managedmodels/finalizers,verbs=update
  41. // +kubebuilder:rbac:groups=locostack.com,resources=components,verbs=get;list;watch;create;update;patch;delete
  42. // +kubebuilder:rbac:groups=locostack.com,resources=stacks,verbs=get;list;watch
  43. // +kubebuilder:rbac:groups=batch,resources=jobs,verbs=get;list;watch;create;update;patch;delete
  44. func (r *ManagedModelReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
  45. log := logf.FromContext(ctx)
  46. mm := &v1alpha1.ManagedModel{}
  47. if err := r.Get(ctx, req.NamespacedName, mm); err != nil {
  48. if apierrors.IsNotFound(err) {
  49. return ctrl.Result{}, nil
  50. }
  51. log.Error(err, "Failed to get ManagedModel", "namespace", req.NamespacedName.Namespace, "name", req.NamespacedName.Name)
  52. return ctrl.Result{}, err
  53. }
  54. patch := client.MergeFrom(mm.DeepCopy())
  55. stack := &v1alpha1.Stack{}
  56. if err := r.Get(ctx, client.ObjectKey{Namespace: mm.Namespace, Name: mm.Spec.StackRef.Name}, stack); err != nil {
  57. if apierrors.IsNotFound(err) {
  58. return ctrl.Result{}, nil
  59. }
  60. log.Error(err, "Failed to get Stack for ManagedModel", "namespace", mm.Namespace, "name", mm.Name, "stack", mm.Spec.StackRef.Name)
  61. return ctrl.Result{}, err
  62. }
  63. chatTemplateConfigMapName, err := r.reconcileChatTemplate(ctx, stack, mm)
  64. if err != nil {
  65. return ctrl.Result{}, err
  66. }
  67. pvcName, weightsReady, weightsFailed, err := r.reconcileWeights(ctx, stack, mm)
  68. if err != nil {
  69. return ctrl.Result{}, err
  70. }
  71. if weightsFailed {
  72. apimeta.SetStatusCondition(&mm.Status.Conditions, metav1.Condition{
  73. Type: "Available",
  74. Status: metav1.ConditionFalse,
  75. Reason: "WeightsFailed",
  76. Message: "ManagedModel weights are not available",
  77. ObservedGeneration: mm.Generation,
  78. })
  79. } else if !weightsReady {
  80. apimeta.SetStatusCondition(&mm.Status.Conditions, metav1.Condition{
  81. Type: "Available",
  82. Status: metav1.ConditionFalse,
  83. Reason: "WeightsNotReady",
  84. Message: "ManagedModel weights are not ready",
  85. ObservedGeneration: mm.Generation,
  86. })
  87. } else {
  88. componentAvailable, err := r.reconcileComponent(ctx, mm, pvcName, chatTemplateConfigMapName)
  89. if err != nil {
  90. return ctrl.Result{}, err
  91. }
  92. gatewayBound, err := r.reconcileGatewayStatus(ctx, mm)
  93. if err != nil {
  94. log.Error(err, "Failed to reconcile gateway status for ManagedModel", "namespace", req.NamespacedName.Namespace, "name", req.NamespacedName.Name)
  95. return ctrl.Result{}, err
  96. }
  97. if !componentAvailable {
  98. apimeta.SetStatusCondition(&mm.Status.Conditions, metav1.Condition{
  99. Type: "Available",
  100. Status: metav1.ConditionFalse,
  101. Reason: "ModelUnavailable",
  102. Message: "ManagedModel is not available",
  103. ObservedGeneration: mm.Generation,
  104. })
  105. } else if !gatewayBound {
  106. apimeta.SetStatusCondition(&mm.Status.Conditions, metav1.Condition{
  107. Type: "Available",
  108. Status: metav1.ConditionFalse,
  109. Reason: "GatewayUnbound",
  110. Message: "ManagedModel is not bound to a gateway",
  111. ObservedGeneration: mm.Generation,
  112. })
  113. } else {
  114. apimeta.SetStatusCondition(&mm.Status.Conditions, metav1.Condition{
  115. Type: "Available",
  116. Status: metav1.ConditionTrue,
  117. Reason: "ModelAvailable",
  118. Message: "ManagedModel is available",
  119. ObservedGeneration: mm.Generation,
  120. })
  121. }
  122. }
  123. mm.Status.ObservedGeneration = mm.Generation
  124. if err := r.Status().Patch(ctx, mm, patch); err != nil {
  125. return ctrl.Result{}, client.IgnoreNotFound(err)
  126. }
  127. log.Info("Reconciled ManagedModel", "namespace", req.NamespacedName.Namespace, "name", req.NamespacedName.Name)
  128. return ctrl.Result{}, nil
  129. }
  130. func (r *ManagedModelReconciler) reconcileChatTemplate(ctx context.Context, stack *v1alpha1.Stack, mm *v1alpha1.ManagedModel) (string, error) {
  131. if mm.Spec.Category != "language" {
  132. return "undefined", nil
  133. }
  134. cmName := fmt.Sprintf("%s-chat-template", mm.Name)
  135. if mm.Spec.RuntimeInferenceParams == nil || mm.Spec.RuntimeInferenceParams.ChatTemplate == "" {
  136. return cmName, nil
  137. }
  138. cm := &corev1.ConfigMap{
  139. ObjectMeta: metav1.ObjectMeta{
  140. Name: cmName,
  141. Namespace: mm.Namespace,
  142. },
  143. }
  144. err := r.Get(ctx, client.ObjectKey{Namespace: mm.Namespace, Name: cm.Name}, cm)
  145. if err != nil && !apierrors.IsNotFound(err) {
  146. return "", err
  147. }
  148. _, err = controllerutil.CreateOrUpdate(ctx, r.Client, cm, func() error {
  149. if cm.CreationTimestamp.IsZero() {
  150. cm.Labels = reconciler.ResourceLabels(stack.Name, "Artifact", cmName, "")
  151. chatTemplate := ""
  152. if mm.Spec.RuntimeInferenceParams != nil {
  153. chatTemplate = mm.Spec.RuntimeInferenceParams.ChatTemplate
  154. }
  155. cm.Data = map[string]string{
  156. "template": chatTemplate,
  157. }
  158. }
  159. return controllerutil.SetControllerReference(mm, cm, r.Scheme)
  160. })
  161. if err != nil {
  162. return "", fmt.Errorf("Failed to create or update chat template: %w", err)
  163. }
  164. return cmName, nil
  165. }
  166. func (r *ManagedModelReconciler) reconcileWeights(ctx context.Context, stack *v1alpha1.Stack, mm *v1alpha1.ManagedModel) (string, bool, bool, error) {
  167. artifactsReconciler := reconciler.NewArtifactReconciler(r.Client, r.Scheme, stack, "model", mm, &mm.Spec.Weights)
  168. pvc, completed, failed, err := artifactsReconciler.ReconcileArtifact(ctx)
  169. pvcName := ""
  170. if pvc != nil {
  171. pvcName = pvc.Name
  172. }
  173. return pvcName, completed, failed, err
  174. }
  175. func (r *ManagedModelReconciler) reconcileComponent(ctx context.Context, mm *v1alpha1.ManagedModel, pvcName string, chatTemplateConfigMapName string) (bool, error) {
  176. compName := fmt.Sprintf("model-%s", mm.Name)
  177. comp := &v1alpha1.Component{
  178. ObjectMeta: metav1.ObjectMeta{
  179. Name: compName,
  180. Namespace: mm.Namespace,
  181. },
  182. }
  183. err := r.Get(ctx, client.ObjectKey{Namespace: mm.Namespace, Name: compName}, comp)
  184. tmpl, err := templates.Manager.ResolveTemplate(mm.Spec.Template, "")
  185. if err != nil {
  186. apimeta.SetStatusCondition(&mm.Status.Conditions, metav1.Condition{
  187. Type: "ModelRuntimeResolved",
  188. Status: metav1.ConditionFalse,
  189. Reason: "TemplateNotResolved",
  190. Message: fmt.Sprintf("Failed to reconcile template for ManagedModel: %v", err),
  191. ObservedGeneration: mm.Generation,
  192. })
  193. return false, nil
  194. }
  195. apimeta.SetStatusCondition(&mm.Status.Conditions, metav1.Condition{
  196. Type: "ModelRuntimeResolved",
  197. Status: metav1.ConditionTrue,
  198. Reason: "TemplateResolved",
  199. Message: fmt.Sprintf("Successfully resolved template for ManagedModel: %v", tmpl.Name),
  200. ObservedGeneration: mm.Generation,
  201. })
  202. _, err = controllerutil.CreateOrUpdate(ctx, r.Client, comp, func() error {
  203. if comp.Labels == nil {
  204. comp.Labels = make(map[string]string)
  205. }
  206. stackName := ""
  207. if mm.Spec.StackRef != nil {
  208. stackName = mm.Spec.StackRef.Name
  209. }
  210. maps.Copy(comp.Labels, reconciler.ResourceLabels(stackName, "Model", mm.Name, ""))
  211. comp.Spec.Category = "Model"
  212. comp.Spec.Template = &tmpl
  213. variables := map[string]string{
  214. "spec.chatTemplateConfigMapName": chatTemplateConfigMapName,
  215. }
  216. params := mm.Spec.RuntimeInferenceParams
  217. if params != nil {
  218. if params.ChatTemplate != "" {
  219. variables["spec.runtimeInferenceParameters.chatTemplateFilePath"] = fmt.Sprintf("%s/template", llamacpp.LLAMA_CPP_TEMPLATE_PATH)
  220. }
  221. if params.ContextWindow != nil {
  222. variables["spec.runtimeInferenceParameters.contextWindow"] = fmt.Sprintf("%d", *params.ContextWindow)
  223. }
  224. if params.Temperature != "" {
  225. variables["spec.runtimeInferenceParameters.temperature"] = params.Temperature
  226. }
  227. if params.TopK != nil {
  228. variables["spec.runtimeInferenceParameters.topK"] = fmt.Sprintf("%d", *params.TopK)
  229. }
  230. if params.TopP != "" {
  231. variables["spec.runtimeInferenceParameters.topP"] = params.TopP
  232. }
  233. }
  234. if mm.Spec.Weights.HuggingFace != nil {
  235. variables["spec.modelName"] = mm.Spec.Weights.HuggingFace.FileName
  236. variables["spec.pvcName"] = pvcName
  237. }
  238. comp.Spec.Variables = variables
  239. if mm.Spec.StackRef != nil {
  240. comp.Spec.StackRef = &corev1.LocalObjectReference{Name: mm.Spec.StackRef.Name}
  241. }
  242. return controllerutil.SetControllerReference(mm, comp, r.Scheme)
  243. })
  244. available := apimeta.IsStatusConditionTrue(comp.Status.Conditions, "Available")
  245. if available {
  246. mm.Status.Endpoint = comp.Status.Endpoint
  247. apimeta.SetStatusCondition(&mm.Status.Conditions, metav1.Condition{
  248. Type: "Ready",
  249. Status: metav1.ConditionTrue,
  250. Reason: "ComponentAvailable",
  251. Message: "Component is available",
  252. ObservedGeneration: mm.Generation,
  253. })
  254. } else {
  255. apimeta.SetStatusCondition(&mm.Status.Conditions, metav1.Condition{
  256. Type: "Ready",
  257. Status: metav1.ConditionFalse,
  258. Reason: "ComponentUnavailable",
  259. Message: "Component is not available",
  260. ObservedGeneration: mm.Generation,
  261. })
  262. }
  263. return available, err
  264. }
  265. func (r *ManagedModelReconciler) reconcileGatewayStatus(ctx context.Context, mm *v1alpha1.ManagedModel) (bool, error) {
  266. condition, err := reconciler.ReconcileGatewayStatus(ctx, r.Client, r.Scheme, mm.Spec.StackRef.Name, mm)
  267. if err != nil {
  268. return false, err
  269. }
  270. apimeta.SetStatusCondition(&mm.Status.Conditions, *condition)
  271. return condition.Status == metav1.ConditionTrue, nil
  272. }
  273. // SetupWithManager sets up the controller with the Manager.
  274. func (r *ManagedModelReconciler) SetupWithManager(mgr ctrl.Manager) error {
  275. return ctrl.NewControllerManagedBy(mgr).
  276. For(&v1alpha1.ManagedModel{}).
  277. Owns(&batchv1.Job{}).
  278. Owns(&corev1.PersistentVolumeClaim{}).
  279. Owns(&v1alpha1.Component{}).
  280. Watches(&v1alpha1.Component{}, ComponentEventHandler("ManagedModel")).
  281. Named("managedmodel").
  282. Complete(r)
  283. }