|
|
@@ -0,0 +1,306 @@
|
|
|
+/*
|
|
|
+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
|
|
|
+ component *v1alpha1.Component
|
|
|
+}
|
|
|
+
|
|
|
+func NewDefaultComponentReconciler(client client.Client, scheme *runtime.Scheme, component *v1alpha1.Component) *DefaultComponentReconciler {
|
|
|
+ reconciler := &DefaultComponentReconciler{
|
|
|
+ client: client,
|
|
|
+ scheme: scheme,
|
|
|
+ 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.component.Spec.Category, r.ResourceName(""), "")
|
|
|
+}
|
|
|
+
|
|
|
+func (r *DefaultComponentReconciler) ResourceLabelsWithNamespace() map[string]string {
|
|
|
+ return ResourceLabels(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
|
|
|
+}
|