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
48 changes: 48 additions & 0 deletions api/v1alpha1/seed_types.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package v1alpha1

// SeedSpec configures a CometBFT seed node: a peer-discovery server running the
// P2P transport and PEX reactor only. It hands a dialing node a spread of peer
// addresses, then drops the connection.
//
// A seed joins no consensus, syncs no blocks, and serves no queries — seid binds
// no RPC, gRPC, REST or EVM listener in this mode. Hence no snapshot source
// (nothing to restore), no snapshotGeneration (no history to snapshot), and no
// signing key or operator keyring (it signs nothing). Its data volume holds the
// peer store plus the block and state DBs seid opens and never writes.
//
// Peers come from SeiNodeSpec.Peers as in every mode; a seed with none is a
// valid bootstrap root that others dial without dialing out itself.
//
// A seed reachable from outside the cluster needs three things this spec does
// not provide: SeiNodeSpec.ExternalAddress set to the published `host:port`
// (otherwise seid advertises its in-cluster listen address over PEX and every
// node that learns the seed by gossip gets an unroutable peer), a load balancer
// and DNS record for TCP 26656 that outlive pod and node churn, and an ingress
// allowance for that port. Nothing detects any of these missing: a seed reads
// Ready when its P2P port is bound, and nothing more.
//
// A seed serves no RPC, so it has no /status and no `catching_up`: sync
// freshness is not a property a seed has. Monitor it on P2P metrics
// (tendermint_p2p_*) at the metrics port instead.
type SeedSpec struct {
// NodeKey supplies the P2P identity (node_key.json) this seed presents.
//
// Required, where the validator's NodeKey is optional, because a seed's
// NodeID is published: operators dial `NodeID@host:port` and the
// secret-connection handshake verifies the pinned value, so a changed NodeID
// silently breaks every client carrying the old one. A Secret-sourced key
// survives pod recreation and PVC loss; one left to `seid init` regenerates
// onto the data volume. Carrying the identity to another cluster means
// replicating the Secret there — the controller reads it, it does not move
// it. Treat the NodeID as a one-way door.
//
// The Secret name is immutable, so rotating the identity means deleting and
// recreating the SeiNode — and announcing the new NodeID. There is no fast
// path for a leaked key: the old NodeID stays dialable until every client
// ships a new default, so a thief keeps impersonating a bootstrap anchor.
//
// Give each seed a distinct Secret. Nothing rejects two seeds sharing one,
// and they would present the same NodeID — collapsing the redundant anchors
// into a single entry in every dialer's peer store.
NodeKey NodeKeySource `json:"nodeKey"`
}
37 changes: 31 additions & 6 deletions api/v1alpha1/seinode_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,9 @@ import (
)

// SeiNodeSpec defines the desired state of a standalone Sei node.
// Exactly one mode sub-spec (fullNode, archive, replayer, validator) must be set;
// the populated field determines the node's operating mode.
// +kubebuilder:validation:XValidation:rule="(has(self.fullNode) ? 1 : 0) + (has(self.archive) ? 1 : 0) + (has(self.replayer) ? 1 : 0) + (has(self.validator) ? 1 : 0) == 1",message="exactly one of fullNode, archive, replayer, or validator must be set"
// Exactly one mode sub-spec (fullNode, archive, replayer, validator, seed) must
// be set; the populated field determines the node's operating mode.
// +kubebuilder:validation:XValidation:rule="(has(self.fullNode) ? 1 : 0) + (has(self.archive) ? 1 : 0) + (has(self.replayer) ? 1 : 0) + (has(self.validator) ? 1 : 0) + (has(self.seed) ? 1 : 0) == 1",message="exactly one of fullNode, archive, replayer, validator, or seed must be set"
// +kubebuilder:validation:XValidation:rule="!has(self.replayer) || (has(self.peers) && size(self.peers) > 0)",message="peers is required when replayer mode is set"
type SeiNodeSpec struct {
// ChainID of the chain this node belongs to.
Expand Down Expand Up @@ -73,6 +73,10 @@ type SeiNodeSpec struct {
// +optional
Validator *ValidatorSpec `json:"validator,omitempty"`

// Seed configures a peer-discovery seed node (P2P + PEX only).
// +optional
Seed *SeedSpec `json:"seed,omitempty"`

// ExternalAddress is the routable P2P host:port written into seid's
// `p2p.external_address`. SeiNetwork-managed nodes get this stamped by the
// SeiNetwork reconciler when TCP networking is enabled. Standalone SeiNodes
Expand Down Expand Up @@ -138,6 +142,27 @@ func (s *SeiNodeSpec) SnapshotSource() *SnapshotSource {
}
}

// NodeKeySecret returns the Secret supplying this node's P2P identity
// (node_key.json), or nil when the node has none and `seid init` generates one
// onto the data volume.
//
// Two modes carry a node key on differing terms — a validator's is optional and
// paired with a signing key, a seed's is required and standalone — so each
// sub-spec declares its own field rather than hoisting one to SeiNodeSpec, out of
// reach of the validator's key-pairing CEL rules. This accessor resolves that
// split in one place, so callers needing only "which Secret holds the node key"
// stay mode-blind. Mirrors SnapshotSource.
func (s *SeiNodeSpec) NodeKeySecret() *SecretNodeKeySource {
switch {
case s.Validator != nil && s.Validator.NodeKey != nil:
return s.Validator.NodeKey.Secret
case s.Seed != nil:
return s.Seed.NodeKey.Secret
default:
return nil
}
}

// ---------------------------------------------------------------------------
// Status
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -294,9 +319,9 @@ const (
// SeiNodes with spec.validator.signingKey.
ConditionSigningKeyReady = "SigningKeyReady"

// ConditionNodeKeyReady indicates whether a referenced validator
// node-key Secret passes all validation requirements. Only set on
// SeiNodes with spec.validator.nodeKey.
// ConditionNodeKeyReady indicates whether a referenced node-key Secret
// passes all validation requirements. Only set on SeiNodes that source a
// node key from a Secret — spec.validator.nodeKey or spec.seed.nodeKey.
ConditionNodeKeyReady = "NodeKeyReady"

// ConditionOperatorKeyringReady indicates whether a referenced
Expand Down
20 changes: 11 additions & 9 deletions api/v1alpha1/validator_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -129,8 +129,8 @@ type SecretSigningKeySource struct {
SecretName string `json:"secretName"`
}

// NodeKeySource declares where a validator's P2P node key
// (node_key.json) comes from. Exactly one variant must be set.
// NodeKeySource declares where a node's P2P node key (node_key.json) comes
// from. Exactly one variant must be set. Used by validator and seed modes.
//
// +kubebuilder:validation:XValidation:rule="(has(self.secret) ? 1 : 0) == 1",message="exactly one node key source must be set"
type NodeKeySource struct {
Expand All @@ -140,14 +140,16 @@ type NodeKeySource struct {
Secret *SecretNodeKeySource `json:"secret,omitempty"`
}

// SecretNodeKeySource references a Kubernetes Secret containing the
// validator's P2P node key. The Secret must contain a data key
// `node_key.json` holding the Tendermint node key, mounted read-only at
// $SEI_HOME/config/node_key.json.
// SecretNodeKeySource references a Kubernetes Secret containing a node's P2P
// node key. The Secret must contain a data key `node_key.json` holding the
// Tendermint node key, mounted read-only at $SEI_HOME/config/node_key.json.
//
// node_key.json is identity-bearing but not slashing-relevant — losing it
// only costs the validator's accumulated peer-graph reputation, not stake.
// Treated as Secret-grade for handling consistency with SigningKey.
// How much the key matters depends on the mode that references it. For a
// validator it is identity-bearing but not slashing-relevant — losing it costs
// only accumulated peer-graph reputation, not stake — and is Secret-grade for
// handling consistency with SigningKey. For a seed the derived NodeID is
// published and dialed by strangers, which makes it a one-way door; see
// SeedSpec.NodeKey before treating it as low-stakes.
type SecretNodeKeySource struct {
// SecretName is the name of a Secret in the SeiNode's namespace.
// The controller never creates, mutates, or deletes this Secret.
Expand Down
21 changes: 21 additions & 0 deletions api/v1alpha1/zz_generated.deepcopy.go

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

65 changes: 60 additions & 5 deletions config/crd/sei.io_seinodes.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,8 @@ spec:
spec:
description: |-
SeiNodeSpec defines the desired state of a standalone Sei node.
Exactly one mode sub-spec (fullNode, archive, replayer, validator) must be set;
the populated field determines the node's operating mode.
Exactly one mode sub-spec (fullNode, archive, replayer, validator, seed) must
be set; the populated field determines the node's operating mode.
properties:
archive:
description: Archive configures an archive node with full history
Expand Down Expand Up @@ -446,6 +446,60 @@ spec:
required:
- snapshot
type: object
seed:
description: Seed configures a peer-discovery seed node (P2P + PEX
only).
properties:
nodeKey:
description: |-
NodeKey supplies the P2P identity (node_key.json) this seed presents.

Required, where the validator's NodeKey is optional, because a seed's
NodeID is published: operators dial `NodeID@host:port` and the
secret-connection handshake verifies the pinned value, so a changed NodeID
silently breaks every client carrying the old one. A Secret-sourced key
survives pod recreation and PVC loss; one left to `seid init` regenerates
onto the data volume. Carrying the identity to another cluster means
replicating the Secret there — the controller reads it, it does not move
it. Treat the NodeID as a one-way door.

The Secret name is immutable, so rotating the identity means deleting and
recreating the SeiNode — and announcing the new NodeID. There is no fast
path for a leaked key: the old NodeID stays dialable until every client
ships a new default, so a thief keeps impersonating a bootstrap anchor.

Give each seed a distinct Secret. Nothing rejects two seeds sharing one,
and they would present the same NodeID — collapsing the redundant anchors
into a single entry in every dialer's peer store.
properties:
secret:
description: |-
Secret loads the node key from a Kubernetes Secret in the
SeiNode's namespace.
properties:
secretName:
description: |-
SecretName is the name of a Secret in the SeiNode's namespace.
The controller never creates, mutates, or deletes this Secret.
Immutable: re-pointing the node ID on a running validator costs
peer reputation and forces a peer-graph reset; force delete-and-recreate.
maxLength: 253
minLength: 1
pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$
type: string
x-kubernetes-validations:
- message: secretName is immutable
rule: self == oldSelf
required:
- secretName
type: object
type: object
x-kubernetes-validations:
- message: exactly one node key source must be set
rule: '(has(self.secret) ? 1 : 0) == 1'
required:
- nodeKey
type: object
sidecar:
description: Sidecar configures the sei-sidecar container.
properties:
Expand Down Expand Up @@ -827,10 +881,11 @@ spec:
- image
type: object
x-kubernetes-validations:
- message: exactly one of fullNode, archive, replayer, or validator must
be set
- message: exactly one of fullNode, archive, replayer, validator, or seed
must be set
rule: '(has(self.fullNode) ? 1 : 0) + (has(self.archive) ? 1 : 0) +
(has(self.replayer) ? 1 : 0) + (has(self.validator) ? 1 : 0) == 1'
(has(self.replayer) ? 1 : 0) + (has(self.validator) ? 1 : 0) + (has(self.seed)
? 1 : 0) == 1'
- message: peers is required when replayer mode is set
rule: '!has(self.replayer) || (has(self.peers) && size(self.peers) >
0)'
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.23
github.com/sei-protocol/sei-config v0.0.24
github.com/sei-protocol/seictl v0.0.68
go.opentelemetry.io/otel v1.43.0
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.43.0
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,8 @@ github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
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/sei-config v0.0.24 h1:DqjehEjC24E7/vVQR6EDPjLOdOUOCegxFwZAeB7S23k=
github.com/sei-protocol/sei-config v0.0.24/go.mod h1:zcEdLzyIH2AyP0/QRBE3s4Y9eGn0C/qAUx1c4o4EROU=
github.com/sei-protocol/seictl v0.0.68 h1:dCT94Ys4OjiPt5Y6TWqtgJ8GKTiWv7tN87D8HqIQxbk=
github.com/sei-protocol/seictl v0.0.68/go.mod h1:kI3HIAIWzuJSme8LqJH1WZiITrSIx5yDtJJea07Xt8Y=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
Expand Down
9 changes: 9 additions & 0 deletions internal/controller/node/controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,15 @@ func (r *SeiNodeReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ct
holdForWorkflow := node.Status.AdoptedWorkflow != nil && !adoptedWorkflowParkedFailed(node)
if !holdInitialSTS && !holdForWorkflow {
if err := r.reconcileStatefulSet(ctx, node); err != nil {
// This return precedes the status flush, so a render failure leaves no
// phase and no condition behind — the operator would see a SeiNode
// stuck with nothing explaining it. Emit an Event so the reason reaches
// `kubectl describe` rather than only the controller log. Reachable on
// operator-supplied app-config: infra fields load once at startup, so a
// seed applied before the controller restarts renders against the old
// config.
r.Recorder.Eventf(node, corev1.EventTypeWarning, "StatefulSetRenderFailed",
"Cannot render the StatefulSet: %v", err)
return ctrl.Result{}, fmt.Errorf("reconciling statefulset: %w", err)
}
}
Expand Down
Loading
Loading