Skip to content
Draft
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
93 changes: 93 additions & 0 deletions grafana-alertcheck/internal/gate/duration.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
package gate

import (
"fmt"
"math"
"strconv"
"strings"
"time"
)

// promDurationUnit is one accepted unit in a Prometheus-style duration string.
// rank increases with unit size; ParsePromDuration requires strictly decreasing
// rank across concatenated components (e.g. "1h30m", never "30m1h").
type promDurationUnit struct {
suffix string
mult time.Duration
rank int
}

// Longest suffix must be tried first ("ms" before "m") — see the matching loop below.
var promDurationUnits = []promDurationUnit{
{"ms", time.Millisecond, 0},
{"s", time.Second, 1},
{"m", time.Minute, 2},
{"h", time.Hour, 3},
{"d", 24 * time.Hour, 4},
{"w", 7 * 24 * time.Hour, 5},
{"y", 365 * 24 * time.Hour, 6},
}

// ParsePromDuration parses a Grafana/Prometheus-style duration ("1h30m", "1d", "1w").
// Unlike time.ParseDuration, it accepts "d" and "w" (§11.8). "" and "0" are 0.
func ParsePromDuration(s string) (time.Duration, error) {
if s == "" || s == "0" {
return 0, nil
}
if strings.HasPrefix(s, "-") {
return 0, fmt.Errorf("invalid duration %q: negative durations are not supported", s)
}

var total time.Duration
prevRank := len(promDurationUnits) // sentinel higher than any real rank
rest := s
for rest != "" {
i := 0
for i < len(rest) && rest[i] >= '0' && rest[i] <= '9' {
i++
}
if i == 0 {
return 0, fmt.Errorf("invalid duration %q: expected a number", s)
}
numPart := rest[:i]
rest = rest[i:]

matched := -1
matchLen := 0
for idx, u := range promDurationUnits {
if strings.HasPrefix(rest, u.suffix) && len(u.suffix) > matchLen {
matched = idx
matchLen = len(u.suffix)
}
}
if matched == -1 {
return 0, fmt.Errorf("invalid duration %q: unrecognized unit", s)
}
u := promDurationUnits[matched]
if u.rank >= prevRank {
return 0, fmt.Errorf("invalid duration %q: units must appear in descending order", s)
}
prevRank = u.rank

n, err := strconv.ParseInt(numPart, 10, 64)
if err != nil {
return 0, fmt.Errorf("invalid duration %q: %w", s, err)
}
// n and u.mult are both non-negative here (the leading "-" check
// above rejects negative input), so overflow of either the
// multiplication or the running sum can only wrap upward past
// math.MaxInt64 — check both explicitly rather than let a duration
// like "300y" silently become negative garbage that would later
// feed transitionGrace.
if n != 0 && n > math.MaxInt64/int64(u.mult) {
return 0, fmt.Errorf("invalid duration %q: overflows time.Duration", s)
}
delta := time.Duration(n) * u.mult
if total > math.MaxInt64-delta {
return 0, fmt.Errorf("invalid duration %q: overflows time.Duration", s)
}
total += delta
rest = rest[matchLen:]
}
return total, nil
}
65 changes: 65 additions & 0 deletions grafana-alertcheck/internal/gate/duration_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
package gate

import (
"testing"
"time"
)

func TestParsePromDuration(t *testing.T) {
cases := []struct {
in string
want time.Duration
}{
{"", 0},
{"0", 0},
{"0s", 0},
{"500ms", 500 * time.Millisecond},
{"1s", time.Second},
{"1m", time.Minute},
{"2m", 2 * time.Minute},
{"3m", 3 * time.Minute},
{"5m", 5 * time.Minute},
{"10m", 10 * time.Minute},
{"15m", 15 * time.Minute},
{"20m", 20 * time.Minute},
{"30m", 30 * time.Minute},
{"1h", time.Hour},
{"6h", 6 * time.Hour},
{"12h", 12 * time.Hour},
{"1d", 24 * time.Hour},
{"1w", 7 * 24 * time.Hour},
{"1y", 365 * 24 * time.Hour},
{"1h30m", time.Hour + 30*time.Minute},
{"1s500ms", time.Second + 500*time.Millisecond},
{"2d12h", 2*24*time.Hour + 12*time.Hour},
}
for _, c := range cases {
got, err := ParsePromDuration(c.in)
if err != nil {
t.Errorf("ParsePromDuration(%q): unexpected error: %v", c.in, err)
continue
}
if got != c.want {
t.Errorf("ParsePromDuration(%q) = %v, want %v", c.in, got, c.want)
}
}
}

func TestParsePromDuration_Errors(t *testing.T) {
cases := []string{
"5", // bare number, no unit
"-5m", // negative
"5x", // unknown unit
"30m1h", // ascending order (must be descending)
"1h1h", // duplicate unit
"m", // unit with no number
"1.5h", // fractional number not supported by this grammar
"1 h", // whitespace
"300y", // overflows time.Duration (int64 nanoseconds) — must error, not wrap negative
}
for _, in := range cases {
if _, err := ParsePromDuration(in); err == nil {
t.Errorf("ParsePromDuration(%q): expected an error, got none", in)
}
}
}
39 changes: 39 additions & 0 deletions grafana-alertcheck/internal/gate/jsonreq.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package gate

import (
"bytes"
"encoding/json"
"fmt"
)

// req decodes m[key] into *dst. It returns an error when key is absent from m
// or explicitly JSON null, so a caller can never mistake absence for a zero
// value (H1) — json.Unmarshal treats "null" as a documented no-op for
// non-pointer targets (string, bool, int, ...), so without this check a
// required field sent as null would silently pass through as its zero value.
func req[T any](m map[string]json.RawMessage, key string, dst *T) error {
raw, ok := m[key]
if !ok || isJSONNull(raw) {
return fmt.Errorf("required field %q is absent", key)
}
if err := json.Unmarshal(raw, dst); err != nil {
return fmt.Errorf("field %q: %w", key, err)
}
return nil
}

func isJSONNull(raw json.RawMessage) bool {
return string(bytes.TrimSpace(raw)) == "null"
}

// opt decodes m[key] into *dst when present, leaving *dst untouched when key is absent.
func opt[T any](m map[string]json.RawMessage, key string, dst *T) error {
raw, ok := m[key]
if !ok {
return nil
}
if err := json.Unmarshal(raw, dst); err != nil {
return fmt.Errorf("field %q: %w", key, err)
}
return nil
}
92 changes: 92 additions & 0 deletions grafana-alertcheck/internal/gate/jsonreq_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
package gate

import (
"encoding/json"
"testing"
)

func rawMap(t *testing.T, jsonObj string) map[string]json.RawMessage {
t.Helper()
var m map[string]json.RawMessage
if err := json.Unmarshal([]byte(jsonObj), &m); err != nil {
t.Fatalf("rawMap: %v", err)
}
return m
}

func TestReq(t *testing.T) {
m := rawMap(t, `{"present":"hello","wrongtype":123,"nullval":null}`)

t.Run("present key decodes", func(t *testing.T) {
var s string
if err := req(m, "present", &s); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if s != "hello" {
t.Errorf("got %q, want hello", s)
}
})

t.Run("absent key errors", func(t *testing.T) {
var s string
if err := req(m, "missing", &s); err == nil {
t.Fatalf("expected an error, got none")
}
})

t.Run("wrong type errors", func(t *testing.T) {
var s string
if err := req(m, "wrongtype", &s); err == nil {
t.Fatalf("expected an error, got none")
}
})

t.Run("explicit JSON null errors, never a zero value", func(t *testing.T) {
var s string
err := req(m, "nullval", &s)
if err == nil {
t.Fatalf("expected an error, got none (s=%q) — a null required field must not silently become a zero value", s)
}
})
}

func TestOpt(t *testing.T) {
m := rawMap(t, `{"present":"hello","wrongtype":123,"nullval":null}`)

t.Run("present key decodes", func(t *testing.T) {
var s string
if err := opt(m, "present", &s); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if s != "hello" {
t.Errorf("got %q, want hello", s)
}
})

t.Run("absent key leaves dst untouched", func(t *testing.T) {
s := "unchanged"
if err := opt(m, "missing", &s); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if s != "unchanged" {
t.Errorf("got %q, want unchanged", s)
}
})

t.Run("wrong type errors", func(t *testing.T) {
var s string
if err := opt(m, "wrongtype", &s); err == nil {
t.Fatalf("expected an error, got none")
}
})

t.Run("explicit JSON null leaves dst at its zero value", func(t *testing.T) {
var s string
if err := opt(m, "nullval", &s); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if s != "" {
t.Errorf("got %q, want empty string", s)
}
})
}
Loading
Loading