From 4cf7de3387b6a9453dcdc1b5f9a4808b0fc045fb Mon Sep 17 00:00:00 2001 From: bdchatham Date: Tue, 2 Jun 2026 16:17:34 +0200 Subject: [PATCH] feat(api): reject replicas>1 when validator.signingKey is set MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A SeiNodeDeployment whose template.spec.validator.signingKey is set mounts the same priv_validator_key.json on every replica. With replicas>1, two seid processes sign with one consensus key — equivocation (double-sign) — which tombstones/slashes the validator. Nothing at the API stopped a replicas:2 edit today; the runner-layer replace-pod check is defense after admission, not a declarative front door. Add a spec-level CEL rule rejecting replicas>1 when validator.signingKey is present. Genesis-ceremony / non-signing-observer validators (validator set without a signingKey) are unaffected and may still scale >1. rule: !has(self.template.spec.validator) || !has(self.template.spec.validator.signingKey) || self.replicas == 1 Regenerated CRDs (config/crd + manifests). envtest covers: signingKey+r1 accepted, signingKey+r2 rejected, validator-without-signingKey+r3 accepted, and scale-to-2-on-update rejected — all passing against a real apiserver. Closes #377. Co-Authored-By: Claude Opus 4.8 (1M context) --- api/v1alpha1/seinodedeployment_types.go | 1 + config/crd/sei.io_seinodedeployments.yaml | 5 ++ .../envtest/validator_replicas_test.go | 78 +++++++++++++++++++ manifests/sei.io_seinodedeployments.yaml | 5 ++ 4 files changed, 89 insertions(+) create mode 100644 internal/controller/nodedeployment/envtest/validator_replicas_test.go 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: