Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions api/v1/cosmosfullnode_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
5 changes: 5 additions & 0 deletions api/v1/zz_generated.deepcopy.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

14 changes: 14 additions & 0 deletions config/crd/bases/cosmos.strange.love_cosmosfullnodes.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
36 changes: 36 additions & 0 deletions internal/fullnode/node_key_collector.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"crypto/ed25519"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
Expand All @@ -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 {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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 {
Expand Down
222 changes: 222 additions & 0 deletions internal/fullnode/node_key_collector_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"crypto/ed25519"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"fmt"
"testing"
Expand Down Expand Up @@ -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")
})
}
Loading