diff --git a/api/v1/cosmosfullnode_types.go b/api/v1/cosmosfullnode_types.go index a3275151..0c619abc 100644 --- a/api/v1/cosmosfullnode_types.go +++ b/api/v1/cosmosfullnode_types.go @@ -75,6 +75,18 @@ type FullNodeSpec struct { // Creates 1 pod per replica. PodTemplate PodSpec `json:"podTemplate"` + // Pre-configured NodeKeys for each replica. + // Each string should be a base64-encoded ed25519 private key (64 bytes when decoded). + // The array index corresponds to the replica ordinal (accounting for spec.ordinals.start). + // For example, if ordinals.start=2 and replicas=3, then: + // - nodeKeys[0] applies to replica with ordinal 2 + // - nodeKeys[1] applies to replica with ordinal 3 + // - nodeKeys[2] applies to replica with ordinal 4 + // If the array has fewer elements than replicas, missing keys will be auto-generated. + // If not set or empty, the operator generates a random ed25519 key for each replica. + // +optional + NodeKeys []string `json:"nodeKeys"` + // Additional pod specs to apply per replica. // This is useful for adding additional pods to the deployment // that need to be versioned alongside the main pod. diff --git a/api/v1/zz_generated.deepcopy.go b/api/v1/zz_generated.deepcopy.go index c1d64565..911ef44c 100644 --- a/api/v1/zz_generated.deepcopy.go +++ b/api/v1/zz_generated.deepcopy.go @@ -308,6 +308,11 @@ func (in *FullNodeSpec) DeepCopyInto(out *FullNodeSpec) { out.Ordinals = in.Ordinals in.ChainSpec.DeepCopyInto(&out.ChainSpec) in.PodTemplate.DeepCopyInto(&out.PodTemplate) + if in.NodeKeys != nil { + in, out := &in.NodeKeys, &out.NodeKeys + *out = make([]string, len(*in)) + copy(*out, *in) + } if in.AdditionalVersionedPods != nil { in, out := &in.AdditionalVersionedPods, &out.AdditionalVersionedPods *out = make([]AdditionalPodSpec, len(*in)) diff --git a/config/crd/bases/cosmos.strange.love_cosmosfullnodes.yaml b/config/crd/bases/cosmos.strange.love_cosmosfullnodes.yaml index d3235bf1..06a31dd1 100644 --- a/config/crd/bases/cosmos.strange.love_cosmosfullnodes.yaml +++ b/config/crd/bases/cosmos.strange.love_cosmosfullnodes.yaml @@ -5069,6 +5069,20 @@ spec: Example: cosmos-1 Used for debugging. type: object + nodeKeys: + description: |- + Pre-configured NodeKeys for each replica. + Each string should be a base64-encoded ed25519 private key (64 bytes when decoded). + The array index corresponds to the replica ordinal (accounting for spec.ordinals.start). + For example, if ordinals.start=2 and replicas=3, then: + - nodeKeys[0] applies to replica with ordinal 2 + - nodeKeys[1] applies to replica with ordinal 3 + - nodeKeys[2] applies to replica with ordinal 4 + If the array has fewer elements than replicas, missing keys will be auto-generated. + If not set or empty, the operator generates a random ed25519 key for each replica. + items: + type: string + type: array ordinals: description: |- Ordinals controls the numbering of replica indices in a CosmosFullnode spec. diff --git a/internal/fullnode/node_key_collector.go b/internal/fullnode/node_key_collector.go index 33febd64..ccae4ddd 100644 --- a/internal/fullnode/node_key_collector.go +++ b/internal/fullnode/node_key_collector.go @@ -5,6 +5,7 @@ import ( "crypto/ed25519" "crypto/rand" "crypto/sha256" + "encoding/base64" "encoding/hex" "encoding/json" "fmt" @@ -31,6 +32,27 @@ func (nk NodeKey) ID() string { return hex.EncodeToString(hash[:20]) } +// base64StrToNodeKey converts a base64-encoded ed25519 private key string into a NodeKey structure. +func base64StrToNodeKey(s string) (*NodeKey, error) { + keyBytes, err := base64.StdEncoding.DecodeString(s) + if err != nil { + return nil, fmt.Errorf("failed to decode base64 string: %w", err) + } + + if len(keyBytes) != ed25519.PrivateKeySize { + return nil, fmt.Errorf("invalid key size: expected %d bytes, got %d", ed25519.PrivateKeySize, len(keyBytes)) + } + + pk := ed25519.PrivateKey(keyBytes) + + return &NodeKey{ + PrivKey: NodeKeyPrivKey{ + Type: "tendermint/PrivKeyEd25519", + Value: pk, + }, + }, nil +} + func randNodeKey() (*NodeKey, error) { _, pk, err := ed25519.GenerateKey(rand.Reader) if err != nil { @@ -64,6 +86,10 @@ func NewNodeKeyCollector(client Client) *NodeKeyCollector { } } +func hasNodeKeyAtIndex(nodeKeys []string, index int32) bool { + return nodeKeys != nil && int(index) < len(nodeKeys) && nodeKeys[index] != "" +} + // Collect node key information given the crd. func (c NodeKeyCollector) Collect(ctx context.Context, crd *cosmosv1.CosmosFullNode) (NodeKeys, kube.ReconcileError) { logger := log.FromContext(ctx) @@ -98,6 +124,16 @@ func (c NodeKeyCollector) Collect(ctx context.Context, crd *cosmosv1.CosmosFullN // Store the exact value of the node key in the configmap to avoid non-deterministic JSON marshaling which can cause unnecessary updates. marshaledNodeKey = []byte(nodeKeyContent) + } else if hasNodeKeyAtIndex(crd.Spec.NodeKeys, i-crd.Spec.Ordinals.Start) { + rNodeKey, err := base64StrToNodeKey(crd.Spec.NodeKeys[i-crd.Spec.Ordinals.Start]) + if err != nil { + return nil, kube.UnrecoverableError(fmt.Errorf("invalid node key: %w", err)) + } + nodeKey = *rNodeKey + marshaledNodeKey, err = json.Marshal(nodeKey) + if err != nil { + return nil, kube.UnrecoverableError(fmt.Errorf("marshal node key: %w", err)) + } } else { rNodeKey, err := randNodeKey() if err != nil { diff --git a/internal/fullnode/node_key_collector_test.go b/internal/fullnode/node_key_collector_test.go index 5f57b177..68dd4bc9 100644 --- a/internal/fullnode/node_key_collector_test.go +++ b/internal/fullnode/node_key_collector_test.go @@ -4,6 +4,7 @@ import ( "context" "crypto/ed25519" "crypto/sha256" + "encoding/base64" "encoding/hex" "fmt" "testing" @@ -226,3 +227,224 @@ func TestNodeKeyCollector_DecreaseReplicas(t *testing.T) { require.Contains(t, nodeKeys, client.ObjectKey{Name: "dydx-1", Namespace: namespace}) require.NotContains(t, nodeKeys, client.ObjectKey{Name: "dydx-2", Namespace: namespace}) } + +func TestNodeKeyCollector_SpecNodeKeys(t *testing.T) { + t.Parallel() + + ctx := context.Background() + const namespace = "strangelove" + + t.Run("valid spec node keys", func(t *testing.T) { + // Generate valid ed25519 private keys for testing + _, pk1, err := ed25519.GenerateKey(nil) + require.NoError(t, err) + _, pk2, err := ed25519.GenerateKey(nil) + require.NoError(t, err) + + nodeKey1Base64 := base64.StdEncoding.EncodeToString(pk1) + nodeKey2Base64 := base64.StdEncoding.EncodeToString(pk2) + + var mClient nodeKeyMockConfigClient + mClient.ObjectList = corev1.ConfigMapList{Items: []corev1.ConfigMap{}} + + var crd cosmosv1.CosmosFullNode + crd.Name = "dydx" + crd.Namespace = namespace + crd.Spec.Replicas = 2 + crd.Spec.NodeKeys = []string{nodeKey1Base64, nodeKey2Base64} + + collector := NewNodeKeyCollector(&mClient) + + nodeKeys, err := collector.Collect(ctx, &crd) + + require.NoError(t, err) + require.Len(t, nodeKeys, 2) + + // Verify the node keys match what we provided + nk1 := nodeKeys[client.ObjectKey{Name: "dydx-0", Namespace: namespace}] + nk2 := nodeKeys[client.ObjectKey{Name: "dydx-1", Namespace: namespace}] + + require.Equal(t, pk1, nk1.NodeKey.PrivKey.Value) + require.Equal(t, pk2, nk2.NodeKey.PrivKey.Value) + }) + + t.Run("partial spec node keys - generates missing keys", func(t *testing.T) { + // Only provide 1 key for 3 replicas + _, pk1, err := ed25519.GenerateKey(nil) + require.NoError(t, err) + nodeKey1Base64 := base64.StdEncoding.EncodeToString(pk1) + + var mClient nodeKeyMockConfigClient + mClient.ObjectList = corev1.ConfigMapList{Items: []corev1.ConfigMap{}} + + var crd cosmosv1.CosmosFullNode + crd.Name = "dydx" + crd.Namespace = namespace + crd.Spec.Replicas = 3 + crd.Spec.NodeKeys = []string{nodeKey1Base64} + + collector := NewNodeKeyCollector(&mClient) + + nodeKeys, err := collector.Collect(ctx, &crd) + + require.NoError(t, err) + require.Len(t, nodeKeys, 3) + + // First key should match what we provided + nk1 := nodeKeys[client.ObjectKey{Name: "dydx-0", Namespace: namespace}] + require.Equal(t, pk1, nk1.NodeKey.PrivKey.Value) + + // Other keys should be generated (different from the first) + nk2 := nodeKeys[client.ObjectKey{Name: "dydx-1", Namespace: namespace}] + nk3 := nodeKeys[client.ObjectKey{Name: "dydx-2", Namespace: namespace}] + require.NotEqual(t, pk1, nk2.NodeKey.PrivKey.Value) + require.NotEqual(t, pk1, nk3.NodeKey.PrivKey.Value) + require.NotEqual(t, nk2.NodeKey.PrivKey.Value, nk3.NodeKey.PrivKey.Value) + }) + + t.Run("configmap node key should take precedence than spec node keys", func(t *testing.T) { + // Generate a new key for spec + _, specPk, err := ed25519.GenerateKey(nil) + require.NoError(t, err) + specKeyBase64 := base64.StdEncoding.EncodeToString(specPk) + + // ConfigMap has existing key + existingNodeKey := `{"priv_key":{"type":"tendermint/PrivKeyEd25519","value":"HBX8VFQ4OdWfOwIOR7jj0af8mVHik5iGW9o1xnn4vRltk1HmwQS2LLGrMPVS2LIUO9BUqmZ1Pjt+qM8x0ibHxQ=="}}` + + var mClient nodeKeyMockConfigClient + mClient.ObjectList = corev1.ConfigMapList{Items: []corev1.ConfigMap{ + { + ObjectMeta: metav1.ObjectMeta{Name: "dydx-0", Namespace: namespace}, + Data: map[string]string{nodeKeyFile: existingNodeKey}, + }, + }} + + var crd cosmosv1.CosmosFullNode + crd.Name = "dydx" + crd.Namespace = namespace + crd.Spec.Replicas = 1 + crd.Spec.NodeKeys = []string{specKeyBase64} + + collector := NewNodeKeyCollector(&mClient) + + nodeKeys, err := collector.Collect(ctx, &crd) + + require.NoError(t, err) + require.Len(t, nodeKeys, 1) + + // Should use configmap key, not spec key + nk := nodeKeys[client.ObjectKey{Name: "dydx-0", Namespace: namespace}] + require.Equal(t, existingNodeKey, string(nk.MarshaledNodeKey)) + + // Verify it's not using the spec key + require.NotEqual(t, specPk, nk.NodeKey.PrivKey.Value) + }) + + t.Run("invalid base64 node key", func(t *testing.T) { + var mClient nodeKeyMockConfigClient + mClient.ObjectList = corev1.ConfigMapList{Items: []corev1.ConfigMap{}} + + var crd cosmosv1.CosmosFullNode + crd.Name = "dydx" + crd.Namespace = namespace + crd.Spec.Replicas = 1 + crd.Spec.NodeKeys = []string{"invalid-base64!@#"} + + collector := NewNodeKeyCollector(&mClient) + + _, err := collector.Collect(ctx, &crd) + + require.Error(t, err) + require.Contains(t, err.Error(), "invalid node key") + }) + + t.Run("wrong key size in spec", func(t *testing.T) { + // Create a key with wrong size (32 bytes instead of 64) + shortKey := make([]byte, 32) + shortKeyBase64 := base64.StdEncoding.EncodeToString(shortKey) + + var mClient nodeKeyMockConfigClient + mClient.ObjectList = corev1.ConfigMapList{Items: []corev1.ConfigMap{}} + + var crd cosmosv1.CosmosFullNode + crd.Name = "dydx" + crd.Namespace = namespace + crd.Spec.Replicas = 1 + crd.Spec.NodeKeys = []string{shortKeyBase64} + + collector := NewNodeKeyCollector(&mClient) + + _, err := collector.Collect(ctx, &crd) + + require.Error(t, err) + require.Contains(t, err.Error(), "invalid node key") + require.Contains(t, err.Error(), "invalid key size") + }) + + t.Run("spec node keys with non-zero ordinals", func(t *testing.T) { + _, pk1, err := ed25519.GenerateKey(nil) + require.NoError(t, err) + _, pk2, err := ed25519.GenerateKey(nil) + require.NoError(t, err) + + nodeKey1Base64 := base64.StdEncoding.EncodeToString(pk1) + nodeKey2Base64 := base64.StdEncoding.EncodeToString(pk2) + + var mClient nodeKeyMockConfigClient + mClient.ObjectList = corev1.ConfigMapList{Items: []corev1.ConfigMap{}} + + var crd cosmosv1.CosmosFullNode + crd.Name = "dydx" + crd.Namespace = namespace + crd.Spec.Replicas = 2 + crd.Spec.Ordinals.Start = 5 + crd.Spec.NodeKeys = []string{nodeKey1Base64, nodeKey2Base64} + + collector := NewNodeKeyCollector(&mClient) + + nodeKeys, err := collector.Collect(ctx, &crd) + + require.NoError(t, err) + require.Len(t, nodeKeys, 2) + + // Verify the node keys are mapped correctly to ordinals 5 and 6 + nk1 := nodeKeys[client.ObjectKey{Name: "dydx-5", Namespace: namespace}] + nk2 := nodeKeys[client.ObjectKey{Name: "dydx-6", Namespace: namespace}] + + require.Equal(t, pk1, nk1.NodeKey.PrivKey.Value) + require.Equal(t, pk2, nk2.NodeKey.PrivKey.Value) + }) +} + +func TestBase64StrToNodeKey(t *testing.T) { + t.Parallel() + + t.Run("valid base64 ed25519 key", func(t *testing.T) { + _, pk, err := ed25519.GenerateKey(nil) + require.NoError(t, err) + + keyBase64 := base64.StdEncoding.EncodeToString(pk) + + nodeKey, err := base64StrToNodeKey(keyBase64) + require.NoError(t, err) + require.NotNil(t, nodeKey) + require.Equal(t, "tendermint/PrivKeyEd25519", nodeKey.PrivKey.Type) + require.Equal(t, pk, nodeKey.PrivKey.Value) + }) + + t.Run("invalid base64", func(t *testing.T) { + _, err := base64StrToNodeKey("invalid-base64!@#") + require.Error(t, err) + require.Contains(t, err.Error(), "failed to decode base64 string") + }) + + t.Run("wrong key size", func(t *testing.T) { + // Create key with wrong size + shortKey := make([]byte, 32) + keyBase64 := base64.StdEncoding.EncodeToString(shortKey) + + _, err := base64StrToNodeKey(keyBase64) + require.Error(t, err) + require.Contains(t, err.Error(), "invalid key size") + }) +}