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
20 changes: 20 additions & 0 deletions cmd/maintainer.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,10 +44,30 @@ func init() {
)
}

// validateMaintainerConfig checks the maintainer configuration before any
// chain connection is attempted, so a misconfiguration fails fast and loudly
// at startup instead of degrading into silent runtime behavior. It delegates
// to maintainer.Config.Validate so the command-level check and the
// config-load check (config.ReadConfig) share one rule: SPV settings are
// validated only when the SPV maintainer will actually run (explicitly
// enabled, or neither maintainer enabled).
func validateMaintainerConfig(cfg *config.Config) error {
if err := cfg.Maintainer.Validate(); err != nil {
return fmt.Errorf("invalid maintainer configuration: [%v]", err)
}

return nil
}

// maintainers initializes maintainer tasks specified by flags passed to the
// maintainer command.
func maintainers(cmd *cobra.Command, args []string) error {
ctx := context.Background()

if err := validateMaintainerConfig(clientConfig); err != nil {
return err
}

btcChain, err := electrum.Connect(ctx, clientConfig.Bitcoin.Electrum)
if err != nil {
return fmt.Errorf("could not connect to Electrum chain: [%v]", err)
Expand Down
4 changes: 2 additions & 2 deletions cmd/maintainer_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -167,8 +167,8 @@ func TestInitializeMaintainerMetricsEnabledWhenPortSet(t *testing.T) {
defer func() { clientConfig.ClientInfo.Port = originalPort }()

// Reserve a genuinely free ephemeral port from the OS and release it
// immediately. clientinfo.Initialize binds this port on
// http.DefaultServeMux via an unowned ListenAndServe goroutine with no
// immediately. clientinfo.Initialize binds this port on a private
// per-registry ServeMux via an unowned ListenAndServe goroutine with no
// shutdown handle, so a hardcoded port risks colliding with another
// process or a parallel test run.
listener, err := net.Listen("tcp", "127.0.0.1:0")
Expand Down
108 changes: 108 additions & 0 deletions cmd/maxproofheaders_flag_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
package cmd

import (
"strings"
"testing"

"github.com/spf13/cobra"

"github.com/keep-network/keep-core/config"
)

// TestMaintainerConfig_RejectsExplicitZeroMaxProofHeaders asserts that an
// explicit `--spv.maxProofHeaders 0` is rejected by the same validation the
// maintainer command runs at startup.
//
// The flag is registered with cobra's UintVar, which accepts 0 as a valid
// unsigned value, so the 144 default only protects the case where the flag is
// omitted entirely. Without validation a zero bound reaches the proof
// assembly loop, where getProofInfo skips every transaction, disabling SPV
// proving. A test exercising only the omitted-flag default would pass
// regardless and mask this.
//
// The test drives validateMaintainerConfig, which delegates to
// maintainer.Config.Validate - the same rule the config-load pass applies.
// A fresh flag-initialized config has neither maintainer enabled, so the
// launch-all branch validates the SPV settings and reports the offending
// field.
func TestMaintainerConfig_RejectsExplicitZeroMaxProofHeaders(t *testing.T) {
command := &cobra.Command{Use: "maintainer-test"}
cfg := &config.Config{}
initMaintainerFlags(command, cfg)

if err := command.Flags().Parse(
[]string{"--spv.maxProofHeaders", "0"},
); err != nil {
t.Fatalf("failed to parse flags: [%v]", err)
}

// Establishes that zero is genuinely reachable through the CLI rather than
// being rejected by flag parsing itself.
if got := cfg.Maintainer.Spv.MaxProofHeaders; got != 0 {
t.Fatalf("expected the flag to parse zero into config, got [%d]", got)
}

err := validateMaintainerConfig(cfg)
if err == nil {
t.Fatal(
"expected startup validation to reject --spv.maxProofHeaders 0, got no error",
)
}
if !strings.Contains(err.Error(), "maxProofHeaders") {
t.Errorf(
"expected the error to name the offending setting, got: [%v]",
err,
)
}
}

// TestMaintainerConfig_AcceptsDefaultMaxProofHeaders asserts the validation
// added above does not reject a normally-configured maintainer.
func TestMaintainerConfig_AcceptsDefaultMaxProofHeaders(t *testing.T) {
command := &cobra.Command{Use: "maintainer-test"}
cfg := &config.Config{}
initMaintainerFlags(command, cfg)

if err := command.Flags().Parse([]string{}); err != nil {
t.Fatalf("failed to parse flags: [%v]", err)
}

if err := validateMaintainerConfig(cfg); err != nil {
t.Errorf(
"expected the default configuration to be accepted, got error: [%v]",
err,
)
}
}

// TestMaintainerConfig_AcceptsCustomMaxProofHeaders asserts that explicitly
// provided positive values flow through the flag -> config -> validation path,
// not just the omitted-flag default. Without these cases a regression that
// breaks non-default values (e.g. a type conversion or off-by-one in
// validation) would only surface when an operator actually deviates from the
// 144 default.
func TestMaintainerConfig_AcceptsCustomMaxProofHeaders(t *testing.T) {
values := []string{"1", "288"}

for _, value := range values {
t.Run(value, func(t *testing.T) {
command := &cobra.Command{Use: "maintainer-test"}
cfg := &config.Config{}
initMaintainerFlags(command, cfg)

if err := command.Flags().Parse(
[]string{"--spv.maxProofHeaders", value},
); err != nil {
t.Fatalf("failed to parse flags: [%v]", err)
}

if err := validateMaintainerConfig(cfg); err != nil {
t.Errorf(
"expected --spv.maxProofHeaders %s to be accepted, got error: [%v]",
value,
err,
)
}
})
}
}
54 changes: 51 additions & 3 deletions config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,11 @@ func TestValidateConfig_TransactionMonitor(t *testing.T) {
}},
Maintainer: maintainer.Config{
Spv: spv.Config{
MaxProofHeaders: spv.DefaultMaxProofHeaders,
HistoryDepth: spv.DefaultHistoryDepth,
TransactionLimit: spv.DefaultTransactionLimit,
RestartBackoffTime: spv.DefaultRestartBackoffTime,
IdleBackoffTime: spv.DefaultIdleBackOffTime,
MaxProofHeaders: spv.DefaultMaxProofHeaders,
},
},
}
Expand Down Expand Up @@ -81,7 +85,14 @@ func TestValidateConfig_Maintainer(t *testing.T) {
config: &Config{
Maintainer: maintainer.Config{
BitcoinDifficulty: btcdiff.Config{Enabled: false},
Spv: spv.Config{Enabled: false, MaxProofHeaders: spv.DefaultMaxProofHeaders},
Spv: spv.Config{
Enabled: false,
HistoryDepth: spv.DefaultHistoryDepth,
TransactionLimit: spv.DefaultTransactionLimit,
RestartBackoffTime: spv.DefaultRestartBackoffTime,
IdleBackoffTime: spv.DefaultIdleBackOffTime,
MaxProofHeaders: spv.DefaultMaxProofHeaders,
},
},
},
expectErr: false,
Expand All @@ -90,11 +101,48 @@ func TestValidateConfig_Maintainer(t *testing.T) {
config: &Config{
Maintainer: maintainer.Config{
BitcoinDifficulty: btcdiff.Config{Enabled: false},
Spv: spv.Config{Enabled: true, MaxProofHeaders: spv.DefaultMaxProofHeaders},
Spv: spv.Config{
Enabled: true,
HistoryDepth: spv.DefaultHistoryDepth,
TransactionLimit: spv.DefaultTransactionLimit,
RestartBackoffTime: spv.DefaultRestartBackoffTime,
IdleBackoffTime: spv.DefaultIdleBackOffTime,
MaxProofHeaders: spv.DefaultMaxProofHeaders,
},
},
},
expectErr: false,
},
"enabled SPV with zero historyDepth fails": {
config: &Config{
Maintainer: maintainer.Config{
BitcoinDifficulty: btcdiff.Config{Enabled: false},
Spv: spv.Config{
Enabled: true,
TransactionLimit: spv.DefaultTransactionLimit,
RestartBackoffTime: spv.DefaultRestartBackoffTime,
IdleBackoffTime: spv.DefaultIdleBackOffTime,
MaxProofHeaders: spv.DefaultMaxProofHeaders,
},
},
},
expectErr: true,
},
"enabled SPV with zero transactionLimit fails": {
config: &Config{
Maintainer: maintainer.Config{
BitcoinDifficulty: btcdiff.Config{Enabled: false},
Spv: spv.Config{
Enabled: true,
HistoryDepth: spv.DefaultHistoryDepth,
RestartBackoffTime: spv.DefaultRestartBackoffTime,
IdleBackoffTime: spv.DefaultIdleBackOffTime,
MaxProofHeaders: spv.DefaultMaxProofHeaders,
},
},
},
expectErr: true,
},
}

for testName, test := range tests {
Expand Down
17 changes: 10 additions & 7 deletions docs/release-process.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,18 +16,21 @@ changes, the project uses a long-lived aggregation PR instead of
landing everything via normal `feature → main` PRs:

- **Base:** `main`
- **Head:** a moving `dev` branch that tracks `main` by merging each
sub-PR into `dev` (and `main`) before the sub-PR closes
- **Head:** a moving `dev` branch that accumulates the cycle's work by
merging each sub-PR into `dev` before the sub-PR closes. Sub-PRs are
not merged into `main` — `main` advances only when the aggregation
PR itself merges at the end of the cycle.
- **State:** the PR stays open across the whole cycle. Its diff
against `main` is the live view of "what is still queued for the
next release."

Sub-PRs are still reviewed and CI'd independently — the aggregation
PR is just the place to watch the cumulative state. When the cycle is
ready to ship, fast-forward `dev` to the latest `main`, resolve any
final conflicts, and merge the aggregation PR into `main` as a single
merge commit. The version tag is then cut from `main` per "Creating
a Release" below.
PR is just the place to watch the cumulative state. Sub-PRs target `dev`
as their base branch; maintainers merge them into `dev` after review.
When the cycle is ready to ship, fast-forward `dev` to the latest `main`,
resolve any final conflicts, and merge the aggregation PR into `main`
as a single merge commit. The version tag is then cut from `main` per
"Creating a Release" below.

## Creating a Release

Expand Down
19 changes: 19 additions & 0 deletions pkg/clientinfo/clientinfo.go
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,14 @@ func Initialize(
cfg Config,
) (*Registry, bool) {
if cfg.Port == 0 {
if cfg.EnablePprof {
// Enabling pprof without a port would be silently ignored because
// no server is started; surface the misconfiguration instead.
logger.Warnf(
"EnablePprof is set but Port is 0; no server is started and " +
"profiling endpoints will not be exposed",
)
}
return nil, false
}

Expand All @@ -100,6 +108,15 @@ func registerPprofHandlers(mux *http.ServeMux) {
mux.HandleFunc("/debug/pprof/trace", pprof.Trace)
}

// newServeMux builds the HTTP handler served on the client info port.
//
// The mux is created per call and never shared with http.DefaultServeMux.
// That isolation is load-bearing for two reasons: importing net/http/pprof
// registers /debug/pprof/* on DefaultServeMux from that package's init
// regardless of how it is imported, so serving DefaultServeMux would expose
// profiling endpoints even when disabled; and registering this registry's own
// routes on a process-global mux makes a second registry panic on duplicate
// patterns.
func (r *Registry) newServeMux(enablePprof bool) *http.ServeMux {
mux := http.NewServeMux()

Expand Down Expand Up @@ -131,6 +148,8 @@ func (r *Registry) newServer(port int, enablePprof bool) *http.Server {
}
}

// enableServer starts the client info HTTP server, exposing the profiling
// endpoints only when enablePprof is true.
func (r *Registry) enableServer(port int, enablePprof bool) {
server := r.newServer(port, enablePprof)

Expand Down
Loading
Loading