github.com/kotalco/kotal@v0.3.0/controllers/ethereum2/validator_controller.go (about)

     1  package controllers
     2  
     3  import (
     4  	"context"
     5  	_ "embed"
     6  	"fmt"
     7  
     8  	appsv1 "k8s.io/api/apps/v1"
     9  	corev1 "k8s.io/api/core/v1"
    10  	"k8s.io/apimachinery/pkg/api/resource"
    11  	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
    12  	ctrl "sigs.k8s.io/controller-runtime"
    13  	"sigs.k8s.io/controller-runtime/pkg/client"
    14  
    15  	ethereum2v1alpha1 "github.com/kotalco/kotal/apis/ethereum2/v1alpha1"
    16  	ethereum2Clients "github.com/kotalco/kotal/clients/ethereum2"
    17  	"github.com/kotalco/kotal/controllers/shared"
    18  )
    19  
    20  // ValidatorReconciler reconciles a Validator object
    21  type ValidatorReconciler struct {
    22  	shared.Reconciler
    23  }
    24  
    25  const (
    26  	envNetwork        = "KOTAL_NETWORK"
    27  	envKeyDir         = "KOTAL_KEY_DIR"
    28  	envKeystoreIndex  = "KOTAL_KEYSTORE_INDEX"
    29  	envValidatorsPath = "KOTAL_VALIDATORS_PATH"
    30  )
    31  
    32  var (
    33  	//go:embed prysm_import_keystore.sh
    34  	PrysmImportKeyStore string
    35  	//go:embed lighthouse_import_keystore.sh
    36  	LighthouseImportKeyStore string
    37  	//go:embed nimbus_copy_validators.sh
    38  	NimbusCopyValidators string
    39  )
    40  
    41  // +kubebuilder:rbac:groups=ethereum2.kotal.io,resources=validators,verbs=get;list;watch;create;update;patch;delete
    42  // +kubebuilder:rbac:groups=ethereum2.kotal.io,resources=validators/status,verbs=get;update;patch
    43  // +kubebuilder:rbac:groups=apps,resources=statefulsets,verbs=watch;get;list;create;update;delete
    44  // +kubebuilder:rbac:groups=core,resources=configmaps;persistentvolumeclaims,verbs=watch;get;create;update;list;delete
    45  
    46  // Reconcile reconciles Ethereum 2.0 validator client
    47  func (r *ValidatorReconciler) Reconcile(ctx context.Context, req ctrl.Request) (result ctrl.Result, err error) {
    48  	defer shared.IgnoreConflicts(&err)
    49  
    50  	var validator ethereum2v1alpha1.Validator
    51  
    52  	if err = r.Client.Get(ctx, req.NamespacedName, &validator); err != nil {
    53  		err = client.IgnoreNotFound(err)
    54  		return
    55  	}
    56  
    57  	// default the peer if webhooks are disabled
    58  	if !shared.IsWebhookEnabled() {
    59  		validator.Default()
    60  	}
    61  
    62  	shared.UpdateLabels(&validator, string(validator.Spec.Client), validator.Spec.Network)
    63  
    64  	// reconcile config map
    65  	if err = r.ReconcileOwned(ctx, &validator, &corev1.ConfigMap{}, func(obj client.Object) error {
    66  		r.specConfigmap(&validator, obj.(*corev1.ConfigMap))
    67  		return nil
    68  	}); err != nil {
    69  		return
    70  	}
    71  
    72  	// reconcile persistent volume claim
    73  	if err = r.ReconcileOwned(ctx, &validator, &corev1.PersistentVolumeClaim{}, func(obj client.Object) error {
    74  		r.specPVC(&validator, obj.(*corev1.PersistentVolumeClaim))
    75  		return nil
    76  	}); err != nil {
    77  		return
    78  	}
    79  
    80  	// reconcile stateful set
    81  	if err = r.ReconcileOwned(ctx, &validator, &appsv1.StatefulSet{}, func(obj client.Object) error {
    82  		client, err := ethereum2Clients.NewClient(&validator)
    83  		if err != nil {
    84  			return err
    85  		}
    86  
    87  		command := client.Command()
    88  		args := client.Args()
    89  		// encode extra arguments as key=value only if client is numbus
    90  		kv := validator.Spec.Client == ethereum2v1alpha1.NimbusClient
    91  		args = append(args, validator.Spec.ExtraArgs.Encode(kv)...)
    92  		homeDir := client.HomeDir()
    93  
    94  		r.specStatefulset(&validator, obj.(*appsv1.StatefulSet), command, args, homeDir)
    95  		return nil
    96  	}); err != nil {
    97  		return
    98  	}
    99  
   100  	return
   101  }
   102  
   103  // specPVC updates validator persistent volume claim spec
   104  func (r *ValidatorReconciler) specPVC(validator *ethereum2v1alpha1.Validator, pvc *corev1.PersistentVolumeClaim) {
   105  
   106  	request := corev1.ResourceList{
   107  		corev1.ResourceStorage: resource.MustParse(validator.Spec.Resources.Storage),
   108  	}
   109  
   110  	// spec is immutable after creation except resources.requests for bound claims
   111  	if !pvc.CreationTimestamp.IsZero() {
   112  		pvc.Spec.Resources.Requests = request
   113  		return
   114  	}
   115  
   116  	pvc.Labels = validator.GetLabels()
   117  
   118  	pvc.Spec = corev1.PersistentVolumeClaimSpec{
   119  		AccessModes: []corev1.PersistentVolumeAccessMode{
   120  			corev1.ReadWriteOnce,
   121  		},
   122  		Resources: corev1.VolumeResourceRequirements{
   123  			Requests: request,
   124  		},
   125  		StorageClassName: validator.Spec.Resources.StorageClass,
   126  	}
   127  }
   128  
   129  // createValidatorVolumes creates validator volumes
   130  func (r *ValidatorReconciler) createValidatorVolumes(validator *ethereum2v1alpha1.Validator) (volumes []corev1.Volume) {
   131  
   132  	var volumeProjections []corev1.VolumeProjection
   133  
   134  	dataVolume := corev1.Volume{
   135  		Name: "data",
   136  		VolumeSource: corev1.VolumeSource{
   137  			PersistentVolumeClaim: &corev1.PersistentVolumeClaimVolumeSource{
   138  				ClaimName: validator.Name,
   139  			},
   140  		},
   141  	}
   142  	volumes = append(volumes, dataVolume)
   143  
   144  	configVolume := corev1.Volume{
   145  		Name: "config",
   146  		VolumeSource: corev1.VolumeSource{
   147  			ConfigMap: &corev1.ConfigMapVolumeSource{
   148  				LocalObjectReference: corev1.LocalObjectReference{
   149  					Name: validator.Name,
   150  				},
   151  			},
   152  		},
   153  	}
   154  	volumes = append(volumes, configVolume)
   155  
   156  	// validator key/secret volumes
   157  	for i, keystore := range validator.Spec.Keystores {
   158  
   159  		var keystorePath string
   160  
   161  		// Nimbus: looking for keystore.json files
   162  		// Prysm: looking for keystore-{any suffix}.json files
   163  		// lighthouse:
   164  		//	- in auto discover mode: looking for voting-keystore.json files
   165  		//	- in validator_defintions.yml: any file name or directory structure can be used
   166  		// teku: indifferernt to file names or directory structure
   167  		if validator.Spec.Client == ethereum2v1alpha1.NimbusClient {
   168  			keystorePath = "keystore.json"
   169  		} else {
   170  			keystorePath = fmt.Sprintf("keystore-%d.json", i)
   171  		}
   172  
   173  		// rename the keystore file (available in key "keystore")
   174  		// will take effect after mounting this volume
   175  		keystoreVolume := corev1.Volume{
   176  			Name: keystore.SecretName,
   177  			VolumeSource: corev1.VolumeSource{
   178  				Secret: &corev1.SecretVolumeSource{
   179  					SecretName: keystore.SecretName,
   180  					Items: []corev1.KeyToPath{
   181  						{
   182  							Key:  "keystore",
   183  							Path: keystorePath,
   184  						},
   185  					},
   186  				},
   187  			},
   188  		}
   189  
   190  		// nimbus requires that all passwords are in same directory
   191  		// each password file holds the name of the key
   192  		// that's why we're creating aggregate volume projections
   193  		if validator.Spec.Client == ethereum2v1alpha1.NimbusClient {
   194  			volumeProjections = append(volumeProjections, corev1.VolumeProjection{
   195  				Secret: &corev1.SecretProjection{
   196  					LocalObjectReference: corev1.LocalObjectReference{
   197  						Name: keystore.SecretName,
   198  					},
   199  					Items: []corev1.KeyToPath{
   200  						{
   201  							Key:  "password",
   202  							Path: keystore.SecretName,
   203  						},
   204  					},
   205  				},
   206  			})
   207  		} else {
   208  			// update keystore volume with password for other clients
   209  			keystoreVolume.VolumeSource.Secret.Items = append(keystoreVolume.VolumeSource.Secret.Items, corev1.KeyToPath{
   210  				Key:  "password",
   211  				Path: "password.txt",
   212  			})
   213  		}
   214  
   215  		volumes = append(volumes, keystoreVolume)
   216  
   217  	}
   218  	// end of keystores loop
   219  
   220  	// nimbus: create projected volume that holds all secrets
   221  	if validator.Spec.Client == ethereum2v1alpha1.NimbusClient {
   222  		validatorSecretsVolume := corev1.Volume{
   223  			Name: "validator-secrets",
   224  			VolumeSource: corev1.VolumeSource{
   225  				Projected: &corev1.ProjectedVolumeSource{
   226  					Sources: volumeProjections,
   227  				},
   228  			},
   229  		}
   230  		volumes = append(volumes, validatorSecretsVolume)
   231  	}
   232  
   233  	// prysm: wallet password volume
   234  	if validator.Spec.Client == ethereum2v1alpha1.PrysmClient {
   235  		walletPasswordVolume := corev1.Volume{
   236  			// TODO: rename volume name to prysm-wallet-password
   237  			Name: validator.Spec.WalletPasswordSecret,
   238  			VolumeSource: corev1.VolumeSource{
   239  				Secret: &corev1.SecretVolumeSource{
   240  					SecretName: validator.Spec.WalletPasswordSecret,
   241  					Items: []corev1.KeyToPath{
   242  						{
   243  							Key:  "password",
   244  							Path: "prysm-wallet-password.txt",
   245  						},
   246  					},
   247  				},
   248  			},
   249  		}
   250  		volumes = append(volumes, walletPasswordVolume)
   251  
   252  		// tls certificate
   253  		if validator.Spec.CertSecretName != "" {
   254  			certVolume := corev1.Volume{
   255  				Name: "cert",
   256  				VolumeSource: corev1.VolumeSource{
   257  					Secret: &corev1.SecretVolumeSource{
   258  						SecretName: validator.Spec.CertSecretName,
   259  					},
   260  				},
   261  			}
   262  			volumes = append(volumes, certVolume)
   263  		}
   264  	}
   265  
   266  	return
   267  }
   268  
   269  // createValidatorVolumeMounts creates validator volume mounts
   270  // secrets-dir/
   271  // |___validator-keys/
   272  // |		|__key-name
   273  // |			|_ keystore[-n].json
   274  // |			|_ password.txt
   275  // |___validator-secrets/
   276  // |		|_ key-name-1.txt
   277  // |		|_ key-name-n.txt
   278  // |___prysm-wallet
   279  // |        |_prysm-wallet-pasword.txt
   280  func (r *ValidatorReconciler) createValidatorVolumeMounts(validator *ethereum2v1alpha1.Validator, homeDir string) (mounts []corev1.VolumeMount) {
   281  	dataMount := corev1.VolumeMount{
   282  		Name:      "data",
   283  		MountPath: shared.PathData(homeDir),
   284  	}
   285  	mounts = append(mounts, dataMount)
   286  
   287  	configMount := corev1.VolumeMount{
   288  		Name:      "config",
   289  		MountPath: shared.PathConfig(homeDir),
   290  	}
   291  	mounts = append(mounts, configMount)
   292  
   293  	for _, keystore := range validator.Spec.Keystores {
   294  
   295  		keystoreMount := corev1.VolumeMount{
   296  			Name:      keystore.SecretName,
   297  			MountPath: fmt.Sprintf("%s/validator-keys/%s", shared.PathSecrets(homeDir), keystore.SecretName),
   298  		}
   299  		mounts = append(mounts, keystoreMount)
   300  	}
   301  
   302  	// prysm wallet password
   303  	if validator.Spec.Client == ethereum2v1alpha1.PrysmClient {
   304  		walletPasswordMount := corev1.VolumeMount{
   305  			Name:      validator.Spec.WalletPasswordSecret,
   306  			ReadOnly:  true,
   307  			MountPath: fmt.Sprintf("%s/prysm-wallet", shared.PathSecrets(homeDir)),
   308  		}
   309  		mounts = append(mounts, walletPasswordMount)
   310  
   311  		if validator.Spec.CertSecretName != "" {
   312  			certMount := corev1.VolumeMount{
   313  				Name:      "cert",
   314  				ReadOnly:  true,
   315  				MountPath: fmt.Sprintf("%s/cert", shared.PathSecrets(homeDir)),
   316  			}
   317  			mounts = append(mounts, certMount)
   318  		}
   319  	}
   320  
   321  	// nimbus client
   322  	if validator.Spec.Client == ethereum2v1alpha1.NimbusClient {
   323  		ValidatorSecretsMount := corev1.VolumeMount{
   324  			Name:      "validator-secrets",
   325  			MountPath: fmt.Sprintf("%s/validator-secrets", shared.PathSecrets(homeDir)),
   326  		}
   327  		mounts = append(mounts, ValidatorSecretsMount)
   328  	}
   329  
   330  	return
   331  }
   332  
   333  // specStatefulset updates vvalidator statefulset spec
   334  func (r *ValidatorReconciler) specStatefulset(validator *ethereum2v1alpha1.Validator, sts *appsv1.StatefulSet, command, args []string, homeDir string) {
   335  
   336  	sts.Labels = validator.GetLabels()
   337  
   338  	initContainers := []corev1.Container{}
   339  
   340  	mounts := r.createValidatorVolumeMounts(validator, homeDir)
   341  
   342  	// prysm: import validator keys from secrets dir
   343  	// keystores are imported into wallet after being decrypted with keystore secret
   344  	// then encrypted with wallet password
   345  	if validator.Spec.Client == ethereum2v1alpha1.PrysmClient {
   346  		for i, keystore := range validator.Spec.Keystores {
   347  			keyDir := fmt.Sprintf("%s/validator-keys/%s", shared.PathSecrets(homeDir), keystore.SecretName)
   348  			importKeystoreContainer := corev1.Container{
   349  				Name:  fmt.Sprintf("import-keystore-%s", keystore.SecretName),
   350  				Image: validator.Spec.Image,
   351  				Env: []corev1.EnvVar{
   352  					{
   353  						Name:  envNetwork,
   354  						Value: validator.Spec.Network,
   355  					},
   356  					{
   357  						Name:  shared.EnvDataPath,
   358  						Value: shared.PathData(homeDir),
   359  					},
   360  					{
   361  						Name:  envKeyDir,
   362  						Value: keyDir,
   363  					},
   364  					{
   365  						Name:  envKeystoreIndex,
   366  						Value: fmt.Sprintf("%d", i),
   367  					},
   368  					{
   369  						Name:  shared.EnvSecretsPath,
   370  						Value: shared.PathSecrets(homeDir),
   371  					},
   372  				},
   373  				Command:      []string{"/bin/sh"},
   374  				Args:         []string{fmt.Sprintf("%s/prysm_import_keystore.sh", shared.PathConfig(homeDir))},
   375  				VolumeMounts: mounts,
   376  			}
   377  			initContainers = append(initContainers, importKeystoreContainer)
   378  		}
   379  	}
   380  
   381  	if validator.Spec.Client == ethereum2v1alpha1.LighthouseClient {
   382  		for i, keystore := range validator.Spec.Keystores {
   383  			keyDir := fmt.Sprintf("%s/validator-keys/%s", shared.PathSecrets(homeDir), keystore.SecretName)
   384  			importKeystoreContainer := corev1.Container{
   385  				Name:  fmt.Sprintf("import-keystore-%s", keystore.SecretName),
   386  				Image: validator.Spec.Image,
   387  				Env: []corev1.EnvVar{
   388  					{
   389  						Name:  envNetwork,
   390  						Value: validator.Spec.Network,
   391  					},
   392  					{
   393  						Name:  shared.EnvDataPath,
   394  						Value: shared.PathData(homeDir),
   395  					},
   396  					{
   397  						Name:  envKeyDir,
   398  						Value: keyDir,
   399  					},
   400  					{
   401  						Name:  envKeystoreIndex,
   402  						Value: fmt.Sprintf("%d", i),
   403  					},
   404  				},
   405  				Command:      []string{"/bin/sh"},
   406  				Args:         []string{fmt.Sprintf("%s/lighthouse_import_keystore.sh", shared.PathConfig(homeDir))},
   407  				VolumeMounts: mounts,
   408  			}
   409  			initContainers = append(initContainers, importKeystoreContainer)
   410  
   411  		}
   412  		// TODO: delete validator definitions file
   413  	}
   414  
   415  	if validator.Spec.Client == ethereum2v1alpha1.NimbusClient {
   416  		// copy secrets into rw directory under blockchain data directory
   417  		validatorsPath := fmt.Sprintf("%s/kotal-validators", shared.PathData(homeDir))
   418  		copyValidators := corev1.Container{
   419  			Name:  "copy-validators",
   420  			Image: validator.Spec.Image,
   421  			Env: []corev1.EnvVar{
   422  				{
   423  					Name:  shared.EnvSecretsPath,
   424  					Value: shared.PathSecrets(homeDir),
   425  				},
   426  				{
   427  					Name:  envValidatorsPath,
   428  					Value: validatorsPath,
   429  				},
   430  			},
   431  			Command:      []string{"/bin/sh"},
   432  			Args:         []string{fmt.Sprintf("%s/nimbus_copy_validators.sh", shared.PathConfig(homeDir))},
   433  			VolumeMounts: mounts,
   434  		}
   435  		initContainers = append(initContainers, copyValidators)
   436  	}
   437  
   438  	replicas := int32(*validator.Spec.Replicas)
   439  
   440  	sts.Spec = appsv1.StatefulSetSpec{
   441  		Selector: &metav1.LabelSelector{
   442  			MatchLabels: validator.GetLabels(),
   443  		},
   444  		Replicas: &replicas,
   445  		Template: corev1.PodTemplateSpec{
   446  			ObjectMeta: metav1.ObjectMeta{
   447  				Labels: validator.GetLabels(),
   448  			},
   449  			Spec: corev1.PodSpec{
   450  				SecurityContext: shared.SecurityContext(),
   451  				Containers: []corev1.Container{
   452  					{
   453  						Name:         "validator",
   454  						Image:        validator.Spec.Image,
   455  						Command:      command,
   456  						Args:         args,
   457  						VolumeMounts: mounts,
   458  						Resources: corev1.ResourceRequirements{
   459  							Requests: corev1.ResourceList{
   460  								corev1.ResourceCPU:    resource.MustParse(validator.Spec.Resources.CPU),
   461  								corev1.ResourceMemory: resource.MustParse(validator.Spec.Resources.Memory),
   462  							},
   463  							Limits: corev1.ResourceList{
   464  								corev1.ResourceCPU:    resource.MustParse(validator.Spec.Resources.CPULimit),
   465  								corev1.ResourceMemory: resource.MustParse(validator.Spec.Resources.MemoryLimit),
   466  							},
   467  						},
   468  					},
   469  				},
   470  				InitContainers: initContainers,
   471  				Volumes:        r.createValidatorVolumes(validator),
   472  			},
   473  		},
   474  	}
   475  }
   476  
   477  // specConfigmap updates validator configmap spec
   478  func (r *ValidatorReconciler) specConfigmap(validator *ethereum2v1alpha1.Validator, configmap *corev1.ConfigMap) {
   479  	if configmap.Data == nil {
   480  		configmap.Data = map[string]string{}
   481  	}
   482  
   483  	switch validator.Spec.Client {
   484  	case ethereum2v1alpha1.PrysmClient:
   485  		configmap.Data["prysm_import_keystore.sh"] = PrysmImportKeyStore
   486  	case ethereum2v1alpha1.LighthouseClient:
   487  		configmap.Data["lighthouse_import_keystore.sh"] = LighthouseImportKeyStore
   488  	case ethereum2v1alpha1.NimbusClient:
   489  		configmap.Data["nimbus_copy_validators.sh"] = NimbusCopyValidators
   490  	}
   491  
   492  }
   493  
   494  // SetupWithManager adds reconciler to the manager
   495  func (r *ValidatorReconciler) SetupWithManager(mgr ctrl.Manager) error {
   496  	return ctrl.NewControllerManagedBy(mgr).
   497  		For(&ethereum2v1alpha1.Validator{}).
   498  		Owns(&appsv1.StatefulSet{}).
   499  		Owns(&corev1.ConfigMap{}).
   500  		Owns(&corev1.PersistentVolumeClaim{}).
   501  		Complete(r)
   502  }