component_controller.go 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259
  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. appsv1 "k8s.io/api/apps/v1"
  18. corev1 "k8s.io/api/core/v1"
  19. apierrors "k8s.io/apimachinery/pkg/api/errors"
  20. apimeta "k8s.io/apimachinery/pkg/api/meta"
  21. metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
  22. "k8s.io/apimachinery/pkg/runtime"
  23. ctrl "sigs.k8s.io/controller-runtime"
  24. "sigs.k8s.io/controller-runtime/pkg/client"
  25. "sigs.k8s.io/controller-runtime/pkg/client/apiutil"
  26. "sigs.k8s.io/controller-runtime/pkg/handler"
  27. logf "sigs.k8s.io/controller-runtime/pkg/log"
  28. "sigs.k8s.io/controller-runtime/pkg/reconcile"
  29. "github.com/LocoStack/loco-operator/api/v1alpha1"
  30. "github.com/LocoStack/loco-operator/internal/reconciler"
  31. "github.com/LocoStack/loco-operator/pkg/templates"
  32. )
  33. // ComponentReconciler reconciles a Component object
  34. type ComponentReconciler struct {
  35. client.Client
  36. Scheme *runtime.Scheme
  37. }
  38. // +kubebuilder:rbac:groups=locostack.com,resources=components,verbs=get;list;watch;create;update;patch;delete
  39. // +kubebuilder:rbac:groups=locostack.com,resources=components/status,verbs=get;update;patch
  40. // +kubebuilder:rbac:groups=locostack.com,resources=components/finalizers,verbs=update
  41. // +kubebuilder:rbac:groups=locostack.com,resources=*,verbs=get;list;watch
  42. // +kubebuilder:rbac:groups="",resources=configmaps,verbs=get;list;watch;create;update;patch
  43. // +kubebuilder:rbac:groups="",resources=persistentvolumes,verbs=get;list;watch;create;update;patch
  44. // +kubebuilder:rbac:groups="",resources=persistentvolumeclaims,verbs=get;list;watch;create;update;patch
  45. // +kubebuilder:rbac:groups="",resources=secrets,verbs=get;list;watch;create;update;patch
  46. // +kubebuilder:rbac:groups="",resources=services,verbs=get;list;watch;create;update;patch
  47. // +kubebuilder:rbac:groups="apps",resources=deployments,verbs=get;list;watch;create;update;patch
  48. func (r *ComponentReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
  49. log := logf.FromContext(ctx)
  50. comp := &v1alpha1.Component{}
  51. if err := r.Get(ctx, req.NamespacedName, comp); err != nil {
  52. if apierrors.IsNotFound(err) {
  53. return ctrl.Result{}, nil
  54. }
  55. log.Error(err, "Failed to get Component", "namespace", req.NamespacedName.Namespace, "name", req.NamespacedName.Name)
  56. return ctrl.Result{}, err
  57. }
  58. patch := client.MergeFrom(comp.DeepCopy())
  59. tmpl, err := templates.Manager.ResolveTemplate(comp.Spec.Template, "")
  60. if err != nil {
  61. apimeta.SetStatusCondition(&comp.Status.Conditions, metav1.Condition{
  62. Type: "Reconciled",
  63. Status: metav1.ConditionFalse,
  64. Reason: "TemplateResolutionFailed",
  65. Message: fmt.Sprintf("Could not resolve template: %v", err),
  66. ObservedGeneration: comp.Generation,
  67. })
  68. log.Error(err, "Failed to resolve template for Component", "namespace", req.NamespacedName.Namespace, "name", req.NamespacedName.Name)
  69. _ = r.Status().Patch(ctx, comp, patch)
  70. return ctrl.Result{}, nil
  71. }
  72. stack := &v1alpha1.Stack{}
  73. if comp.Spec.StackRef == nil || comp.Spec.StackRef.Name == "" {
  74. stack = nil
  75. } else if err := r.Get(ctx, client.ObjectKey{Namespace: comp.Namespace, Name: comp.Spec.StackRef.Name}, stack); err != nil {
  76. stack = nil
  77. }
  78. componentReconciler := r.getComponentReconciler(comp, stack)
  79. dependencies, err := componentReconciler.ReconcileComponent(ctx, &tmpl, make(map[string]string))
  80. if err != nil {
  81. apimeta.SetStatusCondition(&comp.Status.Conditions, metav1.Condition{
  82. Type: "Reconciled",
  83. Status: metav1.ConditionFalse,
  84. Reason: "ReconcileFailed",
  85. Message: fmt.Sprintf("Could not reconcile component: %v", err),
  86. ObservedGeneration: comp.Generation,
  87. })
  88. log.Error(err, "Failed to reconcile Component", "namespace", req.NamespacedName.Namespace, "name", req.NamespacedName.Name)
  89. _ = r.Status().Patch(ctx, comp, patch)
  90. return ctrl.Result{}, err
  91. }
  92. comp.Status.Endpoint = fmt.Sprintf("http://%s.%s.svc.cluster.local:%d", componentReconciler.ResourceName(""), comp.Namespace, tmpl.Spec.Runtime.Port)
  93. comp.Status.ObservedGeneration = comp.Generation
  94. // rebuilt from scratch on every reconcile, including when there are no dependencies
  95. // left: dependencies that are gone have to drop out of the status too, since this is
  96. // what tells them whether the Component is still serving their current generation
  97. comp.Status.Dependencies = nil
  98. for _, dep := range dependencies {
  99. gvk, err := apiutil.GVKForObject(dep, r.Scheme)
  100. if err != nil {
  101. log.Error(err, "Failed to get GVK for dependency", "dependency", dep)
  102. continue
  103. }
  104. if gvk.Group != v1alpha1.GroupVersion.Group {
  105. continue
  106. }
  107. kind := gvk.Kind
  108. name := dep.GetName()
  109. generation := dep.GetGeneration()
  110. if comp.Status.Dependencies == nil {
  111. comp.Status.Dependencies = make(map[string]map[string]int64)
  112. }
  113. if _, exists := comp.Status.Dependencies[kind]; !exists {
  114. comp.Status.Dependencies[kind] = make(map[string]int64)
  115. }
  116. comp.Status.Dependencies[kind][name] = generation
  117. }
  118. apimeta.SetStatusCondition(&comp.Status.Conditions, metav1.Condition{
  119. Type: "Reconciled",
  120. Status: metav1.ConditionTrue,
  121. Reason: "ReconcileSucceeded",
  122. Message: "Component reconciled",
  123. ObservedGeneration: comp.Generation,
  124. })
  125. if available := componentReconciler.ComponentAvailable(ctx); !available {
  126. apimeta.SetStatusCondition(&comp.Status.Conditions, metav1.Condition{
  127. Type: "Available",
  128. Status: metav1.ConditionFalse,
  129. Reason: "ServiceUnavailable",
  130. Message: "Component is not available",
  131. ObservedGeneration: comp.Generation,
  132. })
  133. } else {
  134. apimeta.SetStatusCondition(&comp.Status.Conditions, metav1.Condition{
  135. Type: "Available",
  136. Status: metav1.ConditionTrue,
  137. Reason: "ComponentReady",
  138. Message: "Component is available",
  139. ObservedGeneration: comp.Generation,
  140. })
  141. }
  142. if err := r.Status().Patch(ctx, comp, patch); err != nil {
  143. return ctrl.Result{}, client.IgnoreNotFound(err)
  144. }
  145. log.Info("Reconciled Component", "namespace", comp.Namespace, "name", comp.Name)
  146. return ctrl.Result{}, nil
  147. }
  148. func (r *ComponentReconciler) getComponentReconciler(comp *v1alpha1.Component, stack *v1alpha1.Stack) reconciler.ComponentReconciler {
  149. switch comp.Spec.Template.Name {
  150. case "litellm":
  151. return reconciler.NewLiteLLMReconciler(r.Client, r.Scheme, stack, comp)
  152. case "qdrant":
  153. return reconciler.NewQdrantReconciler(r.Client, r.Scheme, stack, comp)
  154. case "neo4j":
  155. return reconciler.NewNeo4jReconciler(r.Client, r.Scheme, stack, comp)
  156. case "postgresql":
  157. return reconciler.NewPostgreSQLReconciler(r.Client, r.Scheme, stack, comp)
  158. case "phoenix":
  159. return reconciler.NewPhoenixReconciler(r.Client, r.Scheme, stack, comp)
  160. case "llama.cpp":
  161. return reconciler.NewLlamaCPPReconciler(r.Client, r.Scheme, stack, comp)
  162. case "loco-context":
  163. return reconciler.NewLocoContextReconciler(r.Client, r.Scheme, stack, comp)
  164. case "loco-agents":
  165. return reconciler.NewLocoAgentsReconciler(r.Client, r.Scheme, stack, comp)
  166. case "opencode":
  167. return reconciler.NewLocoOpenCodeAgentReconciler(r.Client, r.Scheme, stack, comp)
  168. default:
  169. return reconciler.NewDefaultComponentReconciler(r.Client, r.Scheme, stack, comp)
  170. }
  171. }
  172. // SetupWithManager sets up the controller with the Manager.
  173. func (r *ComponentReconciler) SetupWithManager(mgr ctrl.Manager) error {
  174. c := mgr.GetClient()
  175. builder := ctrl.NewControllerManagedBy(mgr).
  176. For(&v1alpha1.Component{}).
  177. Owns(&corev1.ConfigMap{}).
  178. Owns(&corev1.Secret{}).
  179. Owns(&corev1.PersistentVolumeClaim{}).
  180. Owns(&corev1.Service{}).
  181. Owns(&appsv1.Deployment{}).
  182. // a Component renders its config from the objects it depends on, so every one of
  183. // them has to be able to trigger a re-render - and with it a rollout
  184. Watches(&v1alpha1.Component{}, handler.EnqueueRequestsFromMapFunc(componentDependencyMapFunc(c))).
  185. Watches(&corev1.ConfigMap{}, SkillConfigMapEventHandler(c)).
  186. Watches(&corev1.Secret{}, AuthSecretEventHandler(c))
  187. for _, dep := range componentDependencyKinds {
  188. builder = builder.Watches(dep.object, handler.EnqueueRequestsFromMapFunc(dependencyMapFunc(c, dep.kind)))
  189. }
  190. return builder.
  191. Named("component").
  192. Complete(r)
  193. }
  194. var componentDependencyKinds = []struct {
  195. kind string
  196. object client.Object
  197. }{
  198. {"ExternalModel", &v1alpha1.ExternalModel{}},
  199. {"ManagedModel", &v1alpha1.ManagedModel{}},
  200. {"ExternalTool", &v1alpha1.ExternalTool{}},
  201. {"ManagedTool", &v1alpha1.ManagedTool{}},
  202. {"Skill", &v1alpha1.Skill{}},
  203. {"ExternalKnowledgeBase", &v1alpha1.ExternalKnowledgeBase{}},
  204. {"ManagedKnowledgeBase", &v1alpha1.ManagedKnowledgeBase{}},
  205. {"Agent", &v1alpha1.Agent{}},
  206. }
  207. func componentDependencyMapFunc(c client.Client) handler.MapFunc {
  208. return func(ctx context.Context, obj client.Object) []reconcile.Request {
  209. comp, ok := obj.(*v1alpha1.Component)
  210. if !ok {
  211. return nil
  212. }
  213. stackName := ""
  214. if comp.Spec.StackRef != nil {
  215. stackName = comp.Spec.StackRef.Name
  216. }
  217. stackWide := stackName != "" && stackComponentCategories[comp.Spec.Category]
  218. return dependentComponentRequests(ctx, c, comp.GetNamespace(), func(other *v1alpha1.Component) bool {
  219. if other.GetName() == comp.GetName() {
  220. // the Component's own events already reach the controller through For()
  221. return false
  222. }
  223. if componentDependsOn(other, "Component", comp.GetName(), comp.GetNamespace()) {
  224. return true
  225. }
  226. return stackWide && other.Spec.StackRef != nil && other.Spec.StackRef.Name == stackName
  227. })
  228. }
  229. }
  230. func dependencyMapFunc(c client.Client, kind string) handler.MapFunc {
  231. return func(ctx context.Context, obj client.Object) []reconcile.Request {
  232. return dependentComponentRequests(ctx, c, obj.GetNamespace(), func(comp *v1alpha1.Component) bool {
  233. return componentDependsOn(comp, kind, obj.GetName(), obj.GetNamespace())
  234. })
  235. }
  236. }