From 522177156e97a840bedaae7b26237905a8af536a Mon Sep 17 00:00:00 2001 From: bdchatham Date: Mon, 20 Apr 2026 19:48:36 -0700 Subject: [PATCH] feat: wrap ResultExportConfig around use-case sub-structs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refactors the flat ResultExportConfig into a wrapper around use-case sub-structs, following the pattern merged in #108. First (and currently only) use case is ShadowResultConfig, which carries the CanonicalRPC endpoint for app-hash divergence detection. Removes stale "monitor mode" / "scheduled mode" godoc that described a scheduled-export behavior that was never reachable (canonicalRpc is CRD-required via MinLength=1, so "without canonicalRpc" was never a valid input). Adds cross-field validation to replayerPlanner.Validate rejecting an empty resultExport wrapper, mirroring validateSnapshotGeneration at planner.go:331. Context-free helper, caller wraps with replayer: prefix — no shared helper takes a mode parameter. No sidecar code currently consumes ResultExport (was removed with the monitor-task subsystem in #89); this is a schema-only refactor to prepare for future use-case sub-structs without needing to rename existing fields. Co-Authored-By: Claude Opus 4.7 (1M context) --- api/v1alpha1/common_types.go | 29 ++++---- api/v1alpha1/replayer_types.go | 7 +- api/v1alpha1/zz_generated.deepcopy.go | 22 +++++- config/crd/sei.io_seinodedeployments.yaml | 30 ++++---- config/crd/sei.io_seinodes.yaml | 30 ++++---- internal/planner/planner.go | 12 ++++ internal/planner/replay.go | 3 + internal/planner/replay_test.go | 70 +++++++++++++++++++ .../seinode/pacific-1-shadow-replayer.yaml | 3 +- manifests/sei.io_seinodedeployments.yaml | 30 ++++---- manifests/sei.io_seinodes.yaml | 30 ++++---- 11 files changed, 196 insertions(+), 70 deletions(-) create mode 100644 internal/planner/replay_test.go diff --git a/api/v1alpha1/common_types.go b/api/v1alpha1/common_types.go index 7f31dd5..712b39d 100644 --- a/api/v1alpha1/common_types.go +++ b/api/v1alpha1/common_types.go @@ -143,20 +143,25 @@ type TendermintSnapshotGenerationConfig struct { // override, prefix) without a breaking change. type TendermintSnapshotPublishConfig struct{} -// ResultExportConfig enables export of block execution results to S3. -// The sidecar queries the local RPC endpoint for block results and uploads -// them in compressed NDJSON pages to the platform S3 bucket, keyed by the -// node's chain ID. -// -// When CanonicalRPC is set, the sidecar additionally compares local results -// against the canonical chain and the task completes when app-hash divergence -// is detected (monitor mode). Without CanonicalRPC, results are exported -// periodically on a cron schedule (scheduled mode). +// ResultExportConfig configures the node to export block-execution results. +// One or more use-case sub-structs may be enabled. An empty ResultExportConfig +// (no sub-struct set) is rejected by the planner as a likely user typo. type ResultExportConfig struct { + // ShadowResult configures the node to generate and export shadow-result + // pages, comparing local block execution results against a canonical + // chain via app-hash divergence detection. + // +optional + ShadowResult *ShadowResultConfig `json:"shadowResult,omitempty"` +} + +// ShadowResultConfig configures shadow-result generation. The sidecar queries +// the local RPC endpoint for block_results, uploads them in compressed NDJSON +// pages to the platform result-export bucket, and compares each page's app-hash +// against the canonical chain. The export task completes when divergence is +// detected. +type ShadowResultConfig struct { // CanonicalRPC is the HTTP RPC endpoint of the canonical chain node - // to compare block execution results against. When set, the sidecar - // runs in comparison mode and the task completes when app-hash - // divergence is detected. + // to compare block-execution results against. // +kubebuilder:validation:MinLength=1 CanonicalRPC string `json:"canonicalRpc"` } diff --git a/api/v1alpha1/replayer_types.go b/api/v1alpha1/replayer_types.go index 361fc98..cd73a29 100644 --- a/api/v1alpha1/replayer_types.go +++ b/api/v1alpha1/replayer_types.go @@ -8,10 +8,9 @@ type ReplayerSpec struct { // Snapshot identifies the snapshot to restore from before replay begins. Snapshot SnapshotSource `json:"snapshot"` - // ResultExport configures periodic export of block execution results to S3. - // The sidecar queries the local RPC for block_results and uploads compressed - // NDJSON pages on a schedule. Useful for shadow replayers that need their - // execution results compared against the canonical chain. + // ResultExport configures block-execution result export. Select one or more + // sub-structs (e.g., shadowResult) to enable an export mode. Useful for + // shadow replayers that compare execution results against the canonical chain. // +optional ResultExport *ResultExportConfig `json:"resultExport,omitempty"` } diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index 1dab8ca..c403767 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -399,7 +399,7 @@ func (in *ReplayerSpec) DeepCopyInto(out *ReplayerSpec) { if in.ResultExport != nil { in, out := &in.ResultExport, &out.ResultExport *out = new(ResultExportConfig) - **out = **in + (*in).DeepCopyInto(*out) } } @@ -416,6 +416,11 @@ func (in *ReplayerSpec) DeepCopy() *ReplayerSpec { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ResultExportConfig) DeepCopyInto(out *ResultExportConfig) { *out = *in + if in.ShadowResult != nil { + in, out := &in.ShadowResult, &out.ShadowResult + *out = new(ShadowResultConfig) + **out = **in + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ResultExportConfig. @@ -860,6 +865,21 @@ func (in *ServiceMonitorConfig) DeepCopy() *ServiceMonitorConfig { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ShadowResultConfig) DeepCopyInto(out *ShadowResultConfig) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ShadowResultConfig. +func (in *ShadowResultConfig) DeepCopy() *ShadowResultConfig { + if in == nil { + return nil + } + out := new(ShadowResultConfig) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *SidecarConfig) DeepCopyInto(out *SidecarConfig) { *out = *in diff --git a/config/crd/sei.io_seinodedeployments.yaml b/config/crd/sei.io_seinodedeployments.yaml index 16789d9..023e679 100644 --- a/config/crd/sei.io_seinodedeployments.yaml +++ b/config/crd/sei.io_seinodedeployments.yaml @@ -457,21 +457,25 @@ spec: properties: resultExport: description: |- - ResultExport configures periodic export of block execution results to S3. - The sidecar queries the local RPC for block_results and uploads compressed - NDJSON pages on a schedule. Useful for shadow replayers that need their - execution results compared against the canonical chain. + ResultExport configures block-execution result export. Select one or more + sub-structs (e.g., shadowResult) to enable an export mode. Useful for + shadow replayers that compare execution results against the canonical chain. properties: - canonicalRpc: + shadowResult: description: |- - CanonicalRPC is the HTTP RPC endpoint of the canonical chain node - to compare block execution results against. When set, the sidecar - runs in comparison mode and the task completes when app-hash - divergence is detected. - minLength: 1 - type: string - required: - - canonicalRpc + ShadowResult configures the node to generate and export shadow-result + pages, comparing local block execution results against a canonical + chain via app-hash divergence detection. + properties: + canonicalRpc: + description: |- + CanonicalRPC is the HTTP RPC endpoint of the canonical chain node + to compare block-execution results against. + minLength: 1 + type: string + required: + - canonicalRpc + type: object type: object snapshot: description: Snapshot identifies the snapshot to restore diff --git a/config/crd/sei.io_seinodes.yaml b/config/crd/sei.io_seinodes.yaml index 0993d90..2e52a3f 100644 --- a/config/crd/sei.io_seinodes.yaml +++ b/config/crd/sei.io_seinodes.yaml @@ -283,21 +283,25 @@ spec: properties: resultExport: description: |- - ResultExport configures periodic export of block execution results to S3. - The sidecar queries the local RPC for block_results and uploads compressed - NDJSON pages on a schedule. Useful for shadow replayers that need their - execution results compared against the canonical chain. + ResultExport configures block-execution result export. Select one or more + sub-structs (e.g., shadowResult) to enable an export mode. Useful for + shadow replayers that compare execution results against the canonical chain. properties: - canonicalRpc: + shadowResult: description: |- - CanonicalRPC is the HTTP RPC endpoint of the canonical chain node - to compare block execution results against. When set, the sidecar - runs in comparison mode and the task completes when app-hash - divergence is detected. - minLength: 1 - type: string - required: - - canonicalRpc + ShadowResult configures the node to generate and export shadow-result + pages, comparing local block execution results against a canonical + chain via app-hash divergence detection. + properties: + canonicalRpc: + description: |- + CanonicalRPC is the HTTP RPC endpoint of the canonical chain node + to compare block-execution results against. + minLength: 1 + type: string + required: + - canonicalRpc + type: object type: object snapshot: description: Snapshot identifies the snapshot to restore from diff --git a/internal/planner/planner.go b/internal/planner/planner.go index a7705ad..83039bf 100644 --- a/internal/planner/planner.go +++ b/internal/planner/planner.go @@ -337,6 +337,18 @@ func validateSnapshotGeneration(sg *seiv1alpha1.SnapshotGenerationConfig) error return nil } +// validateResultExport returns errors without a mode prefix; callers +// wrap with their own (e.g., fmt.Errorf("replayer: %w", err)). +func validateResultExport(re *seiv1alpha1.ResultExportConfig) error { + if re == nil { + return nil + } + if re.ShadowResult == nil { + return fmt.Errorf("resultExport is set but has no sub-struct (e.g., shadowResult); omit it to disable result export") + } + return nil +} + func hasS3Snapshot(snap *seiv1alpha1.SnapshotSource) bool { return snap != nil && snap.S3 != nil } diff --git a/internal/planner/replay.go b/internal/planner/replay.go index 830492a..22e51f0 100644 --- a/internal/planner/replay.go +++ b/internal/planner/replay.go @@ -28,6 +28,9 @@ func (p *replayerPlanner) Validate(node *seiv1alpha1.SeiNode) error { if len(node.Spec.Peers) == 0 { return fmt.Errorf("replayer requires at least one peer source for block sync") } + if err := validateResultExport(node.Spec.Replayer.ResultExport); err != nil { + return fmt.Errorf("replayer: %w", err) + } return nil } diff --git a/internal/planner/replay_test.go b/internal/planner/replay_test.go new file mode 100644 index 0000000..f91c84a --- /dev/null +++ b/internal/planner/replay_test.go @@ -0,0 +1,70 @@ +package planner + +import ( + "strings" + "testing" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + seiv1alpha1 "github.com/sei-protocol/sei-k8s-controller/api/v1alpha1" +) + +func TestReplayerPlanner_Validate_ResultExport(t *testing.T) { + cases := []struct { + name string + re *seiv1alpha1.ResultExportConfig + wantErr string + }{ + { + name: "nil is fine", + re: nil, + }, + { + name: "shadowResult set is fine", + re: &seiv1alpha1.ResultExportConfig{ + ShadowResult: &seiv1alpha1.ShadowResultConfig{ + CanonicalRPC: "http://rpc.pacific-1.example.com:26657", + }, + }, + }, + { + name: "empty resultExport is rejected", + re: &seiv1alpha1.ResultExportConfig{}, + wantErr: "replayer: resultExport is set but has no sub-struct", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + node := &seiv1alpha1.SeiNode{ + ObjectMeta: metav1.ObjectMeta{Name: "replayer-0", Namespace: "pacific-1"}, + Spec: seiv1alpha1.SeiNodeSpec{ + ChainID: "pacific-1", + Image: "seid:v6.4.1", + Peers: []seiv1alpha1.PeerSource{ + {Static: &seiv1alpha1.StaticPeerSource{Addresses: []string{"peer1@host:26656"}}}, + }, + Replayer: &seiv1alpha1.ReplayerSpec{ + Snapshot: seiv1alpha1.SnapshotSource{ + S3: &seiv1alpha1.S3SnapshotSource{TargetHeight: 100000000}, + }, + ResultExport: tc.re, + }, + }, + } + err := (&replayerPlanner{}).Validate(node) + if tc.wantErr == "" { + if err != nil { + t.Fatalf("Validate: unexpected error: %v", err) + } + return + } + if err == nil { + t.Fatalf("Validate: expected error containing %q, got nil", tc.wantErr) + } + if !strings.Contains(err.Error(), tc.wantErr) { + t.Fatalf("Validate: error = %q, want containing %q", err.Error(), tc.wantErr) + } + }) + } +} diff --git a/manifests/samples/seinode/pacific-1-shadow-replayer.yaml b/manifests/samples/seinode/pacific-1-shadow-replayer.yaml index 5a2101e..8537514 100644 --- a/manifests/samples/seinode/pacific-1-shadow-replayer.yaml +++ b/manifests/samples/seinode/pacific-1-shadow-replayer.yaml @@ -39,4 +39,5 @@ spec: bootstrapImage: "ghcr.io/sei-protocol/sei:v6.3.0" trustPeriod: "9999h0m0s" resultExport: - canonicalRpc: "http://rpc.pacific-1.prod.platform.sei.io:26657" + shadowResult: + canonicalRpc: "http://rpc.pacific-1.prod.platform.sei.io:26657" diff --git a/manifests/sei.io_seinodedeployments.yaml b/manifests/sei.io_seinodedeployments.yaml index 16789d9..023e679 100644 --- a/manifests/sei.io_seinodedeployments.yaml +++ b/manifests/sei.io_seinodedeployments.yaml @@ -457,21 +457,25 @@ spec: properties: resultExport: description: |- - ResultExport configures periodic export of block execution results to S3. - The sidecar queries the local RPC for block_results and uploads compressed - NDJSON pages on a schedule. Useful for shadow replayers that need their - execution results compared against the canonical chain. + ResultExport configures block-execution result export. Select one or more + sub-structs (e.g., shadowResult) to enable an export mode. Useful for + shadow replayers that compare execution results against the canonical chain. properties: - canonicalRpc: + shadowResult: description: |- - CanonicalRPC is the HTTP RPC endpoint of the canonical chain node - to compare block execution results against. When set, the sidecar - runs in comparison mode and the task completes when app-hash - divergence is detected. - minLength: 1 - type: string - required: - - canonicalRpc + ShadowResult configures the node to generate and export shadow-result + pages, comparing local block execution results against a canonical + chain via app-hash divergence detection. + properties: + canonicalRpc: + description: |- + CanonicalRPC is the HTTP RPC endpoint of the canonical chain node + to compare block-execution results against. + minLength: 1 + type: string + required: + - canonicalRpc + type: object type: object snapshot: description: Snapshot identifies the snapshot to restore diff --git a/manifests/sei.io_seinodes.yaml b/manifests/sei.io_seinodes.yaml index 0993d90..2e52a3f 100644 --- a/manifests/sei.io_seinodes.yaml +++ b/manifests/sei.io_seinodes.yaml @@ -283,21 +283,25 @@ spec: properties: resultExport: description: |- - ResultExport configures periodic export of block execution results to S3. - The sidecar queries the local RPC for block_results and uploads compressed - NDJSON pages on a schedule. Useful for shadow replayers that need their - execution results compared against the canonical chain. + ResultExport configures block-execution result export. Select one or more + sub-structs (e.g., shadowResult) to enable an export mode. Useful for + shadow replayers that compare execution results against the canonical chain. properties: - canonicalRpc: + shadowResult: description: |- - CanonicalRPC is the HTTP RPC endpoint of the canonical chain node - to compare block execution results against. When set, the sidecar - runs in comparison mode and the task completes when app-hash - divergence is detected. - minLength: 1 - type: string - required: - - canonicalRpc + ShadowResult configures the node to generate and export shadow-result + pages, comparing local block execution results against a canonical + chain via app-hash divergence detection. + properties: + canonicalRpc: + description: |- + CanonicalRPC is the HTTP RPC endpoint of the canonical chain node + to compare block-execution results against. + minLength: 1 + type: string + required: + - canonicalRpc + type: object type: object snapshot: description: Snapshot identifies the snapshot to restore from