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
18 changes: 18 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,15 @@ func (c Config) HistoryCapacity() int {
return capacity
}

// maxHistoryCapacity bounds HistoryCapacity(). NewRingBuffer allocates its
// capacity eagerly for each of the 7 scalar history series, so an
// unreasonably large window/interval ratio (e.g. a config typo like
// history_window_minutes: 525600 with poll_interval_seconds: 0.05) would
// otherwise allocate on the order of gigabytes at startup and OOM a Pi. One
// million points per series (~7M points, well under 1 GiB total) is far more
// than any real dashboard use case needs.
const maxHistoryCapacity = 1_000_000

// validLogLevels are the log levels newLogger understands; any other value
// silently falls back to info, so we reject it here instead.
var validLogLevels = map[string]bool{
Expand All @@ -208,6 +217,15 @@ func (c Config) Validate() error {
if c.HistoryWindowMinutes <= 0 {
return fmt.Errorf("history_window_minutes must be > 0 (got %v)", c.HistoryWindowMinutes)
}
// Compare the ratio in float64 before any int conversion: converting an
// out-of-range float64 to int is implementation-specific in Go and
// differs by architecture (e.g. amd64 wraps to math.MinInt64, arm64
// saturates to math.MaxInt64), so calling HistoryCapacity() first and
// comparing the resulting int could let an extreme ratio slip past this
// check on some platforms but not others.
if ratio := c.HistoryWindowMinutes * 60 / c.PollIntervalSeconds; ratio > maxHistoryCapacity {
return fmt.Errorf("history_window_minutes (%v) / poll_interval_seconds (%v) implies %v history points per series, exceeding the %d limit; increase poll_interval_seconds or reduce history_window_minutes", c.HistoryWindowMinutes, c.PollIntervalSeconds, ratio, maxHistoryCapacity)
}
if c.HistoryPersistEnabled && c.DataDir == "" {
return fmt.Errorf("data_dir must not be empty when history_persist_enabled is true")
}
Expand Down
30 changes: 30 additions & 0 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,19 @@ func TestValidate_RejectsBadValues(t *testing.T) {
{"disk warn above crit", func(c *Config) { c.Thresholds.DiskWarnPercent = 99 }},
{"swap warn above crit", func(c *Config) { c.Thresholds.SwapWarnPercent = 99 }},
{"memory warn above crit", func(c *Config) { c.Thresholds.MemoryWarnPercent = 99 }},
{"history capacity exceeds sanity cap", func(c *Config) {
c.HistoryWindowMinutes = 525600
c.PollIntervalSeconds = 0.05
}},
{"history capacity ratio overflows int64", func(c *Config) {
// Regression test: converting an out-of-range float64 to int is
// implementation-specific in Go and differs by architecture
// (amd64 wraps to a negative number, arm64 saturates to
// math.MaxInt64), so the cap must be checked on the float64
// ratio, not on HistoryCapacity()'s post-conversion int.
c.HistoryWindowMinutes = 1e300
c.PollIntervalSeconds = 1
}},
{"negative alerts for_seconds", func(c *Config) { c.Alerts.ForSeconds = -1 }},
{"negative notify max retries", func(c *Config) { c.Alerts.NotifyMaxRetries = -1 }},
{"negative notify backoff", func(c *Config) { c.Alerts.NotifyRetryBackoffSeconds = -1 }},
Expand All @@ -255,6 +268,23 @@ func TestValidate_RejectsBadValues(t *testing.T) {
}
}

func TestValidate_HistoryCapacityBoundary(t *testing.T) {
cfg := Default()
// With poll_interval_seconds: 60, HistoryCapacity() equals
// history_window_minutes exactly, giving clean integer boundaries.
cfg.PollIntervalSeconds = 60

cfg.HistoryWindowMinutes = maxHistoryCapacity // exactly at the cap
if err := cfg.Validate(); err != nil {
t.Fatalf("Validate() rejected history capacity exactly at the cap: %v", err)
}

cfg.HistoryWindowMinutes = maxHistoryCapacity + 1 // one point over
if err := cfg.Validate(); err == nil {
t.Fatal("Validate() accepted history capacity one point over the cap")
}
}

func TestValidate_AcceptsValidEdgeCases(t *testing.T) {
cfg := Default()
// Warn equal to crit and a zero stale threshold are both allowed.
Expand Down
Loading