Skip to content

Commit 494d014

Browse files
committed
BUG/MEDIUM: configuration: stats refresh delay defaults to seconds
The 'stats refresh' keyword takes a delay that HAProxy reads as seconds when no suffix is given, but it was parsed with ParseTimeout, which assumes milliseconds. The model therefore held a value a thousand times smaller than the one in the configuration file, and serializing wrote that millisecond count back as a bare number, which HAProxy then read as seconds again. The raw text happened to survive a parse and serialize cycle, so this stayed invisible when only rewriting a file, but the value exposed through the model was wrong in both directions. Reading 'stats refresh 30' reported 30 ms for what HAProxy applies as 30 s, and asking for 30000 ms through the model produced 'stats refresh 30000', which HAProxy applies as 30000 s. Parse the delay with ParseTimeoutDefaultSeconds and serialize it with an explicit 'ms' suffix, matching what is already done for the other keywords using that parser, namely clitcpka-idle, clitcpka-intvl, srvtcpka-idle and srvtcpka-intvl. The two changes belong together: fixing the parser alone would leave the serializer scaling the delay by a thousand on every reload, and would break callers that verify a submitted configuration against its regenerated form. The same fix is already in v6, where the delay is described as a duration defaulting to seconds and serialized through misc.SerializeTime.
1 parent 3aa197d commit 494d014

2 files changed

Lines changed: 79 additions & 2 deletions

File tree

configuration/configuration.go

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1076,7 +1076,8 @@ func (s *SectionParser) statsOptions() interface{} { //nolint:gocognit
10761076
opt.StatsMaxconn = mc.Value
10771077
case *stats.Refresh:
10781078
if v.Delay != "" {
1079-
opt.StatsRefreshDelay = misc.ParseTimeout(v.Delay)
1079+
// the keyword defaults to seconds, not milliseconds
1080+
opt.StatsRefreshDelay = misc.ParseTimeoutDefaultSeconds(v.Delay)
10801081
}
10811082
case *stats.ShowNode:
10821083
opt.StatsShowNodeName = misc.StringP(v.Name)
@@ -2605,7 +2606,9 @@ func (s *SectionObject) statsOptions(field reflect.Value) error {
26052606
}
26062607
if opt.StatsRefreshDelay != nil {
26072608
s := &stats.Refresh{
2608-
Delay: strconv.FormatInt(*opt.StatsRefreshDelay, 10),
2609+
// the model holds milliseconds while the keyword defaults to
2610+
// seconds, so the suffix must be explicit
2611+
Delay: fmt.Sprintf("%dms", *opt.StatsRefreshDelay),
26092612
}
26102613
ss = append(ss, s)
26112614
}
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
package configuration
2+
3+
import (
4+
"strings"
5+
"testing"
6+
7+
parser "github.com/haproxytech/client-native/v5/config-parser"
8+
"github.com/haproxytech/client-native/v5/config-parser/options"
9+
"github.com/haproxytech/client-native/v5/models"
10+
11+
"github.com/stretchr/testify/require"
12+
)
13+
14+
// statsRefreshRoundTrip parses a frontend holding a single 'stats refresh'
15+
// keyword and serializes it back, returning the regenerated keyword and the
16+
// milliseconds the model held in between.
17+
func statsRefreshRoundTrip(t *testing.T, delay string) (string, *int64) {
18+
t.Helper()
19+
20+
pIn, err := parser.New(options.String("frontend stats\n stats refresh " + delay + "\n"))
21+
require.NoError(t, err)
22+
23+
fe := &models.Frontend{Name: "stats"}
24+
require.NoError(t, ParseSection(fe, parser.Frontends, "stats", pIn))
25+
require.NotNil(t, fe.StatsOptions)
26+
27+
pOut, err := parser.New(options.String("frontend stats\n"))
28+
require.NoError(t, err)
29+
require.NoError(t, CreateEditSection(fe, parser.Frontends, "stats", pOut))
30+
31+
var got string
32+
for _, line := range strings.Split(pOut.String(), "\n") {
33+
if line = strings.TrimSpace(line); strings.HasPrefix(line, "stats refresh ") {
34+
got = strings.TrimPrefix(line, "stats refresh ")
35+
}
36+
}
37+
return got, fe.StatsOptions.StatsRefreshDelay
38+
}
39+
40+
// TestStatsRefreshDelayDefaultsToSeconds checks that a 'stats refresh' value
41+
// written without a suffix is read as seconds, as the keyword documents, and
42+
// that the delay is always serialized with an explicit unit. The model stores
43+
// milliseconds, so writing the bare number would scale the value by 1000 on
44+
// reload and make the round trip non-idempotent.
45+
func TestStatsRefreshDelayDefaultsToSeconds(t *testing.T) {
46+
tests := []struct {
47+
name string
48+
delay string
49+
expected string
50+
ms int64
51+
}{
52+
{name: "no suffix means seconds", delay: "30", expected: "30000ms", ms: 30000},
53+
{name: "large value with no suffix", delay: "30000", expected: "30000000ms", ms: 30000000},
54+
{name: "seconds", delay: "30s", expected: "30000ms", ms: 30000},
55+
{name: "milliseconds", delay: "5000ms", expected: "5000ms", ms: 5000},
56+
{name: "minutes", delay: "2m", expected: "120000ms", ms: 120000},
57+
}
58+
59+
for _, tt := range tests {
60+
t.Run(tt.name, func(t *testing.T) {
61+
got, ms := statsRefreshRoundTrip(t, tt.delay)
62+
require.Equal(t, tt.expected, got)
63+
require.NotNil(t, ms)
64+
require.Equal(t, tt.ms, *ms)
65+
66+
// serializing the regenerated value again must be a no-op,
67+
// otherwise every reload scales the delay by 1000
68+
stable, msAgain := statsRefreshRoundTrip(t, got)
69+
require.Equal(t, tt.expected, stable)
70+
require.NotNil(t, msAgain)
71+
require.Equal(t, tt.ms, *msAgain)
72+
})
73+
}
74+
}

0 commit comments

Comments
 (0)