Skip to content
Merged
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
5 changes: 3 additions & 2 deletions api/v1alpha1/seinodetaskworkflow_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
37 changes: 32 additions & 5 deletions internal/controller/node/workflow.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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 {
Expand Down
45 changes: 45 additions & 0 deletions internal/controller/node/workflow_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
13 changes: 8 additions & 5 deletions internal/planner/workflow.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
27 changes: 19 additions & 8 deletions internal/planner/workflow_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading