|
|
@@ -18,13 +18,25 @@ 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/utils/ptr"
|
|
|
ctrl "sigs.k8s.io/controller-runtime"
|
|
|
"sigs.k8s.io/controller-runtime/pkg/client"
|
|
|
+ "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
|
|
|
logf "sigs.k8s.io/controller-runtime/pkg/log"
|
|
|
|
|
|
"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
|
|
|
@@ -36,28 +48,245 @@ type StackReconciler struct {
|
|
|
// +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
|
|
|
|
|
|
-// Reconcile is part of the main kubernetes reconciliation loop which aims to
|
|
|
-// move the current state of the cluster closer to the desired state.
|
|
|
-// TODO(user): Modify the Reconcile function to compare the state specified by
|
|
|
-// the Stack object against the actual cluster state, and then
|
|
|
-// perform operations to make the cluster state reflect the state specified by
|
|
|
-// the user.
|
|
|
-//
|
|
|
-// For more details, check Reconcile and its Result here:
|
|
|
-// - https://pkg.go.dev/sigs.k8s.io/controller-runtime@v0.23.3/pkg/reconcile
|
|
|
func (r *StackReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
|
|
|
- _ = logf.FromContext(ctx)
|
|
|
+ log := logf.FromContext(ctx)
|
|
|
|
|
|
- // TODO(user): your logic here
|
|
|
+ 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
|
|
|
+ }
|
|
|
+
|
|
|
+ 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
|
|
|
+}
|
|
|
+
|
|
|
// SetupWithManager sets up the controller with the Manager.
|
|
|
func (r *StackReconciler) SetupWithManager(mgr ctrl.Manager) error {
|
|
|
return ctrl.NewControllerManagedBy(mgr).
|
|
|
For(&v1alpha1.Stack{}).
|
|
|
+ Owns(&v1alpha1.Component{}).
|
|
|
+ Owns(&corev1.PersistentVolumeClaim{}).
|
|
|
Named("stack").
|
|
|
Complete(r)
|
|
|
}
|