stack_controller.go 9.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292
  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. "strings"
  19. corev1 "k8s.io/api/core/v1"
  20. apierrors "k8s.io/apimachinery/pkg/api/errors"
  21. apimeta "k8s.io/apimachinery/pkg/api/meta"
  22. "k8s.io/apimachinery/pkg/api/resource"
  23. metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
  24. "k8s.io/apimachinery/pkg/runtime"
  25. "k8s.io/utils/ptr"
  26. ctrl "sigs.k8s.io/controller-runtime"
  27. "sigs.k8s.io/controller-runtime/pkg/client"
  28. "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
  29. logf "sigs.k8s.io/controller-runtime/pkg/log"
  30. "github.com/LocoStack/loco-operator/api/v1alpha1"
  31. "github.com/LocoStack/loco-operator/internal/reconciler"
  32. "github.com/LocoStack/loco-operator/pkg/templates"
  33. )
  34. // StackReconciler reconciles a Stack object
  35. type StackReconciler struct {
  36. client.Client
  37. Scheme *runtime.Scheme
  38. }
  39. // +kubebuilder:rbac:groups=locostack.com,resources=stacks,verbs=get;list;watch;create;update;patch;delete
  40. // +kubebuilder:rbac:groups=locostack.com,resources=stacks/status,verbs=get;update;patch
  41. // +kubebuilder:rbac:groups=locostack.com,resources=stacks/finalizers,verbs=update
  42. // +kubebuilder:rbac:groups=locostack.com,resources=components,verbs=get;list;watch;create;update;patch;delete
  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. func (r *StackReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
  46. log := logf.FromContext(ctx)
  47. s := &v1alpha1.Stack{}
  48. if err := r.Get(ctx, req.NamespacedName, s); err != nil {
  49. if apierrors.IsNotFound(err) {
  50. return ctrl.Result{}, nil
  51. }
  52. return ctrl.Result{}, err
  53. }
  54. patch := client.MergeFrom(s.DeepCopy())
  55. if err := r.reconcileSharedVolumes(ctx, s); err != nil {
  56. return ctrl.Result{}, err
  57. }
  58. if err := r.reconcileComponents(ctx, s); err != nil {
  59. return ctrl.Result{}, err
  60. }
  61. s.Status.ObservedGeneration = s.Generation
  62. if err := r.Status().Patch(ctx, s, patch); err != nil {
  63. return ctrl.Result{}, client.IgnoreNotFound(err)
  64. }
  65. log.Info("Reconciled Stack", "namespace", s.Namespace, "name", s.Name)
  66. return ctrl.Result{}, nil
  67. }
  68. // reconcileSharedVolumes ensures all PVC-backed shared volumes declared in the Stack spec exist.
  69. func (r *StackReconciler) reconcileSharedVolumes(ctx context.Context, s *v1alpha1.Stack) error {
  70. failedVolumes := make([]string, 0)
  71. for _, volume := range s.Spec.SharedVolumes {
  72. claimName := fmt.Sprintf("%s-%s", s.Name, volume)
  73. pvc := &corev1.PersistentVolumeClaim{
  74. ObjectMeta: metav1.ObjectMeta{
  75. Name: claimName,
  76. Namespace: s.Namespace,
  77. },
  78. }
  79. pv := &corev1.PersistentVolume{}
  80. if err := r.Get(ctx, client.ObjectKey{Name: volume}, pv); err != nil {
  81. if apierrors.IsNotFound(err) {
  82. apimeta.SetStatusCondition(&s.Status.Conditions, metav1.Condition{
  83. Type: "SharedVolumesResolved",
  84. Status: metav1.ConditionFalse,
  85. Reason: "ReconcileFailed",
  86. Message: fmt.Sprintf("Could not find shared volume %s: %v", volume, err),
  87. ObservedGeneration: s.Generation,
  88. })
  89. return nil
  90. }
  91. return err
  92. }
  93. _, err := controllerutil.CreateOrUpdate(ctx, r.Client, pvc, func() error {
  94. if pvc.Labels == nil {
  95. pvc.Labels = map[string]string{}
  96. }
  97. maps.Copy(pvc.Labels, reconciler.ResourceLabels(s.Name, "SharedVolume", volume, ""))
  98. pvc.Spec.VolumeName = volume
  99. pvc.Spec.StorageClassName = ptr.To("")
  100. pvc.Spec.AccessModes = []corev1.PersistentVolumeAccessMode{corev1.ReadWriteMany}
  101. if pvc.Spec.Resources.Requests == nil {
  102. pvc.Spec.Resources.Requests = corev1.ResourceList{}
  103. }
  104. if _, ok := pvc.Spec.Resources.Requests[corev1.ResourceStorage]; !ok {
  105. pvc.Spec.Resources.Requests[corev1.ResourceStorage] = resource.MustParse("1Gi")
  106. }
  107. return controllerutil.SetControllerReference(s, pvc, r.Scheme)
  108. })
  109. if err != nil {
  110. failedVolumes = append(failedVolumes, volume)
  111. }
  112. }
  113. if len(failedVolumes) > 0 {
  114. apimeta.SetStatusCondition(&s.Status.Conditions, metav1.Condition{
  115. Type: "SharedVolumesResolved",
  116. Status: metav1.ConditionFalse,
  117. Reason: "ReconcileFailed",
  118. Message: fmt.Sprintf("Could not reconcile shared volume(s): %v", failedVolumes),
  119. ObservedGeneration: s.Generation,
  120. })
  121. } else {
  122. apimeta.SetStatusCondition(&s.Status.Conditions, metav1.Condition{
  123. Type: "SharedVolumesResolved",
  124. Status: metav1.ConditionTrue,
  125. Reason: "ReconcileSucceeded",
  126. Message: "All shared volumes reconciled successfully",
  127. ObservedGeneration: s.Generation,
  128. })
  129. }
  130. return nil
  131. }
  132. func (r *StackReconciler) reconcileComponents(ctx context.Context, s *v1alpha1.Stack) error {
  133. components := getComponents(s)
  134. failedComponents := make([]string, 0)
  135. errList := make([]error, 0)
  136. for _, comp := range components {
  137. resolvedTemplateName, reconciled, err := r.reconcileComponent(ctx, s, comp)
  138. setResolvedTemplateName(&s.Status, comp.name, resolvedTemplateName)
  139. if !reconciled {
  140. failedComponents = append(failedComponents, comp.name)
  141. }
  142. if err != nil {
  143. errList = append(errList, err)
  144. }
  145. }
  146. if len(failedComponents) > 0 {
  147. apimeta.SetStatusCondition(&s.Status.Conditions, metav1.Condition{
  148. Type: "ComponentsReconciled",
  149. Status: metav1.ConditionFalse,
  150. Reason: "ReconcileFailed",
  151. Message: fmt.Sprintf("%d component(s) failed to reconcile: %v", len(failedComponents), failedComponents),
  152. ObservedGeneration: s.Generation,
  153. })
  154. } else {
  155. apimeta.SetStatusCondition(&s.Status.Conditions, metav1.Condition{
  156. Type: "ComponentsReconciled",
  157. Status: metav1.ConditionTrue,
  158. Reason: "ReconcileSucceeded",
  159. Message: "All enabled components were reconciled successfully",
  160. ObservedGeneration: s.Generation,
  161. })
  162. }
  163. if len(errList) > 0 {
  164. return fmt.Errorf("failed with errors: %v", errList)
  165. }
  166. return nil
  167. }
  168. type componentConfig struct {
  169. name string
  170. defaultTmpl string
  171. template *v1alpha1.Template
  172. enabled bool
  173. }
  174. func newComponentConfig(name, defaultTmpl string, enabled bool, optional *v1alpha1.OptionalComponent) componentConfig {
  175. var tmpl *v1alpha1.Template
  176. if optional != nil {
  177. enabled = optional.Enabled
  178. tmpl = optional.Template
  179. }
  180. return componentConfig{
  181. name: name,
  182. defaultTmpl: defaultTmpl,
  183. template: tmpl,
  184. enabled: enabled,
  185. }
  186. }
  187. func getComponents(s *v1alpha1.Stack) []componentConfig {
  188. return []componentConfig{
  189. newComponentConfig("Gateway", "litellm", true, s.Spec.Gateway),
  190. newComponentConfig("VectorStore", "qdrant", true, s.Spec.VectorStore),
  191. newComponentConfig("GraphStore", "neo4j", false, s.Spec.GraphStore),
  192. newComponentConfig("Database", "postgresql", true, s.Spec.Database),
  193. newComponentConfig("Observability", "phoenix", false, s.Spec.Observability),
  194. }
  195. }
  196. func setResolvedTemplateName(status *v1alpha1.StackStatus, name string, templateName string) {
  197. switch name {
  198. case "Gateway":
  199. status.ComponentStatus.Gateway = templateName
  200. case "VectorStore":
  201. status.ComponentStatus.VectorStore = templateName
  202. case "GraphStore":
  203. status.ComponentStatus.GraphStore = templateName
  204. case "Database":
  205. status.ComponentStatus.Database = templateName
  206. case "Observability":
  207. status.ComponentStatus.Observability = templateName
  208. }
  209. }
  210. func (r *StackReconciler) reconcileComponent(ctx context.Context, s *v1alpha1.Stack, compCfg componentConfig) (string, bool, error) {
  211. compName := fmt.Sprintf("%s-%s", strings.ToLower(compCfg.name), s.Name)
  212. comp := &v1alpha1.Component{
  213. ObjectMeta: metav1.ObjectMeta{
  214. Name: compName,
  215. Namespace: s.Namespace,
  216. },
  217. }
  218. err := r.Get(ctx, client.ObjectKey{Namespace: s.Namespace, Name: compName}, comp)
  219. if !compCfg.enabled {
  220. tmpl := "disabled"
  221. if err == nil {
  222. if err := r.Delete(ctx, comp); err != nil {
  223. return tmpl, false, err
  224. }
  225. return tmpl, true, nil
  226. }
  227. if apierrors.IsNotFound(err) {
  228. return tmpl, true, nil
  229. }
  230. return tmpl, false, err
  231. }
  232. tmpl, err := templates.Manager.ResolveTemplate(compCfg.template, compCfg.defaultTmpl)
  233. if err != nil {
  234. return "unknown", false, err
  235. }
  236. _, err = controllerutil.CreateOrUpdate(ctx, r.Client, comp, func() error {
  237. if comp.Labels == nil {
  238. comp.Labels = make(map[string]string)
  239. }
  240. maps.Copy(comp.Labels, reconciler.ResourceLabels(s.Name, compCfg.name, s.Name, ""))
  241. comp.Labels["stack.locostack.com/default"] = "true"
  242. comp.Spec.Category = compCfg.name
  243. comp.Spec.Template = &tmpl
  244. comp.Spec.StackRef = &corev1.LocalObjectReference{Name: s.Name}
  245. return controllerutil.SetControllerReference(s, comp, r.Scheme)
  246. })
  247. available := apimeta.IsStatusConditionTrue(comp.Status.Conditions, "Available")
  248. return tmpl.Name, available, err
  249. }
  250. // SetupWithManager sets up the controller with the Manager.
  251. func (r *StackReconciler) SetupWithManager(mgr ctrl.Manager) error {
  252. return ctrl.NewControllerManagedBy(mgr).
  253. For(&v1alpha1.Stack{}).
  254. Owns(&v1alpha1.Component{}).
  255. Owns(&corev1.PersistentVolumeClaim{}).
  256. Named("stack").
  257. Complete(r)
  258. }