/* Copyright 2026 LocoStack. 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" "fmt" "maps" "strings" corev1 "k8s.io/api/core/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" apimeta "k8s.io/apimachinery/pkg/api/meta" "k8s.io/apimachinery/pkg/api/resource" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/types" "k8s.io/utils/ptr" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" "sigs.k8s.io/controller-runtime/pkg/handler" logf "sigs.k8s.io/controller-runtime/pkg/log" "sigs.k8s.io/controller-runtime/pkg/reconcile" "github.com/LocoStack/loco-operator/api/v1alpha1" "github.com/LocoStack/loco-operator/internal/reconciler" "github.com/LocoStack/loco-operator/pkg/templates" ) // StackReconciler reconciles a Stack object type StackReconciler struct { client.Client Scheme *runtime.Scheme } // +kubebuilder:rbac:groups=locostack.com,resources=stacks,verbs=get;list;watch;create;update;patch;delete // +kubebuilder:rbac:groups=locostack.com,resources=stacks/status,verbs=get;update;patch // +kubebuilder:rbac:groups=locostack.com,resources=stacks/finalizers,verbs=update // +kubebuilder:rbac:groups=locostack.com,resources=components,verbs=get;list;watch;create;update;patch;delete // +kubebuilder:rbac:groups="",resources=persistentvolumes,verbs=get;list;watch;create;update;patch // +kubebuilder:rbac:groups="",resources=persistentvolumeclaims,verbs=get;list;watch;create;update;patch func (r *StackReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { log := logf.FromContext(ctx) s := &v1alpha1.Stack{} if err := r.Get(ctx, req.NamespacedName, s); err != nil { if apierrors.IsNotFound(err) { return ctrl.Result{}, nil } return ctrl.Result{}, err } patch := client.MergeFrom(s.DeepCopy()) if err := r.reconcileSharedVolumes(ctx, s); err != nil { return ctrl.Result{}, err } if err := r.reconcileComponents(ctx, s); err != nil { return ctrl.Result{}, err } if err := r.reconcileGateways(ctx, s); err != nil { return ctrl.Result{}, err } s.Status.ObservedGeneration = s.Generation if err := r.Status().Patch(ctx, s, patch); err != nil { return ctrl.Result{}, client.IgnoreNotFound(err) } log.Info("Reconciled Stack", "namespace", s.Namespace, "name", s.Name) return ctrl.Result{}, nil } // reconcileSharedVolumes ensures all PVC-backed shared volumes declared in the Stack spec exist. func (r *StackReconciler) reconcileSharedVolumes(ctx context.Context, s *v1alpha1.Stack) error { failedVolumes := make([]string, 0) for _, volume := range s.Spec.SharedVolumes { claimName := fmt.Sprintf("%s-%s", s.Name, volume) pvc := &corev1.PersistentVolumeClaim{ ObjectMeta: metav1.ObjectMeta{ Name: claimName, Namespace: s.Namespace, }, } pv := &corev1.PersistentVolume{} if err := r.Get(ctx, client.ObjectKey{Name: volume}, pv); err != nil { if apierrors.IsNotFound(err) { apimeta.SetStatusCondition(&s.Status.Conditions, metav1.Condition{ Type: "SharedVolumesResolved", Status: metav1.ConditionFalse, Reason: "ReconcileFailed", Message: fmt.Sprintf("Could not find shared volume %s: %v", volume, err), ObservedGeneration: s.Generation, }) return nil } return err } _, err := controllerutil.CreateOrUpdate(ctx, r.Client, pvc, func() error { if pvc.Labels == nil { pvc.Labels = map[string]string{} } maps.Copy(pvc.Labels, reconciler.ResourceLabels(s.Name, "SharedVolume", volume, "")) pvc.Spec.VolumeName = volume pvc.Spec.StorageClassName = ptr.To("") pvc.Spec.AccessModes = []corev1.PersistentVolumeAccessMode{corev1.ReadWriteMany} if pvc.Spec.Resources.Requests == nil { pvc.Spec.Resources.Requests = corev1.ResourceList{} } if _, ok := pvc.Spec.Resources.Requests[corev1.ResourceStorage]; !ok { pvc.Spec.Resources.Requests[corev1.ResourceStorage] = resource.MustParse("1Gi") } return controllerutil.SetControllerReference(s, pvc, r.Scheme) }) if err != nil { failedVolumes = append(failedVolumes, volume) } } if len(failedVolumes) > 0 { apimeta.SetStatusCondition(&s.Status.Conditions, metav1.Condition{ Type: "SharedVolumesResolved", Status: metav1.ConditionFalse, Reason: "ReconcileFailed", Message: fmt.Sprintf("Could not reconcile shared volume(s): %v", failedVolumes), ObservedGeneration: s.Generation, }) } else { apimeta.SetStatusCondition(&s.Status.Conditions, metav1.Condition{ Type: "SharedVolumesResolved", Status: metav1.ConditionTrue, Reason: "ReconcileSucceeded", Message: "All shared volumes reconciled successfully", ObservedGeneration: s.Generation, }) } return nil } func (r *StackReconciler) reconcileComponents(ctx context.Context, s *v1alpha1.Stack) error { components := getComponents(s) failedComponents := make([]string, 0) errList := make([]error, 0) for _, comp := range components { resolvedTemplateName, reconciled, err := r.reconcileComponent(ctx, s, comp) setResolvedTemplateName(&s.Status, comp.name, resolvedTemplateName) if !reconciled { failedComponents = append(failedComponents, comp.name) } if err != nil { errList = append(errList, err) } } if len(failedComponents) > 0 { apimeta.SetStatusCondition(&s.Status.Conditions, metav1.Condition{ Type: "ComponentsReconciled", Status: metav1.ConditionFalse, Reason: "ReconcileFailed", Message: fmt.Sprintf("%d component(s) failed to reconcile: %v", len(failedComponents), failedComponents), ObservedGeneration: s.Generation, }) } else { apimeta.SetStatusCondition(&s.Status.Conditions, metav1.Condition{ Type: "ComponentsReconciled", Status: metav1.ConditionTrue, Reason: "ReconcileSucceeded", Message: "All enabled components were reconciled successfully", ObservedGeneration: s.Generation, }) } if len(errList) > 0 { return fmt.Errorf("failed with errors: %v", errList) } return nil } type componentConfig struct { name string defaultTmpl string template *v1alpha1.Template enabled bool } func newComponentConfig(name, defaultTmpl string, enabled bool, optional *v1alpha1.OptionalComponent) componentConfig { var tmpl *v1alpha1.Template if optional != nil { enabled = optional.Enabled tmpl = optional.Template } return componentConfig{ name: name, defaultTmpl: defaultTmpl, template: tmpl, enabled: enabled, } } func getComponents(s *v1alpha1.Stack) []componentConfig { return []componentConfig{ newComponentConfig("Gateway", "litellm", true, s.Spec.Gateway), newComponentConfig("VectorStore", "qdrant", true, s.Spec.VectorStore), newComponentConfig("GraphStore", "neo4j", false, s.Spec.GraphStore), newComponentConfig("Database", "postgresql", true, s.Spec.Database), newComponentConfig("Observability", "phoenix", false, s.Spec.Observability), } } func setResolvedTemplateName(status *v1alpha1.StackStatus, name string, templateName string) { switch name { case "Gateway": status.ComponentStatus.Gateway = templateName case "VectorStore": status.ComponentStatus.VectorStore = templateName case "GraphStore": status.ComponentStatus.GraphStore = templateName case "Database": status.ComponentStatus.Database = templateName case "Observability": status.ComponentStatus.Observability = templateName } } func (r *StackReconciler) reconcileComponent(ctx context.Context, s *v1alpha1.Stack, compCfg componentConfig) (string, bool, error) { compName := fmt.Sprintf("%s-%s", strings.ToLower(compCfg.name), s.Name) comp := &v1alpha1.Component{ ObjectMeta: metav1.ObjectMeta{ Name: compName, Namespace: s.Namespace, }, } err := r.Get(ctx, client.ObjectKey{Namespace: s.Namespace, Name: compName}, comp) if !compCfg.enabled { tmpl := "disabled" if err == nil { if err := r.Delete(ctx, comp); err != nil { return tmpl, false, err } return tmpl, true, nil } if apierrors.IsNotFound(err) { return tmpl, true, nil } return tmpl, false, err } tmpl, err := templates.Manager.ResolveTemplate(compCfg.template, compCfg.defaultTmpl) if err != nil { return "unknown", false, err } _, err = controllerutil.CreateOrUpdate(ctx, r.Client, comp, func() error { if comp.Labels == nil { comp.Labels = make(map[string]string) } maps.Copy(comp.Labels, reconciler.ResourceLabels(s.Name, compCfg.name, s.Name, "")) comp.Labels["stack.locostack.com/default"] = "true" comp.Spec.Category = compCfg.name comp.Spec.Template = &tmpl comp.Spec.StackRef = &corev1.LocalObjectReference{Name: s.Name} return controllerutil.SetControllerReference(s, comp, r.Scheme) }) available := apimeta.IsStatusConditionTrue(comp.Status.Conditions, "Available") return tmpl.Name, available, err } func (r *StackReconciler) reconcileGateways(ctx context.Context, s *v1alpha1.Stack) error { log := logf.FromContext(ctx) gwList := &v1alpha1.ComponentList{} err := r.List(ctx, gwList, client.InNamespace(s.Namespace), client.MatchingLabels{"locostack.com/stack": s.Name, "locostack.com/component": "Gateway"}) if err != nil { return fmt.Errorf("failed to list gateway components: %v", err) } if len(gwList.Items) == 0 { log.Info("No gateways found for stack", "namespace", s.Namespace, "stack", s.Name) return nil } dependencies := make([]corev1.ObjectReference, 0) emList := &v1alpha1.ExternalModelList{} if err := r.List(ctx, emList, client.InNamespace(s.Namespace)); err != nil { return fmt.Errorf("Failed to list external models: %v", err) } for _, em := range emList.Items { if em.Spec.StackRef.Name == s.Name { dependencies = append(dependencies, corev1.ObjectReference{ Kind: "ExternalModel", Name: em.Name, Namespace: em.Namespace, }) } } mmList := &v1alpha1.ManagedModelList{} if err := r.List(ctx, mmList, client.InNamespace(s.Namespace)); err != nil { return fmt.Errorf("Failed to list managed models: %v", err) } for _, em := range mmList.Items { if em.Spec.StackRef.Name == s.Name { dependencies = append(dependencies, corev1.ObjectReference{ Kind: "ManagedModel", Name: em.Name, Namespace: em.Namespace, }) } } etList := &v1alpha1.ExternalToolList{} if err := r.List(ctx, etList, client.InNamespace(s.Namespace)); err != nil { return fmt.Errorf("Failed to list external tools: %v", err) } for _, em := range etList.Items { if em.Spec.StackRef.Name == s.Name { dependencies = append(dependencies, corev1.ObjectReference{ Kind: "ExternalTool", Name: em.Name, Namespace: em.Namespace, }) } } mtList := &v1alpha1.ManagedToolList{} if err := r.List(ctx, mtList, client.InNamespace(s.Namespace)); err != nil { return fmt.Errorf("Failed to list managed tools: %v", err) } for _, em := range mtList.Items { if em.Spec.StackRef.Name == s.Name { dependencies = append(dependencies, corev1.ObjectReference{ Kind: "ManagedTool", Name: em.Name, Namespace: em.Namespace, }) } } if len(dependencies) == 0 { return nil } for _, gw := range gwList.Items { gw.Spec.Dependencies = dependencies if err := r.Update(ctx, &gw); err != nil { return fmt.Errorf("Failed to update gateway %s with dependencies: %v", gw.Name, err) } } return nil } // SetupWithManager sets up the controller with the Manager. func (r *StackReconciler) SetupWithManager(mgr ctrl.Manager) error { mapToStack := func(ctx context.Context, obj client.Object) []reconcile.Request { var stackName string switch typed := obj.(type) { case *v1alpha1.ExternalModel: stackName = typed.Spec.StackRef.Name case *v1alpha1.ManagedModel: stackName = typed.Spec.StackRef.Name case *v1alpha1.ExternalTool: stackName = typed.Spec.StackRef.Name case *v1alpha1.ManagedTool: stackName = typed.Spec.StackRef.Name default: return nil } stackList := &v1alpha1.StackList{} if err := mgr.GetClient().List(ctx, stackList, client.InNamespace(obj.GetNamespace())); err != nil { return nil } var reqs []reconcile.Request for _, stack := range stackList.Items { if stackName == stack.Name { reqs = append(reqs, reconcile.Request{ NamespacedName: types.NamespacedName{Name: stack.Name, Namespace: stack.Namespace}, }) } } return reqs } return ctrl.NewControllerManagedBy(mgr). For(&v1alpha1.Stack{}). Owns(&v1alpha1.Component{}). Owns(&corev1.PersistentVolumeClaim{}). Watches(&v1alpha1.ExternalModel{}, handler.EnqueueRequestsFromMapFunc(mapToStack)). Watches(&v1alpha1.ManagedModel{}, handler.EnqueueRequestsFromMapFunc(mapToStack)). Watches(&v1alpha1.ExternalTool{}, handler.EnqueueRequestsFromMapFunc(mapToStack)). Watches(&v1alpha1.ManagedTool{}, handler.EnqueueRequestsFromMapFunc(mapToStack)). Named("stack"). Complete(r) }