Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
66 changes: 64 additions & 2 deletions api/v1alpha1/seinodetask_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import (

// SeiNodeTaskKind discriminates the SeiNodeTask spec union. Exactly one of
// the matching payload sub-structs in SeiNodeTaskSpec must be set.
// +kubebuilder:validation:Enum=GovSoftwareUpgrade;GovVote;GovParamChange;AwaitCondition;UpdateNodeImage;AwaitNodesAtHeight;RestartSeid;MarkReady
// +kubebuilder:validation:Enum=GovSoftwareUpgrade;GovVote;GovParamChange;AwaitCondition;UpdateNodeImage;AwaitNodesAtHeight;RestartSeid;MarkReady;ConfigPatch
type SeiNodeTaskKind string

const (
Expand Down Expand Up @@ -68,6 +68,27 @@ const (
// accepts the request, a beat before /v0/healthz serves 200. Gate on the node
// actually serving with a following AwaitCondition/AwaitNodesAtHeight step.
SeiNodeTaskKindMarkReady SeiNodeTaskKind = "MarkReady"

// SeiNodeTaskKindConfigPatch applies an incremental config patch to the target
// node's on-disk config via the sidecar's config-apply task. The payload uses
// the SAME operator-facing dotted sei-config key schema as SeiNodeSpec.Overrides
// (e.g. "giga_executor.enabled"); the sidecar's sei-config resolver reads the
// current on-disk config, applies the overrides (registry-aware, covering every
// registered key), and re-renders config.toml / app.toml — so rendering is
// correct-by-construction, not a controller-side render table.
//
// PATCHING ONLY — this kind does NOT restart seid or reload config. seid
// re-reads config.toml/app.toml only on (re)start, so a caller composes a
// following RestartSeid (in-place seid bounce) or StateSync/re-bootstrap step
// to make the change take effect. Completion = the sidecar acks the patch
// written to disk (idempotent: re-running rewrites the same keys).
//
// Scope is a fail-closed ALLOWLIST (see ConfigPatchPayload.Overrides): only
// runtime-safe toggles are permitted, and keys the controller re-asserts on
// its own update plans (p2p peers/external-address, genesis/state-sync-managed
// keys) are rejected — a patch to those would be silently clobbered by the
// next SeiNode update plan.
SeiNodeTaskKindConfigPatch SeiNodeTaskKind = "ConfigPatch"
)

// SeiNodeTaskPhase is the high-level lifecycle state of a SeiNodeTask.
Expand Down Expand Up @@ -112,7 +133,7 @@ const (
// Field names locked at v1alpha1 — see https://github.com/sei-protocol/bdchatham-designs/blob/main/designs/seinode-task/seinode-task-lld.md
// (PR sei-protocol/sei-k8s-controller#277).
//
// +kubebuilder:validation:XValidation:rule="(has(self.govSoftwareUpgrade) ? 1 : 0) + (has(self.govVote) ? 1 : 0) + (has(self.govParamChange) ? 1 : 0) + (has(self.awaitCondition) ? 1 : 0) + (has(self.updateNodeImage) ? 1 : 0) + (has(self.awaitNodesAtHeight) ? 1 : 0) + (has(self.restartSeid) ? 1 : 0) + (has(self.markReady) ? 1 : 0) == 1",message="exactly one of govSoftwareUpgrade, govVote, govParamChange, awaitCondition, updateNodeImage, awaitNodesAtHeight, restartSeid, or markReady must be set"
// +kubebuilder:validation:XValidation:rule="(has(self.govSoftwareUpgrade) ? 1 : 0) + (has(self.govVote) ? 1 : 0) + (has(self.govParamChange) ? 1 : 0) + (has(self.awaitCondition) ? 1 : 0) + (has(self.updateNodeImage) ? 1 : 0) + (has(self.awaitNodesAtHeight) ? 1 : 0) + (has(self.restartSeid) ? 1 : 0) + (has(self.markReady) ? 1 : 0) + (has(self.configPatch) ? 1 : 0) == 1",message="exactly one of govSoftwareUpgrade, govVote, govParamChange, awaitCondition, updateNodeImage, awaitNodesAtHeight, restartSeid, markReady, or configPatch must be set"
// +kubebuilder:validation:XValidation:rule="self.kind != 'GovSoftwareUpgrade' || has(self.govSoftwareUpgrade)",message="spec.govSoftwareUpgrade is required when kind=GovSoftwareUpgrade"
// +kubebuilder:validation:XValidation:rule="self.kind != 'GovVote' || has(self.govVote)",message="spec.govVote is required when kind=GovVote"
// +kubebuilder:validation:XValidation:rule="self.kind != 'GovParamChange' || has(self.govParamChange)",message="spec.govParamChange is required when kind=GovParamChange"
Expand All @@ -121,6 +142,8 @@ const (
// +kubebuilder:validation:XValidation:rule="self.kind != 'AwaitNodesAtHeight' || has(self.awaitNodesAtHeight)",message="spec.awaitNodesAtHeight is required when kind=AwaitNodesAtHeight"
// +kubebuilder:validation:XValidation:rule="self.kind != 'RestartSeid' || has(self.restartSeid)",message="spec.restartSeid is required when kind=RestartSeid"
// +kubebuilder:validation:XValidation:rule="self.kind != 'MarkReady' || has(self.markReady)",message="spec.markReady is required when kind=MarkReady"
// +kubebuilder:validation:XValidation:rule="self.kind != 'ConfigPatch' || has(self.configPatch)",message="spec.configPatch is required when kind=ConfigPatch"
// +kubebuilder:validation:XValidation:rule="!has(self.configPatch) || self.configPatch.overrides.all(k, k == 'chain.occ_enabled' || k.startsWith('giga_executor.') || k.startsWith('mempool.'))",message="spec.configPatch.overrides only admits allowlisted runtime-safe keys (chain.occ_enabled, giga_executor.*, mempool.*)"
// +kubebuilder:validation:XValidation:rule="self.kind == oldSelf.kind",message="spec.kind is immutable"
type SeiNodeTaskSpec struct {
// Kind selects the task implementation. Immutable after creation.
Expand Down Expand Up @@ -175,6 +198,10 @@ type SeiNodeTaskSpec struct {
// MarkReady is the payload for kind=MarkReady.
// +optional
MarkReady *MarkReadyPayload `json:"markReady,omitempty"`

// ConfigPatch is the payload for kind=ConfigPatch.
// +optional
ConfigPatch *ConfigPatchPayload `json:"configPatch,omitempty"`
}

// SeiNodeTaskTarget identifies the single SeiNode this task operates on.
Expand Down Expand Up @@ -445,6 +472,41 @@ type RestartSeidPayload struct{}
// SeiNodeTaskKindMarkReady doc comment for the use case.
type MarkReadyPayload struct{}

// ConfigPatchPayload is the payload for kind=ConfigPatch — a set of dotted
// sei-config key overrides to merge-patch into the target node's on-disk config.
//
// VALUE CONTRACT — THE ALLOWLIST (fail-closed; keep in sync across the three
// enforcement points below). Only these runtime-safe toggle families are
// admitted; everything else is rejected:
//
// chain.occ_enabled — OCC concurrency toggle
// giga_executor.* — v2 (giga) executor toggles, incl. giga_executor.enabled
// mempool.* — mempool sizing/TTL toggles
//
// Deliberately EXCLUDED (the controller re-asserts these on its own SeiNode
// update plans, so a patch here would be silently clobbered): p2p peers
// (network.p2p.persistent_peers), p2p.external-address, and genesis /
// state-sync-managed keys.
//
// The allowlist is pure allow/deny POLICY on key prefixes, not a renderer:
// sei-config resolves and renders every registered key on the node, but only
// these families are safe to patch out-of-band. Enforcement is layered:
// (1) a coarse family-level CEL rule on SeiNodeTaskSpec rejects out-of-family
// keys at admission; (2) the controller re-enforces the same allowlist policy
// when it synthesizes the config-apply task, and the sidecar's sei-config
// resolver additionally rejects unknown fields and type-mismatched values;
// (3) the SDK re-checks family membership client-side for a fast clean error.
// Widening this allowlist is a value-contract change — review the three points
// together.
type ConfigPatchPayload struct {
// Overrides maps dotted sei-config keys (the same operator-facing schema as
// SeiNodeSpec.Overrides, e.g. "giga_executor.enabled": "false") to their
// desired string values. Values are coerced to each field's registered type
// (bool/int/…) when rendered to TOML. At least one entry is required.
// +kubebuilder:validation:MinProperties=1
Overrides map[string]string `json:"overrides"`
}

// ---------------------------------------------------------------------------
// Status
// ---------------------------------------------------------------------------
Expand Down
27 changes: 27 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.

27 changes: 25 additions & 2 deletions config/crd/sei.io_seinodetasks.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,22 @@ spec:
required:
- targetHeight
type: object
configPatch:
description: ConfigPatch is the payload for kind=ConfigPatch.
properties:
overrides:
additionalProperties:
type: string
description: |-
Overrides maps dotted sei-config keys (the same operator-facing schema as
SeiNodeSpec.Overrides, e.g. "giga_executor.enabled": "false") to their
desired string values. Values are coerced to each field's registered type
(bool/int/…) when rendered to TOML. At least one entry is required.
minProperties: 1
type: object
required:
- overrides
type: object
govParamChange:
description: GovParamChange is the payload for kind=GovParamChange.
properties:
Expand Down Expand Up @@ -338,6 +354,7 @@ spec:
- AwaitNodesAtHeight
- RestartSeid
- MarkReady
- ConfigPatch
type: string
markReady:
description: MarkReady is the payload for kind=MarkReady.
Expand Down Expand Up @@ -416,12 +433,12 @@ spec:
x-kubernetes-validations:
- message: exactly one of govSoftwareUpgrade, govVote, govParamChange,
awaitCondition, updateNodeImage, awaitNodesAtHeight, restartSeid,
or markReady must be set
markReady, or configPatch must be set
rule: '(has(self.govSoftwareUpgrade) ? 1 : 0) + (has(self.govVote) ?
1 : 0) + (has(self.govParamChange) ? 1 : 0) + (has(self.awaitCondition)
? 1 : 0) + (has(self.updateNodeImage) ? 1 : 0) + (has(self.awaitNodesAtHeight)
? 1 : 0) + (has(self.restartSeid) ? 1 : 0) + (has(self.markReady)
? 1 : 0) == 1'
? 1 : 0) + (has(self.configPatch) ? 1 : 0) == 1'
- message: spec.govSoftwareUpgrade is required when kind=GovSoftwareUpgrade
rule: self.kind != 'GovSoftwareUpgrade' || has(self.govSoftwareUpgrade)
- message: spec.govVote is required when kind=GovVote
Expand All @@ -438,6 +455,12 @@ spec:
rule: self.kind != 'RestartSeid' || has(self.restartSeid)
- message: spec.markReady is required when kind=MarkReady
rule: self.kind != 'MarkReady' || has(self.markReady)
- message: spec.configPatch is required when kind=ConfigPatch
rule: self.kind != 'ConfigPatch' || has(self.configPatch)
- message: spec.configPatch.overrides only admits allowlisted runtime-safe
keys (chain.occ_enabled, giga_executor.*, mempool.*)
rule: '!has(self.configPatch) || self.configPatch.overrides.all(k, k
== ''chain.occ_enabled'' || k.startsWith(''giga_executor.'') || k.startsWith(''mempool.''))'
- message: spec.kind is immutable
rule: self.kind == oldSelf.kind
status:
Expand Down
6 changes: 6 additions & 0 deletions internal/controller/nodetask/controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,10 @@ const (
// sidecar SIGTERMs seid (up to ~90s graceful), then polls seid's RPC back up.
defaultRestartSeidTimeout = 10 * time.Minute
defaultMarkReadyTimeout = 2 * time.Minute
// defaultConfigPatchTimeout bounds a config-patch: a quick TOML merge-and-
// write on the sidecar. A generous 2m guards against a wedged sidecar
// pinning the task unbounded, while leaving ample room for the write + poll.
defaultConfigPatchTimeout = 2 * time.Minute
)

// resultRequeueImmediate mirrors planner.ResultRequeueImmediate without
Expand Down Expand Up @@ -376,6 +380,8 @@ func effectiveTimeout(cr *seiv1alpha1.SeiNodeTask) time.Duration {
return defaultRestartSeidTimeout
case seiv1alpha1.SeiNodeTaskKindMarkReady:
return defaultMarkReadyTimeout
case seiv1alpha1.SeiNodeTaskKindConfigPatch:
return defaultConfigPatchTimeout
default:
return 0
}
Expand Down
48 changes: 48 additions & 0 deletions internal/controller/nodetask/envtest/cel_validation_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,54 @@ func TestCEL_MarkReady_MultiplePayloads_Rejected(t *testing.T) {
g.Expect(err.Error()).To(ContainSubstring("exactly one"))
}

// ConfigPatch with an allowlisted key is accepted.
func TestCEL_ConfigPatch_Accepted(t *testing.T) {
g := NewWithT(t)
ns := makeNamespace(t)
snt := baseTask(ns, "configpatch-ok", seiv1alpha1.SeiNodeTaskKindConfigPatch)
snt.Spec.ConfigPatch = &seiv1alpha1.ConfigPatchPayload{
Overrides: map[string]string{"giga_executor.enabled": "false"},
}
g.Expect(testCli.Create(testCtx, snt)).To(Succeed())
}

// ConfigPatch with an empty overrides map is rejected (MinProperties=1).
func TestCEL_ConfigPatch_EmptyOverrides_Rejected(t *testing.T) {
g := NewWithT(t)
ns := makeNamespace(t)
snt := baseTask(ns, "configpatch-empty", seiv1alpha1.SeiNodeTaskKindConfigPatch)
snt.Spec.ConfigPatch = &seiv1alpha1.ConfigPatchPayload{Overrides: map[string]string{}}
err := testCli.Create(testCtx, snt)
g.Expect(err).To(HaveOccurred())
}

// ConfigPatch with an out-of-allowlist key is rejected by the family-level CEL
// rule (before the controller's authoritative renderer ever runs).
func TestCEL_ConfigPatch_DisallowedKey_Rejected(t *testing.T) {
g := NewWithT(t)
ns := makeNamespace(t)
snt := baseTask(ns, "configpatch-disallowed", seiv1alpha1.SeiNodeTaskKindConfigPatch)
snt.Spec.ConfigPatch = &seiv1alpha1.ConfigPatchPayload{
Overrides: map[string]string{"network.p2p.persistent_peers": "a@1.2.3.4:26656"},
}
err := testCli.Create(testCtx, snt)
g.Expect(err).To(HaveOccurred())
g.Expect(err.Error()).To(ContainSubstring("runtime-safe"))
}

// kind=ConfigPatch with NO payload is rejected.
func TestCEL_ConfigPatch_NoPayload_Rejected(t *testing.T) {
g := NewWithT(t)
ns := makeNamespace(t)
snt := baseTask(ns, "configpatch-nopayload", seiv1alpha1.SeiNodeTaskKindConfigPatch)
err := testCli.Create(testCtx, snt)
g.Expect(err).To(HaveOccurred())
g.Expect(err.Error()).To(Or(
ContainSubstring("exactly one"),
ContainSubstring("configPatch is required"),
))
}

// kind=RestartSeid with NO payload is rejected.
func TestCEL_RestartSeid_NoPayload_Rejected(t *testing.T) {
g := NewWithT(t)
Expand Down
55 changes: 55 additions & 0 deletions internal/task/seinodetask_params.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,11 @@ import (
"encoding/json"
"errors"
"fmt"
"maps"
"slices"
"strings"

seiconfig "github.com/sei-protocol/sei-config"
sidecar "github.com/sei-protocol/seictl/sidecar/client"

seiv1alpha1 "github.com/sei-protocol/sei-k8s-controller/api/v1alpha1"
Expand Down Expand Up @@ -94,6 +98,8 @@ func SeiNodeTaskParamsFor(cr *seiv1alpha1.SeiNodeTask, target *seiv1alpha1.SeiNo
return restartSeidParams(cr)
case seiv1alpha1.SeiNodeTaskKindMarkReady:
return markReadyParams(cr)
case seiv1alpha1.SeiNodeTaskKindConfigPatch:
return configPatchParams(cr)
default:
return SeiNodeTaskParams{}, &ErrUnsupportedKind{Kind: cr.Spec.Kind}
}
Expand Down Expand Up @@ -225,6 +231,55 @@ func markReadyParams(cr *seiv1alpha1.SeiNodeTask) (SeiNodeTaskParams, error) {
return SeiNodeTaskParams{sidecar.TaskTypeMarkReady, sidecar.MarkReadyTask{}}, nil
}

// configPatchParams synthesizes an incremental config-apply sidecar task from
// the payload's dotted sei-config overrides. sei-config's own resolver on the
// node (ResolveIncrementalIntent -> registry-aware ApplyOverrides -> the legacy
// renderer) turns the overrides into on-disk config, so the controller no longer
// hand-renders a files tree — that path is correct-by-construction for every
// registered key. Mode is left empty: incremental resolution reads the node's
// current on-disk config and patches it, so no mode default is needed.
//
// The controller still enforces the runtime-safe ALLOWLIST as pure policy —
// sei-config's registry only checks that a key exists and type-checks its value,
// and would happily apply an unsafe key (e.g. network.p2p.persistent_peers) the
// controller re-asserts on its own SeiNode update plans. A disallowed key
// surfaces as ParamsBuildFailed (not a remote round-trip). Keys are checked in
// sorted order so a multi-key input reports a stable first offender.
//
// This does NOT restart or reload seid; the caller composes RestartSeid /
// StateSync to make the change take effect.
func configPatchParams(cr *seiv1alpha1.SeiNodeTask) (SeiNodeTaskParams, error) {
p := cr.Spec.ConfigPatch
if p == nil {
return SeiNodeTaskParams{}, paramsErr("spec.configPatch is required for kind=ConfigPatch")
}
if len(p.Overrides) == 0 {
return SeiNodeTaskParams{}, paramsErr("config-patch: overrides must not be empty")
}
for _, key := range slices.Sorted(maps.Keys(p.Overrides)) {
if !configPatchKeyAllowed(key) {
return SeiNodeTaskParams{}, paramsErr(fmt.Sprintf(
"config-patch: key %q is not in the runtime-safe allowlist (chain.occ_enabled, giga_executor.*, mempool.*)", key))
}
}
return SeiNodeTaskParams{sidecar.TaskTypeConfigApply, &seiconfig.ConfigIntent{
Incremental: true,
Overrides: p.Overrides,
}}, nil
}

// configPatchKeyAllowed reports whether a dotted sei-config override key is in
// the ConfigPatch runtime-safe allowlist. It is pure allow/deny POLICY on key
// prefixes, not a renderer: sei-config resolves and renders every registered key,
// but the controller admits only families that are safe to patch out-of-band.
// Keep this in sync with the CEL family rule on SeiNodeTaskSpec and
// sdk/sei.configPatchKeyAllowed — it is a value contract across three points.
func configPatchKeyAllowed(key string) bool {
return key == "chain.occ_enabled" ||
strings.HasPrefix(key, "giga_executor.") ||
strings.HasPrefix(key, "mempool.")
}

// resolveSigningUID returns explicit when set; otherwise derives from target
// via ResolveOperatorKeyringUID. With nil target (early-validation path), the
// final marshal happens later from driveTask, which has target.
Expand Down
Loading
Loading