diff --git a/api/v1alpha1/seinodedeployment_types.go b/api/v1alpha1/seinodedeployment_types.go index 2aa97c5..19b61ff 100644 --- a/api/v1alpha1/seinodedeployment_types.go +++ b/api/v1alpha1/seinodedeployment_types.go @@ -9,6 +9,7 @@ import ( // // +kubebuilder:validation:XValidation:rule="!has(self.genesis) || has(self.template.spec.validator)",message="genesis is meaningful only for validator-role deployments (full nodes inherit genesis from the validator ceremony's S3 artifact); remove spec.genesis or set template.spec.validator: {}" // +kubebuilder:validation:XValidation:rule="!has(oldSelf.genesis) || (has(self.genesis) && self.genesis == oldSelf.genesis)",message="spec.genesis is immutable once set; the ceremony's outputs (chain ID, validator gentxs, account balances) are baked into chain state and cannot be retroactively rewritten by editing the spec" +// +kubebuilder:validation:XValidation:rule="!has(self.template.spec.validator) || !has(self.template.spec.validator.signingKey) || self.replicas == 1",message="a validator with a signingKey must have replicas: 1 — every replica mounts the same priv_validator_key.json, so >1 replica double-signs (equivocation) and tombstones/slashes the validator" type SeiNodeDeploymentSpec struct { // Replicas is the number of SeiNode instances to create. // +kubebuilder:validation:Minimum=1 diff --git a/config/crd/sei.io_seinodedeployments.yaml b/config/crd/sei.io_seinodedeployments.yaml index 56b6d45..aec8f74 100644 --- a/config/crd/sei.io_seinodedeployments.yaml +++ b/config/crd/sei.io_seinodedeployments.yaml @@ -925,6 +925,11 @@ spec: state and cannot be retroactively rewritten by editing the spec rule: '!has(oldSelf.genesis) || (has(self.genesis) && self.genesis == oldSelf.genesis)' + - message: 'a validator with a signingKey must have replicas: 1 — every + replica mounts the same priv_validator_key.json, so >1 replica double-signs + (equivocation) and tombstones/slashes the validator' + rule: '!has(self.template.spec.validator) || !has(self.template.spec.validator.signingKey) + || self.replicas == 1' status: description: SeiNodeDeploymentStatus defines the observed state of a SeiNodeDeployment. properties: diff --git a/internal/controller/nodedeployment/envtest/validator_replicas_test.go b/internal/controller/nodedeployment/envtest/validator_replicas_test.go new file mode 100644 index 0000000..c776d66 --- /dev/null +++ b/internal/controller/nodedeployment/envtest/validator_replicas_test.go @@ -0,0 +1,78 @@ +//go:build envtest + +package envtest_test + +import ( + "testing" + + . "github.com/onsi/gomega" + + "sigs.k8s.io/controller-runtime/pkg/client" + + seiv1alpha1 "github.com/sei-protocol/sei-k8s-controller/api/v1alpha1" + "github.com/sei-protocol/sei-k8s-controller/internal/controller/nodedeployment/envtest/fixtures" +) + +// withSigningAndNodeKey attaches a signingKey + nodeKey to an existing +// validator spec, referencing distinct Secrets. Distinct names satisfy the +// signingKey/nodeKey-paired CEL rules so a test can isolate the +// replicas==1 rule. Requires WithValidator() to have set Validator first. +func withSigningAndNodeKey(snd *seiv1alpha1.SeiNodeDeployment) { + snd.Spec.Template.Spec.Validator.SigningKey = &seiv1alpha1.SigningKeySource{ + Secret: &seiv1alpha1.SecretSigningKeySource{SecretName: "test-signing-key"}, + } + snd.Spec.Template.Spec.Validator.NodeKey = &seiv1alpha1.NodeKeySource{ + Secret: &seiv1alpha1.SecretNodeKeySource{SecretName: "test-node-key"}, + } +} + +// TestValidator_SigningKeyRequiresSingleReplica asserts the spec-level CEL +// rule that rejects replicas > 1 when validator.signingKey is set. Every +// replica mounts the same priv_validator_key.json, so more than one replica +// double-signs (equivocation) and tombstones/slashes the validator. +func TestValidator_SigningKeyRequiresSingleReplica(t *testing.T) { + t.Run("signingKey with replicas 1 is accepted", func(t *testing.T) { + g := NewWithT(t) + ns := makeNamespace(t) + snd := fixtures.NewSND(ns, "val-keys-r1", fixtures.WithValidator(), fixtures.WithReplicas(1)) + withSigningAndNodeKey(snd) + g.Expect(testCli.Create(testCtx, snd)).To(Succeed(), + "a signing validator with replicas:1 must be accepted") + }) + + t.Run("signingKey with replicas 2 is rejected", func(t *testing.T) { + g := NewWithT(t) + ns := makeNamespace(t) + snd := fixtures.NewSND(ns, "val-keys-r2", fixtures.WithValidator(), fixtures.WithReplicas(2)) + withSigningAndNodeKey(snd) + err := testCli.Create(testCtx, snd) + g.Expect(err).To(HaveOccurred(), "a signing validator with replicas:2 must be rejected") + g.Expect(err.Error()).To(ContainSubstring("must have replicas: 1"), + "rejection must carry the CEL rule message; got: %s", err.Error()) + }) + + t.Run("validator without a signingKey allows replicas > 1", func(t *testing.T) { + g := NewWithT(t) + ns := makeNamespace(t) + // Genesis-ceremony / non-signing observer: keys are generated + // on-cluster, no fixed signingKey is mounted, so multiple replicas + // are legitimate. The rule must not catch this case. + snd := fixtures.NewSND(ns, "val-nokeys-r3", fixtures.WithValidator(), fixtures.WithReplicas(3)) + g.Expect(testCli.Create(testCtx, snd)).To(Succeed(), + "a validator without a signingKey must allow replicas > 1") + }) + + t.Run("scaling a signing validator above 1 is rejected on update", func(t *testing.T) { + g := NewWithT(t) + ns := makeNamespace(t) + snd := fixtures.NewSND(ns, "val-keys-scale", fixtures.WithValidator(), fixtures.WithReplicas(1)) + withSigningAndNodeKey(snd) + g.Expect(testCli.Create(testCtx, snd)).To(Succeed()) + + err := updateSNDWithRetry(t, client.ObjectKeyFromObject(snd), func(cur *seiv1alpha1.SeiNodeDeployment) { + cur.Spec.Replicas = 2 + }) + g.Expect(err).To(HaveOccurred(), "scaling a signing validator above 1 must be rejected") + g.Expect(err.Error()).To(ContainSubstring("must have replicas: 1")) + }) +} diff --git a/manifests/sei.io_seinodedeployments.yaml b/manifests/sei.io_seinodedeployments.yaml index 56b6d45..aec8f74 100644 --- a/manifests/sei.io_seinodedeployments.yaml +++ b/manifests/sei.io_seinodedeployments.yaml @@ -925,6 +925,11 @@ spec: state and cannot be retroactively rewritten by editing the spec rule: '!has(oldSelf.genesis) || (has(self.genesis) && self.genesis == oldSelf.genesis)' + - message: 'a validator with a signingKey must have replicas: 1 — every + replica mounts the same priv_validator_key.json, so >1 replica double-signs + (equivocation) and tombstones/slashes the validator' + rule: '!has(self.template.spec.validator) || !has(self.template.spec.validator.signingKey) + || self.replicas == 1' status: description: SeiNodeDeploymentStatus defines the observed state of a SeiNodeDeployment. properties: