github.com/kotalco/kotal@v0.3.0/controllers/chainlink/node_controller.go (about)

     1  package controllers
     2  
     3  import (
     4  	"context"
     5  	_ "embed"
     6  	"fmt"
     7  
     8  	chainlinkv1alpha1 "github.com/kotalco/kotal/apis/chainlink/v1alpha1"
     9  	chainlinkClients "github.com/kotalco/kotal/clients/chainlink"
    10  	"github.com/kotalco/kotal/controllers/shared"
    11  	appsv1 "k8s.io/api/apps/v1"
    12  	corev1 "k8s.io/api/core/v1"
    13  	"k8s.io/apimachinery/pkg/api/resource"
    14  	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
    15  	"k8s.io/apimachinery/pkg/util/intstr"
    16  	ctrl "sigs.k8s.io/controller-runtime"
    17  	"sigs.k8s.io/controller-runtime/pkg/client"
    18  	"sigs.k8s.io/controller-runtime/pkg/log"
    19  	"sigs.k8s.io/controller-runtime/pkg/predicate"
    20  )
    21  
    22  // NodeReconciler reconciles a Node object
    23  type NodeReconciler struct {
    24  	shared.Reconciler
    25  }
    26  
    27  const (
    28  	envApiEmail = "KOTAL_API_EMAIL"
    29  )
    30  
    31  var (
    32  	//go:embed copy_api_credentials.sh
    33  	CopyAPICredentials string
    34  )
    35  
    36  // +kubebuilder:rbac:groups=chainlink.kotal.io,resources=nodes,verbs=get;list;watch;create;update;patch;delete
    37  // +kubebuilder:rbac:groups=chainlink.kotal.io,resources=nodes/status,verbs=get;update;patch
    38  // +kubebuilder:rbac:groups=apps,resources=statefulsets,verbs=watch;get;list;create;update;delete
    39  // +kubebuilder:rbac:groups=core,resources=services;configmaps;persistentvolumeclaims,verbs=watch;get;create;update;list;delete
    40  
    41  func (r *NodeReconciler) Reconcile(ctx context.Context, req ctrl.Request) (result ctrl.Result, err error) {
    42  	defer shared.IgnoreConflicts(&err)
    43  
    44  	var node chainlinkv1alpha1.Node
    45  
    46  	if err = r.Client.Get(ctx, req.NamespacedName, &node); err != nil {
    47  		err = client.IgnoreNotFound(err)
    48  		return
    49  	}
    50  
    51  	// default the node if webhooks are disabled
    52  	if !shared.IsWebhookEnabled() {
    53  		node.Default()
    54  	}
    55  
    56  	shared.UpdateLabels(&node, "chainlink", "")
    57  
    58  	// reconcile service
    59  	if err = r.ReconcileOwned(ctx, &node, &corev1.Service{}, func(obj client.Object) error {
    60  		r.specService(&node, obj.(*corev1.Service))
    61  		return nil
    62  	}); err != nil {
    63  		return
    64  	}
    65  
    66  	// reconcile config map
    67  	if err = r.ReconcileOwned(ctx, &node, &corev1.ConfigMap{}, func(obj client.Object) error {
    68  		homeDir := chainlinkClients.NewClient(&node).HomeDir()
    69  
    70  		configToml, err := ConfigFromSpec(&node, homeDir)
    71  		if err != nil {
    72  			return err
    73  		}
    74  
    75  		secretsConfigToml, err := SecretsFromSpec(&node, homeDir, r.Client)
    76  		if err != nil {
    77  			return err
    78  		}
    79  		r.specConfigmap(&node, obj.(*corev1.ConfigMap), configToml, secretsConfigToml)
    80  		return nil
    81  	}); err != nil {
    82  		return
    83  	}
    84  
    85  	// reconcile persistent volume claim
    86  	if err = r.ReconcileOwned(ctx, &node, &corev1.PersistentVolumeClaim{}, func(obj client.Object) error {
    87  		r.specPVC(&node, obj.(*corev1.PersistentVolumeClaim))
    88  		return nil
    89  	}); err != nil {
    90  		return
    91  	}
    92  
    93  	// reconcile stateful set
    94  	if err = r.ReconcileOwned(ctx, &node, &appsv1.StatefulSet{}, func(obj client.Object) error {
    95  		client := chainlinkClients.NewClient(&node)
    96  
    97  		command := client.Command()
    98  		args := client.Args()
    99  		args = append(args, node.Spec.ExtraArgs.Encode(false)...)
   100  		env := client.Env()
   101  		homeDir := client.HomeDir()
   102  		return r.specStatefulSet(&node, obj.(*appsv1.StatefulSet), homeDir, command, args, env)
   103  	}); err != nil {
   104  		return
   105  	}
   106  
   107  	if err = r.updateStatus(ctx, &node); err != nil {
   108  		return
   109  	}
   110  
   111  	return
   112  }
   113  
   114  // updateStatus updates chainlink node status
   115  func (r *NodeReconciler) updateStatus(ctx context.Context, node *chainlinkv1alpha1.Node) error {
   116  	node.Status.Client = "chainlink"
   117  
   118  	if err := r.Status().Update(ctx, node); err != nil {
   119  		log.FromContext(ctx).Error(err, "unable to update node status")
   120  		return err
   121  	}
   122  
   123  	return nil
   124  }
   125  
   126  // specService updates node service spec
   127  func (r *NodeReconciler) specService(node *chainlinkv1alpha1.Node, svc *corev1.Service) {
   128  	labels := node.Labels
   129  
   130  	svc.ObjectMeta.Labels = labels
   131  
   132  	svc.Spec.Ports = []corev1.ServicePort{
   133  		{
   134  			Name:       "p2p",
   135  			Port:       int32(node.Spec.P2PPort),
   136  			TargetPort: intstr.FromString("p2p"),
   137  		},
   138  	}
   139  
   140  	if node.Spec.API {
   141  		svc.Spec.Ports = append(svc.Spec.Ports, corev1.ServicePort{
   142  			Name:       "api",
   143  			Port:       int32(node.Spec.APIPort),
   144  			TargetPort: intstr.FromString("api"),
   145  		})
   146  	}
   147  
   148  	if node.Spec.TLSPort != 0 {
   149  		svc.Spec.Ports = append(svc.Spec.Ports, corev1.ServicePort{
   150  			Name:       "tls",
   151  			Port:       int32(node.Spec.TLSPort),
   152  			TargetPort: intstr.FromString("tls"),
   153  		})
   154  	}
   155  
   156  	svc.Spec.Selector = labels
   157  }
   158  
   159  // specConfigmap updates chainlink node configmap spec
   160  func (r *NodeReconciler) specConfigmap(node *chainlinkv1alpha1.Node, config *corev1.ConfigMap, configToml, secretsConfigToml string) {
   161  	config.ObjectMeta.Labels = node.Labels
   162  
   163  	if config.Data == nil {
   164  		config.Data = make(map[string]string)
   165  	}
   166  
   167  	config.Data["config.toml"] = configToml
   168  	config.Data["secrets.toml"] = secretsConfigToml
   169  	config.Data["copy_api_credentials.sh"] = CopyAPICredentials
   170  }
   171  
   172  func (r *NodeReconciler) createVolumes(node *chainlinkv1alpha1.Node) []corev1.Volume {
   173  	volumes := []corev1.Volume{}
   174  
   175  	// data volume
   176  	volumes = append(volumes, corev1.Volume{
   177  		Name: "data",
   178  		VolumeSource: corev1.VolumeSource{
   179  			PersistentVolumeClaim: &corev1.PersistentVolumeClaimVolumeSource{
   180  				ClaimName: node.Name,
   181  			},
   182  		},
   183  	})
   184  
   185  	// config volume
   186  	volumes = append(volumes, corev1.Volume{
   187  		Name: "config",
   188  		VolumeSource: corev1.VolumeSource{
   189  			ConfigMap: &corev1.ConfigMapVolumeSource{
   190  				LocalObjectReference: corev1.LocalObjectReference{
   191  					Name: node.Name,
   192  				},
   193  			},
   194  		},
   195  	})
   196  
   197  	// projected volume sources
   198  	sources := []corev1.VolumeProjection{
   199  		{
   200  			Secret: &corev1.SecretProjection{
   201  				LocalObjectReference: corev1.LocalObjectReference{
   202  					Name: node.Spec.APICredentials.PasswordSecretName,
   203  				},
   204  				Items: []corev1.KeyToPath{
   205  					{
   206  						Key:  "password",
   207  						Path: "api-password",
   208  					},
   209  				},
   210  			},
   211  		},
   212  	}
   213  
   214  	if node.Spec.CertSecretName != "" {
   215  		sources = append(sources, corev1.VolumeProjection{
   216  			Secret: &corev1.SecretProjection{
   217  				LocalObjectReference: corev1.LocalObjectReference{
   218  					Name: node.Spec.CertSecretName,
   219  				},
   220  			},
   221  		})
   222  	}
   223  
   224  	// secrets volume
   225  	volumes = append(volumes, corev1.Volume{
   226  		Name: "secrets",
   227  		VolumeSource: corev1.VolumeSource{
   228  			Projected: &corev1.ProjectedVolumeSource{
   229  				Sources: sources,
   230  			},
   231  		},
   232  	})
   233  
   234  	return volumes
   235  }
   236  
   237  func (r *NodeReconciler) createVolumeMounts(node *chainlinkv1alpha1.Node, homeDir string) []corev1.VolumeMount {
   238  	// chainlink chmod the root dir
   239  	// we mount data volume at home dir
   240  	// chainlink root dir will be mounted at $data/kotal-data
   241  	return []corev1.VolumeMount{
   242  		{
   243  			Name:      "data",
   244  			MountPath: homeDir,
   245  		},
   246  		{
   247  			Name:      "config",
   248  			MountPath: shared.PathConfig(homeDir),
   249  		},
   250  		{
   251  			Name:      "secrets",
   252  			MountPath: shared.PathSecrets(homeDir),
   253  		},
   254  	}
   255  }
   256  
   257  // specStatefulSet updates node statefulset spec
   258  func (r *NodeReconciler) specStatefulSet(node *chainlinkv1alpha1.Node, sts *appsv1.StatefulSet, homeDir string, command, args []string, env []corev1.EnvVar) error {
   259  
   260  	sts.ObjectMeta.Labels = node.Labels
   261  
   262  	ports := []corev1.ContainerPort{
   263  		{
   264  			Name:          "p2p",
   265  			ContainerPort: int32(node.Spec.P2PPort),
   266  		},
   267  	}
   268  
   269  	if node.Spec.API {
   270  		ports = append(ports, corev1.ContainerPort{
   271  			Name:          "api",
   272  			ContainerPort: int32(node.Spec.APIPort),
   273  		})
   274  	}
   275  
   276  	if node.Spec.TLSPort != 0 {
   277  		ports = append(ports, corev1.ContainerPort{
   278  			Name:          "tls",
   279  			ContainerPort: int32(node.Spec.TLSPort),
   280  		})
   281  	}
   282  
   283  	replicas := int32(*node.Spec.Replicas)
   284  
   285  	sts.Spec = appsv1.StatefulSetSpec{
   286  		Selector: &metav1.LabelSelector{
   287  			MatchLabels: node.Labels,
   288  		},
   289  		Replicas:    &replicas,
   290  		ServiceName: node.Name,
   291  		Template: corev1.PodTemplateSpec{
   292  			ObjectMeta: metav1.ObjectMeta{
   293  				Labels: node.Labels,
   294  			},
   295  			Spec: corev1.PodSpec{
   296  				SecurityContext: shared.SecurityContext(),
   297  				InitContainers: []corev1.Container{
   298  					{
   299  						Name:    "copy-api-credentials",
   300  						Image:   shared.BusyboxImage,
   301  						Command: []string{"/bin/sh"},
   302  						Env: []corev1.EnvVar{
   303  							{
   304  								Name:  shared.EnvDataPath,
   305  								Value: shared.PathData(homeDir),
   306  							},
   307  							{
   308  								Name:  envApiEmail,
   309  								Value: node.Spec.APICredentials.Email,
   310  							},
   311  							{
   312  								Name:  shared.EnvSecretsPath,
   313  								Value: shared.PathSecrets(homeDir),
   314  							},
   315  						},
   316  						Args:         []string{fmt.Sprintf("%s/copy_api_credentials.sh", shared.PathConfig(homeDir))},
   317  						VolumeMounts: r.createVolumeMounts(node, homeDir),
   318  					},
   319  				},
   320  				Containers: []corev1.Container{
   321  					{
   322  						Name:    "node",
   323  						Image:   node.Spec.Image,
   324  						Command: command,
   325  						Args:    args,
   326  						Env:     env,
   327  						Ports:   ports,
   328  						Resources: corev1.ResourceRequirements{
   329  							Requests: corev1.ResourceList{
   330  								corev1.ResourceCPU:    resource.MustParse(node.Spec.Resources.CPU),
   331  								corev1.ResourceMemory: resource.MustParse(node.Spec.Resources.Memory),
   332  							},
   333  							Limits: corev1.ResourceList{
   334  								corev1.ResourceCPU:    resource.MustParse(node.Spec.Resources.CPULimit),
   335  								corev1.ResourceMemory: resource.MustParse(node.Spec.Resources.MemoryLimit),
   336  							},
   337  						},
   338  						VolumeMounts: r.createVolumeMounts(node, homeDir),
   339  					},
   340  				},
   341  				Volumes: r.createVolumes(node),
   342  			},
   343  		},
   344  	}
   345  
   346  	return nil
   347  }
   348  
   349  // specPVC updates chainlink persistent volume claim
   350  func (r *NodeReconciler) specPVC(node *chainlinkv1alpha1.Node, pvc *corev1.PersistentVolumeClaim) {
   351  	request := corev1.ResourceList{
   352  		corev1.ResourceStorage: resource.MustParse(node.Spec.Storage),
   353  	}
   354  
   355  	// spec is immutable after creation except resources.requests for bound claims
   356  	if !pvc.CreationTimestamp.IsZero() {
   357  		pvc.Spec.Resources.Requests = request
   358  		return
   359  	}
   360  
   361  	pvc.ObjectMeta.Labels = node.Labels
   362  	pvc.Spec = corev1.PersistentVolumeClaimSpec{
   363  		AccessModes: []corev1.PersistentVolumeAccessMode{
   364  			corev1.ReadWriteOnce,
   365  		},
   366  		Resources: corev1.VolumeResourceRequirements{
   367  			Requests: request,
   368  		},
   369  		StorageClassName: node.Spec.StorageClass,
   370  	}
   371  }
   372  
   373  func (r *NodeReconciler) SetupWithManager(mgr ctrl.Manager) error {
   374  	pred := predicate.GenerationChangedPredicate{}
   375  	return ctrl.NewControllerManagedBy(mgr).
   376  		For(&chainlinkv1alpha1.Node{}).
   377  		WithEventFilter(pred).
   378  		Owns(&appsv1.StatefulSet{}).
   379  		Owns(&corev1.ConfigMap{}).
   380  		Owns(&corev1.Service{}).
   381  		Owns(&corev1.PersistentVolumeClaim{}).
   382  		Complete(r)
   383  }