/* 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 reconciler import ( "context" "crypto/sha256" "encoding/hex" "fmt" "maps" "path" "strings" "github.com/LocoStack/loco-operator/api/v1alpha1" "go.yaml.in/yaml/v2" appsv1 "k8s.io/api/apps/v1" corev1 "k8s.io/api/core/v1" discoveryv1 "k8s.io/api/discovery/v1" "k8s.io/apimachinery/pkg/api/resource" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/util/intstr" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" ) type DefaultComponentReconciler struct { client client.Client scheme *runtime.Scheme stack *v1alpha1.Stack component *v1alpha1.Component } func NewDefaultComponentReconciler(client client.Client, scheme *runtime.Scheme, stack *v1alpha1.Stack, component *v1alpha1.Component) *DefaultComponentReconciler { reconciler := &DefaultComponentReconciler{ client: client, scheme: scheme, stack: stack, component: component, } return reconciler } func (r *DefaultComponentReconciler) ReconcileComponent(ctx context.Context, tmpl *v1alpha1.Template, variables map[string]string) ([]client.Object, error) { r.component.Labels = r.ResourceLabels() tmplVars := map[string]string{ "metadata.name": r.ResourceName(""), "spec.runtime.port": fmt.Sprintf("%d", tmpl.Spec.Runtime.Port), } maps.Copy(tmplVars, variables) maps.Copy(tmplVars, r.component.Spec.Variables) err := r.reconcileService(ctx, tmpl) if err != nil { return nil, fmt.Errorf("Failed to reconcile service: %w", err) } err = r.reconcileDeployment(ctx, tmpl, tmplVars) if err != nil { return nil, fmt.Errorf("Failed to reconcile deployment: %w", err) } return nil, nil } func (r *DefaultComponentReconciler) ComponentAvailable(ctx context.Context) bool { endpoints := &discoveryv1.EndpointSliceList{} err := r.client.List(ctx, endpoints, client.InNamespace(r.component.GetNamespace()), client.MatchingLabels{"kubernetes.io/service-name": r.component.Name}) if err != nil { return false } for _, endpoint := range endpoints.Items { if len(endpoint.Endpoints) > 0 { return true } } return false } func (r *DefaultComponentReconciler) ResourceName(name string) string { if name == "" { return r.component.GetName() } return fmt.Sprintf("%s-%s", r.component.GetName(), name) } func (r *DefaultComponentReconciler) ResourceLabels() map[string]string { return ResourceLabels(r.stack.Name, r.component.Spec.Category, r.ResourceName(""), "") } func (r *DefaultComponentReconciler) ResourceLabelsWithNamespace() map[string]string { return ResourceLabels(r.stack.Name, r.component.Spec.Category, r.ResourceName(""), r.component.GetNamespace()) } func (r *DefaultComponentReconciler) reconcileService(ctx context.Context, tmpl *v1alpha1.Template) error { labels := r.ResourceLabels() svc := &corev1.Service{ObjectMeta: metav1.ObjectMeta{Name: r.ResourceName(""), Namespace: r.component.GetNamespace()}} _, err := controllerutil.CreateOrUpdate(ctx, r.client, svc, func() error { svc.Labels = labels svc.Spec.Type = corev1.ServiceTypeLoadBalancer svc.Spec.Selector = labels svc.Spec.Ports = []corev1.ServicePort{{ Port: tmpl.Spec.Runtime.Port, TargetPort: intstr.FromInt32(tmpl.Spec.Runtime.Port), Protocol: corev1.ProtocolTCP, }} return controllerutil.SetControllerReference(r.component, svc, r.scheme) }) return err } func (r *DefaultComponentReconciler) reconcileDeployment(ctx context.Context, tmpl *v1alpha1.Template, variables map[string]string) error { podSpec, err := buildPodSpec(variables, tmpl) if err != nil { return fmt.Errorf("Failed to build pod spec: %w", err) } labels := r.ResourceLabels() maps.Copy(labels, tmpl.Metadata.Labels) replicas := int32(1) dep := &appsv1.Deployment{ObjectMeta: metav1.ObjectMeta{Name: r.ResourceName(""), Namespace: r.component.GetNamespace()}} _, err = controllerutil.CreateOrUpdate(ctx, r.client, dep, func() error { dep.Labels = labels dep.Spec.Replicas = &replicas dep.Spec.Selector = &metav1.LabelSelector{MatchLabels: labels} dep.Spec.Template = corev1.PodTemplateSpec{ ObjectMeta: metav1.ObjectMeta{ Labels: labels, Annotations: tmpl.Metadata.Annotations, }, Spec: podSpec, } return controllerutil.SetControllerReference(r.component, dep, r.scheme) }) return err } func (r *DefaultComponentReconciler) ReconcileStorage(ctx context.Context, name, size string) error { pvName, err := r.reconcilePersistentVolume(ctx, name, size) if err != nil { return fmt.Errorf("Failed to reconcile persistent volume: %w", err) } if err := r.reconcilePersistentVolumeClaim(ctx, pvName, name, size); err != nil { return fmt.Errorf("Failed to reconcile persistent volume claim: %w", err) } return nil } func (r *DefaultComponentReconciler) reconcilePersistentVolume(ctx context.Context, name, size string) (string, error) { pvName := fmt.Sprintf("%s-%s", r.component.GetNamespace(), r.ResourceName(name)) pv := &corev1.PersistentVolume{ObjectMeta: metav1.ObjectMeta{Name: pvName}} storage := resource.MustParse(size) hostPathType := corev1.HostPathDirectoryOrCreate hostPath := path.Join("/var/lib/locostack", r.component.GetNamespace(), r.ResourceName("")) _, err := controllerutil.CreateOrUpdate(ctx, r.client, pv, func() error { if pv.Labels == nil { pv.Labels = map[string]string{} } maps.Copy(pv.Labels, r.ResourceLabelsWithNamespace()) pv.Spec.Capacity = corev1.ResourceList{corev1.ResourceStorage: storage} pv.Spec.AccessModes = []corev1.PersistentVolumeAccessMode{corev1.ReadWriteOnce} pv.Spec.PersistentVolumeReclaimPolicy = corev1.PersistentVolumeReclaimRetain pv.Spec.StorageClassName = "" pv.Spec.VolumeMode = nil pv.Spec.PersistentVolumeSource = corev1.PersistentVolumeSource{ HostPath: &corev1.HostPathVolumeSource{ Path: hostPath, Type: &hostPathType, }, } return nil }) return pvName, err } func (r *DefaultComponentReconciler) reconcilePersistentVolumeClaim(ctx context.Context, pvName, name, size string) error { pvc := &corev1.PersistentVolumeClaim{ObjectMeta: metav1.ObjectMeta{Name: r.ResourceName(name), Namespace: r.component.GetNamespace()}} storage := resource.MustParse(size) emptyStorageClass := "" _, err := controllerutil.CreateOrUpdate(ctx, r.client, pvc, func() error { if pvc.Labels == nil { pvc.Labels = map[string]string{} } maps.Copy(pvc.Labels, r.ResourceLabels()) pvc.Spec.AccessModes = []corev1.PersistentVolumeAccessMode{corev1.ReadWriteOnce} pvc.Spec.VolumeName = pvName pvc.Spec.StorageClassName = &emptyStorageClass if pvc.Spec.Resources.Requests == nil { pvc.Spec.Resources.Requests = corev1.ResourceList{} } pvc.Spec.Resources.Requests[corev1.ResourceStorage] = storage return nil }) return err } func (r *DefaultComponentReconciler) ReconcileKey(ctx context.Context, name, key string, keygen func() (string, error)) error { secret := &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: r.ResourceName(name), Namespace: r.component.GetNamespace()}} _, err := controllerutil.CreateOrUpdate(ctx, r.client, secret, func() error { if secret.Labels == nil { secret.Labels = map[string]string{} } maps.Copy(secret.Labels, r.ResourceLabels()) if secret.Data == nil { value, err := keygen() if err != nil { return err } secret.Data = map[string][]byte{key: []byte(value)} } return controllerutil.SetControllerReference(r.component, secret, r.scheme) }) return err } func (r *DefaultComponentReconciler) ReconcileConfig(ctx context.Context, key string, config map[string]string, isSecret bool) (string, error) { if err := r.reconcileEnvVarConfig(ctx, config, isSecret); err != nil { return "", fmt.Errorf("Failed to reconcile env var config map: %w", err) } configHash, err := r.reconcileFileConfig(ctx, key, config, isSecret) if err != nil { return "", fmt.Errorf("Failed to reconcile file config map: %w", err) } return configHash, nil } func (r *DefaultComponentReconciler) reconcileEnvVarConfig(ctx context.Context, config map[string]string, isSecret bool) error { configData := make(map[string]string) for k, v := range config { envVarName := strings.NewReplacer("-", "_", ".", "_").Replace(k) configData[strings.ToUpper(envVarName)] = v } var obj client.Object if isSecret { obj = &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: r.ResourceName("env"), Namespace: r.component.GetNamespace()}} } else { obj = &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: r.ResourceName("env"), Namespace: r.component.GetNamespace()}} } _, err := controllerutil.CreateOrUpdate(ctx, r.client, obj, func() error { if obj.GetLabels() == nil { obj.SetLabels(map[string]string{}) } maps.Copy(obj.GetLabels(), r.ResourceLabels()) switch o := obj.(type) { case *corev1.ConfigMap: o.Data = configData case *corev1.Secret: o.Data = make(map[string][]byte) for k, v := range configData { o.Data[k] = []byte(v) } } return controllerutil.SetControllerReference(r.component, obj, r.scheme) }) return err } func (r *DefaultComponentReconciler) reconcileFileConfig(ctx context.Context, key string, config map[string]string, isSecret bool) (string, error) { yamlConfig, err := yaml.Marshal(config) if err != nil { return "", fmt.Errorf("Failed to marshal config to yaml: %w", err) } h := sha256.Sum256(yamlConfig) configHash := hex.EncodeToString(h[:]) var obj client.Object if isSecret { obj = &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: r.ResourceName("config"), Namespace: r.component.GetNamespace()}} } else { obj = &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: r.ResourceName("config"), Namespace: r.component.GetNamespace()}} } _, err = controllerutil.CreateOrUpdate(ctx, r.client, obj, func() error { if obj.GetLabels() == nil { obj.SetLabels(map[string]string{}) } maps.Copy(obj.GetLabels(), r.ResourceLabels()) if obj.GetAnnotations() == nil { obj.SetAnnotations(map[string]string{}) } obj.GetAnnotations()["hash"] = configHash switch o := obj.(type) { case *corev1.ConfigMap: o.Data = map[string]string{ key: string(yamlConfig), } case *corev1.Secret: o.Data = map[string][]byte{ key: yamlConfig, } } return controllerutil.SetControllerReference(r.component, obj, r.scheme) }) return configHash, err }