Browse Source

feat(stack): implement stack controller

Thomas Zhang 3 months ago
parent
commit
e8fd5e28f2
1 changed files with 240 additions and 11 deletions
  1. 240 11
      internal/controller/stack_controller.go

+ 240 - 11
internal/controller/stack_controller.go

@@ -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)
 }