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(ðereum2v1alpha1.Validator{}). 498 Owns(&appsv1.StatefulSet{}). 499 Owns(&corev1.ConfigMap{}). 500 Owns(&corev1.PersistentVolumeClaim{}). 501 Complete(r) 502 }