From e56d6e3bc1406f8ac7acc9354a3efa4fd875b76d Mon Sep 17 00:00:00 2001 From: bdchatham Date: Wed, 15 Jul 2026 12:50:36 -0700 Subject: [PATCH 1/2] feat(workflow): refuse non-full/RPC targets for state-sync/giga workflows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The giga SS-store migration (and state-sync generally) is valid only for a full/RPC node: validators and archive nodes block-sync and never restore from a snapshot, and a replayer is an ephemeral restore workload a wipe-and-resync would destroy. Generalize the prior validator-only refusal into an allowlist (ineligibleWorkflowRole → eligible iff spec.FullNode), at both the adoption gate and the planner's belt-and-braces Validate (kept in lockstep — the plan-rebuild recovery path skips the adoption gate). Fails closed for a future node mode. Refuses validator/archive/replayer with ReasonWorkflowTargetRejected; role in the message, reason enum unchanged. Tests cover all three modes at both layers. Co-Authored-By: Claude Opus 4.8 (1M context) --- api/v1alpha1/seinodetaskworkflow_types.go | 5 ++- internal/controller/node/workflow.go | 37 ++++++++++++++++--- internal/controller/node/workflow_test.go | 45 +++++++++++++++++++++++ internal/planner/workflow.go | 13 ++++--- internal/planner/workflow_test.go | 27 ++++++++++---- 5 files changed, 107 insertions(+), 20 deletions(-) diff --git a/api/v1alpha1/seinodetaskworkflow_types.go b/api/v1alpha1/seinodetaskworkflow_types.go index 6c0a22f4..dc3039c1 100644 --- a/api/v1alpha1/seinodetaskworkflow_types.go +++ b/api/v1alpha1/seinodetaskworkflow_types.go @@ -68,8 +68,9 @@ const ( // ReasonWorkflowTargetNotReady: the target node is not yet adoptable // (missing, not Running, or Paused). ReasonWorkflowTargetNotReady = "TargetNotReady" - // ReasonWorkflowTargetRejected: the target node is structurally ineligible - // (validator mode) and can never adopt this workflow. + // ReasonWorkflowTargetRejected: the target node is structurally ineligible — + // not a full/RPC node (e.g. validator, archive, or replayer mode) — and can + // never adopt this workflow. ReasonWorkflowTargetRejected = "TargetRejected" // ReasonWorkflowTargetPhaseTimeout: the target did not reach the required // phase within spec.target.requirePhaseTimeout; the workflow is failed. diff --git a/internal/controller/node/workflow.go b/internal/controller/node/workflow.go index d8a4316d..6844d4f4 100644 --- a/internal/controller/node/workflow.go +++ b/internal/controller/node/workflow.go @@ -246,13 +246,14 @@ func (r *SeiNodeReconciler) maybeAdoptWorkflow( return false, ctrl.Result{}, false, nil } - // Validators are structurally ineligible (CEL cannot see the target). Fail - // terminally so `kubectl wait --for=condition=Failed` resolves rather than - // parking Pending forever. - if node.Spec.Validator != nil { + // Only a full/RPC node is an eligible target; every other role is structurally + // ineligible (CEL cannot see the target). Fail terminally so + // `kubectl wait --for=condition=Failed` resolves rather than parking Pending + // forever. See ineligibleWorkflowRole for the per-role rationale. + if role := ineligibleWorkflowRole(node); role != "" { for i := range candidates { r.failWorkflow(ctx, &candidates[i], seiv1alpha1.ReasonWorkflowTargetRejected, - "target is a validator; workflows refuse validator nodes") + fmt.Sprintf("%s target is ineligible; workflows target full/RPC nodes only", role)) } return false, ctrl.Result{}, false, nil } @@ -600,6 +601,32 @@ func (r *SeiNodeReconciler) persistWorkflowPlan( }) } +// ineligibleWorkflowRole returns the target's role name when it may not be a +// state-sync / giga-migration workflow target, or "" when it is eligible. Only a +// full/RPC node (spec.fullNode, which absorbs the RPC role) is eligible; every +// other role is refused: validators and archive nodes block-sync and never restore +// from a snapshot, and a replayer is an ephemeral restore workload a re-bootstrap +// recipe would destroy. It is an allowlist by design — a node mode added later is +// refused until explicitly permitted here, rather than silently becoming eligible. +// The role is read from the typed spec sub-struct, the authoritative source +// deriveRole classifies from, so the check holds even when a CR's derived +// sei.io/role label is absent or stale. +func ineligibleWorkflowRole(node *seiv1alpha1.SeiNode) string { + if node.Spec.FullNode != nil { + return "" // full/RPC node — the only eligible target + } + switch { + case node.Spec.Validator != nil: + return "validator" + case node.Spec.Archive != nil: + return "archive" + case node.Spec.Replayer != nil: + return "replayer" + default: + return "non-full" // unset/unknown mode — refused by the allowlist + } +} + // failWorkflow marks a workflow terminally Failed. It does not touch the node. func (r *SeiNodeReconciler) failWorkflow(ctx context.Context, wf *seiv1alpha1.SeiNodeTaskWorkflow, reason, msg string) { if wf.Status.Phase == seiv1alpha1.SeiNodeTaskWorkflowPhaseFailed { diff --git a/internal/controller/node/workflow_test.go b/internal/controller/node/workflow_test.go index a1c55f12..934503ab 100644 --- a/internal/controller/node/workflow_test.go +++ b/internal/controller/node/workflow_test.go @@ -162,6 +162,51 @@ func TestReconcileWorkflow_RefusesValidatorTarget(t *testing.T) { g.Expect(cond.Reason).To(Equal(seiv1alpha1.ReasonWorkflowTargetRejected)) } +func TestReconcileWorkflow_RefusesArchiveTarget(t *testing.T) { + g := NewWithT(t) + node := idleRunningNode("arch-0") + node.Spec.FullNode = nil + node.Spec.Archive = &seiv1alpha1.ArchiveSpec{} + wf := workflowFor("ss-0", "arch-0", time.Now()) + r, c := newWorkflowReconciler(t, node, wf) + + got, _, handled := callReconcileWorkflow(t, r, c, "arch-0") + + g.Expect(handled).To(BeFalse()) + g.Expect(got.Status.AdoptedWorkflow).To(BeNil(), "an archive node never adopts") + // Archive block-syncs rather than restoring from a snapshot, so it is not an + // eligible state-sync target and is refused terminally, same as a validator. + refused := getWorkflow(t, c, "ss-0") + g.Expect(refused.Status.Phase).To(Equal(seiv1alpha1.SeiNodeTaskWorkflowPhaseFailed)) + cond := apimeta.FindStatusCondition(refused.Status.Conditions, seiv1alpha1.ConditionSeiNodeTaskWorkflowFailed) + g.Expect(cond).NotTo(BeNil()) + g.Expect(cond.Status).To(Equal(metav1.ConditionTrue)) + g.Expect(cond.Reason).To(Equal(seiv1alpha1.ReasonWorkflowTargetRejected)) + g.Expect(cond.Message).To(ContainSubstring("archive")) +} + +func TestReconcileWorkflow_RefusesReplayerTarget(t *testing.T) { + g := NewWithT(t) + node := idleRunningNode("replay-0") + node.Spec.FullNode = nil + node.Spec.Replayer = &seiv1alpha1.ReplayerSpec{} + wf := workflowFor("ss-0", "replay-0", time.Now()) + r, c := newWorkflowReconciler(t, node, wf) + + got, _, handled := callReconcileWorkflow(t, r, c, "replay-0") + + g.Expect(handled).To(BeFalse()) + g.Expect(got.Status.AdoptedWorkflow).To(BeNil(), "a replayer never adopts") + // A replayer is an ephemeral restore workload the wipe-and-resync recipe would + // destroy — the allowlist admits only full/RPC nodes, so it is refused. + refused := getWorkflow(t, c, "ss-0") + g.Expect(refused.Status.Phase).To(Equal(seiv1alpha1.SeiNodeTaskWorkflowPhaseFailed)) + cond := apimeta.FindStatusCondition(refused.Status.Conditions, seiv1alpha1.ConditionSeiNodeTaskWorkflowFailed) + g.Expect(cond).NotTo(BeNil()) + g.Expect(cond.Reason).To(Equal(seiv1alpha1.ReasonWorkflowTargetRejected)) + g.Expect(cond.Message).To(ContainSubstring("replayer")) +} + func TestReconcileWorkflow_RefusesPausedTarget(t *testing.T) { g := NewWithT(t) node := idleRunningNode("rpc-0") diff --git a/internal/planner/workflow.go b/internal/planner/workflow.go index f823e028..dc0e76fb 100644 --- a/internal/planner/workflow.go +++ b/internal/planner/workflow.go @@ -53,11 +53,14 @@ func (p *stateSyncWorkflowPlanner) Validate(node *seiv1alpha1.SeiNode, wf *seiv1 if wf.Spec.StateSync == nil { return fmt.Errorf("stateSync recipe params are nil") } - // Validators are excluded (wipe-and-resync is double-sign-adjacent). CEL - // cannot see the target, so this is the belt-and-braces check behind the - // adoption-time refusal. - if node.Spec.Validator != nil { - return fmt.Errorf("stateSync workflow refuses validator target %s/%s", node.Namespace, node.Name) + // Only a full/RPC node is a valid stateSync target; the recipe wipes and + // resyncs, which is invalid for a validator (double-sign-adjacent), an archive + // (block-syncs, never restores from a snapshot), or a replayer (ephemeral + // restore workload). CEL cannot see the target, so this allowlist is the + // belt-and-braces check behind the adoption-time refusal + // (SeiNodeReconciler.ineligibleWorkflowRole), kept in lockstep with it. + if node.Spec.FullNode == nil { + return fmt.Errorf("stateSync workflow refuses non-full/RPC target %s/%s", node.Namespace, node.Name) } return nil } diff --git a/internal/planner/workflow_test.go b/internal/planner/workflow_test.go index ed0c87f3..afa9397e 100644 --- a/internal/planner/workflow_test.go +++ b/internal/planner/workflow_test.go @@ -208,15 +208,26 @@ func TestStateSyncWorkflow_MigrationGuardrails(t *testing.T) { g.Expect(err).NotTo(HaveOccurred()) } -func TestStateSyncWorkflow_Validate_RefusesValidator(t *testing.T) { - g := NewWithT(t) - node := fullNodeForWorkflow([]string{wfWitnessA, wfWitnessB}) - node.Spec.FullNode = nil - node.Spec.Validator = &seiv1alpha1.ValidatorSpec{} +func TestStateSyncWorkflow_Validate_RefusesNonFullTargets(t *testing.T) { + // Only a full/RPC node is a valid stateSync target; validator, archive, and + // replayer are refused at compile time (lockstep with the adoption-time gate). + cases := map[string]func(*seiv1alpha1.SeiNode){ + "validator": func(n *seiv1alpha1.SeiNode) { n.Spec.Validator = &seiv1alpha1.ValidatorSpec{} }, + "archive": func(n *seiv1alpha1.SeiNode) { n.Spec.Archive = &seiv1alpha1.ArchiveSpec{} }, + "replayer": func(n *seiv1alpha1.SeiNode) { n.Spec.Replayer = &seiv1alpha1.ReplayerSpec{} }, + } + for role, setMode := range cases { + t.Run(role, func(t *testing.T) { + g := NewWithT(t) + node := fullNodeForWorkflow([]string{wfWitnessA, wfWitnessB}) + node.Spec.FullNode = nil + setMode(node) - _, err := buildPlan(t, node, stateSyncWorkflow(nil, nil)) - g.Expect(err).To(HaveOccurred()) - g.Expect(err.Error()).To(ContainSubstring("validator")) + _, err := buildPlan(t, node, stateSyncWorkflow(nil, nil)) + g.Expect(err).To(HaveOccurred()) + g.Expect(err.Error()).To(ContainSubstring("non-full/RPC")) + }) + } } // configureStateSyncWitnesses extracts the RpcServers from the plan's From 3625cf7e084872216f6e70d78e74ac26d44a47dd Mon Sep 17 00:00:00 2001 From: bdchatham Date: Wed, 15 Jul 2026 13:38:08 -0700 Subject: [PATCH 2/2] chore: bump sei-config to v0.0.23 (evm-ss-split RPC default) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The controller validates the config-apply ConfigIntent (including spec.overrides) against its own sei-config before dispatching to the sidecar, so it must know the storage.state_store.evm_ss_split field — otherwise a node carrying that override stalls at config-apply with 'unknown config field'. Bumps the controller in lockstep with the sidecar (seictl v0.0.67) so the override validates end to end. Co-Authored-By: Claude Opus 4.8 (1M context) --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 129a1c3b..c6eaf132 100644 --- a/go.mod +++ b/go.mod @@ -14,7 +14,7 @@ require ( github.com/google/go-cmp v0.7.0 github.com/google/uuid v1.6.0 github.com/onsi/gomega v1.39.1 - github.com/sei-protocol/sei-config v0.0.22 + github.com/sei-protocol/sei-config v0.0.23 github.com/sei-protocol/seictl v0.0.64 go.opentelemetry.io/otel v1.43.0 go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.43.0 diff --git a/go.sum b/go.sum index e73bafd4..72a29253 100644 --- a/go.sum +++ b/go.sum @@ -170,8 +170,8 @@ github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4Ul github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/sei-protocol/sei-config v0.0.22 h1:/inFTbf9ON/qq5o0CTLlbYL5R7wxH1nFCYepgSz0E90= -github.com/sei-protocol/sei-config v0.0.22/go.mod h1:zcEdLzyIH2AyP0/QRBE3s4Y9eGn0C/qAUx1c4o4EROU= +github.com/sei-protocol/sei-config v0.0.23 h1:pGFxRnKoXZLK3Ew/Vd5lgC5RvH9Wo58NnL19CuMgFIk= +github.com/sei-protocol/sei-config v0.0.23/go.mod h1:zcEdLzyIH2AyP0/QRBE3s4Y9eGn0C/qAUx1c4o4EROU= github.com/sei-protocol/seictl v0.0.64 h1:GfAA/2br7nDTlpiav1BC7Z2fUS9iv7gXYhECXKClPiY= github.com/sei-protocol/seictl v0.0.64/go.mod h1:YJYgm3yl1fTpLWdPrpwgAqyONK5IwSZ3Dlw4dzH6U6U= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=