Skip to content

Commit f953439

Browse files
authored
Add a threshold for the ratio warning to not notify on small violations (#109)
1 parent 0e32b8a commit f953439

File tree

9 files changed

+129
-14
lines changed

9 files changed

+129
-14
lines changed

config.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66

77
"github.com/appuio/appuio-cloud-agent/limits"
88
"go.uber.org/multierr"
9+
"gopkg.in/inf.v0"
910
"k8s.io/apimachinery/pkg/api/resource"
1011
"sigs.k8s.io/yaml"
1112
)
@@ -27,6 +28,12 @@ type Config struct {
2728
// MemoryPerCoreLimits is the fair use limit of memory usage per CPU core
2829
// It is possible to select limits by node selector labels
2930
MemoryPerCoreLimits limits.Limits
31+
// MemoryPerCoreWarnThreshold is the threshold at which a warning is emitted if the memory per core limit is exceeded.
32+
// Should be a decimal number resembling a percentage (e.g. "0.8" for 80%), represented as a string.
33+
// The limit is multiplied by the optional threshold before comparison.
34+
// A threshold of 0.95 would mean that the warnings are emitted if the ratio is below 95% of the limit.
35+
// Thus adding a leniency of 5% to the limit.
36+
MemoryPerCoreWarnThreshold *inf.Dec
3037

3138
// Privileged* is a list of the given type allowed to bypass restrictions.
3239
// Wildcards are supported (e.g. "system:serviceaccount:default:*" or "cluster-*-operator").

config_test.go

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88

99
"github.com/stretchr/testify/assert"
1010
"github.com/stretchr/testify/require"
11+
"gopkg.in/inf.v0"
1112
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
1213
)
1314

@@ -82,3 +83,33 @@ func Test_Config_MemoryPerCoreLimits(t *testing.T) {
8283
})
8384
}
8485
}
86+
87+
func Test_Config_MemoryPerCoreWarnThreshold(t *testing.T) {
88+
tc := []struct {
89+
yaml string
90+
expected *inf.Dec
91+
}{
92+
{
93+
yaml: `MemoryPerCoreWarnThreshold: "0.95"`,
94+
expected: inf.NewDec(95, 2),
95+
}, {
96+
expected: nil,
97+
},
98+
}
99+
100+
for _, tC := range tc {
101+
t.Run(tC.yaml, func(t *testing.T) {
102+
t.Parallel()
103+
104+
tmp := t.TempDir()
105+
configPath := filepath.Join(tmp, "config.yaml")
106+
require.NoError(t, os.WriteFile(configPath, []byte(tC.yaml), 0o644))
107+
108+
c, warnings, err := ConfigFromFile(configPath)
109+
assert.Len(t, warnings, 0)
110+
require.NoError(t, err)
111+
112+
assert.Equal(t, tC.expected, c.MemoryPerCoreWarnThreshold)
113+
})
114+
}
115+
}

controllers/ratio_controller.go

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77

88
"github.com/appuio/appuio-cloud-agent/limits"
99
"github.com/appuio/appuio-cloud-agent/ratio"
10+
"gopkg.in/inf.v0"
1011
corev1 "k8s.io/api/core/v1"
1112
"k8s.io/client-go/tools/record"
1213

@@ -24,8 +25,9 @@ type RatioReconciler struct {
2425
Recorder record.EventRecorder
2526
Scheme *runtime.Scheme
2627

27-
Ratio ratioFetcher
28-
RatioLimits limits.Limits
28+
Ratio ratioFetcher
29+
RatioLimits limits.Limits
30+
RatioWarnThreshold *inf.Dec
2931
}
3032

3133
type ratioFetcher interface {
@@ -62,7 +64,7 @@ func (r *RatioReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl
6264
continue
6365
}
6466

65-
if ratio.Below(*limit) {
67+
if ratio.Below(*limit, r.RatioWarnThreshold) {
6668
l.Info("recording warn event: ratio too low")
6769

6870
if err := r.warnPod(ctx, req.Name, req.Namespace, ratio, nodeSel, limit); err != nil {

controllers/ratio_controller_test.go

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"github.com/appuio/appuio-cloud-agent/ratio"
99
"github.com/stretchr/testify/assert"
1010
"github.com/stretchr/testify/require"
11+
"gopkg.in/inf.v0"
1112
corev1 "k8s.io/api/core/v1"
1213
"k8s.io/apimachinery/pkg/api/resource"
1314
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
@@ -45,6 +46,28 @@ func TestRatioReconciler_Warn(t *testing.T) {
4546
require.Len(t, recorder.Events, 2)
4647
}
4748

49+
func TestRatioReconciler_Threshold_Ok(t *testing.T) {
50+
recorder := record.NewFakeRecorder(4)
51+
_, err := prepareRatioTest(t, testRatioCfg{
52+
limit: resource.MustParse("4G"),
53+
fetchMemory: resource.MustParse("4G"),
54+
fetchCPU: resource.MustParse("1100m"),
55+
recorder: recorder,
56+
threshold: inf.NewDec(90, 2),
57+
obj: []client.Object{
58+
testNs,
59+
testPod,
60+
},
61+
}).Reconcile(context.TODO(), ctrl.Request{
62+
NamespacedName: types.NamespacedName{
63+
Namespace: testNs.Name,
64+
Name: testPod.Name,
65+
},
66+
})
67+
assert.NoError(t, err)
68+
requireNEvents(t, recorder, 0)
69+
}
70+
4871
func TestRatioReconciler_Ok(t *testing.T) {
4972
recorder := record.NewFakeRecorder(4)
5073
_, err := prepareRatioTest(t, testRatioCfg{
@@ -129,11 +152,17 @@ func TestRatioReconciler_RecordFailed(t *testing.T) {
129152
},
130153
})
131154
assert.NoError(t, err)
132-
if !assert.Len(t, recorder.Events, 0) {
155+
requireNEvents(t, recorder, 0)
156+
}
157+
158+
func requireNEvents(t *testing.T, recorder *record.FakeRecorder, n int) {
159+
t.Helper()
160+
161+
if !assert.Len(t, recorder.Events, n) {
133162
for i := 0; i < len(recorder.Events); i++ {
134-
e := <-recorder.Events
135-
t.Log(e)
163+
t.Log("Recorded event: ", <-recorder.Events)
136164
}
165+
t.FailNow()
137166
}
138167
}
139168

@@ -142,6 +171,7 @@ type testRatioCfg struct {
142171
fetchErr error
143172
fetchCPU resource.Quantity
144173
fetchMemory resource.Quantity
174+
threshold *inf.Dec
145175
obj []client.Object
146176
recorder record.EventRecorder
147177
}
@@ -175,6 +205,7 @@ func prepareRatioTest(t *testing.T, cfg testRatioCfg) *RatioReconciler {
175205
Limit: &cfg.limit,
176206
},
177207
},
208+
RatioWarnThreshold: cfg.threshold,
178209
}
179210
}
180211

main.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -323,6 +323,7 @@ func registerRatioController(mgr ctrl.Manager, conf Config, orgLabel string) {
323323
Ratio: &ratio.Fetcher{
324324
Client: mgr.GetClient(),
325325
},
326+
RatioWarnThreshold: conf.MemoryPerCoreWarnThreshold,
326327
},
327328
})
328329

@@ -335,6 +336,7 @@ func registerRatioController(mgr ctrl.Manager, conf Config, orgLabel string) {
335336
Client: mgr.GetClient(),
336337
OrganizationLabel: orgLabel,
337338
},
339+
RatioWarnThreshold: conf.MemoryPerCoreWarnThreshold,
338340
}).SetupWithManager(mgr); err != nil {
339341
setupLog.Error(err, "unable to create controller", "controller", "ratio")
340342
os.Exit(1)

ratio/ratio.go

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -92,8 +92,15 @@ func (r Ratio) Ratio() *resource.Quantity {
9292

9393
// Below returns if the memory to CPU ratio of the recorded objects is below the given limit.
9494
// Always returns false if no CPU is requested.
95-
func (r Ratio) Below(limit resource.Quantity) bool {
96-
return r.Ratio() != nil && r.Ratio().Cmp(limit) < 0
95+
// The limit is multiplied by the optional threshold before comparison.
96+
// If threshold is nil, it defaults to 1.
97+
// A threshold of 0.95 would mean that the function returns true if the ratio is below 95% of the limit.
98+
func (r Ratio) Below(limit resource.Quantity, threshold *inf.Dec) bool {
99+
if threshold == nil {
100+
threshold = inf.NewDec(1, 0)
101+
}
102+
103+
return r.Ratio() != nil && r.Ratio().AsDec().Cmp(inf.NewDec(0, 0).Mul(limit.AsDec(), threshold)) < 0
97104
}
98105

99106
// String implements Stringer to print ratio

ratio/ratio_test.go

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"testing"
66

77
"github.com/stretchr/testify/assert"
8+
"gopkg.in/inf.v0"
89
appsv1 "k8s.io/api/apps/v1"
910
corev1 "k8s.io/api/core/v1"
1011
"k8s.io/apimachinery/pkg/api/resource"
@@ -211,6 +212,8 @@ func TestRatio_ratio(t *testing.T) {
211212
memory string
212213
ratio string
213214

215+
threshold *inf.Dec
216+
214217
smallerThan string
215218
largerOrEqualTo string
216219
}{
@@ -252,6 +255,15 @@ func TestRatio_ratio(t *testing.T) {
252255
memory: "801Mi",
253256
ratio: "401Mi",
254257
},
258+
{
259+
cpu: "1",
260+
memory: "4Gi",
261+
ratio: "4Gi",
262+
263+
threshold: inf.NewDec(95, 2),
264+
largerOrEqualTo: "3.9Gi",
265+
smallerThan: "4.3Gi",
266+
},
255267
}
256268
for _, tc := range tcs {
257269
t.Run(fmt.Sprintf("[%s/%s=%s]", tc.memory, tc.cpu, tc.ratio), func(t *testing.T) {
@@ -272,10 +284,10 @@ func TestRatio_ratio(t *testing.T) {
272284
assert.Equalf(t, tc.ratio, r.String(), "should pretty print")
273285
}
274286
if tc.smallerThan != "" {
275-
assert.Truef(t, r.Below(resource.MustParse(tc.smallerThan)), "should be smaller than %s", tc.smallerThan)
287+
assert.Truef(t, r.Below(resource.MustParse(tc.smallerThan), tc.threshold), "should be smaller than %s", tc.smallerThan)
276288
}
277289
if tc.largerOrEqualTo != "" {
278-
assert.Falsef(t, r.Below(resource.MustParse(tc.largerOrEqualTo)), "should not be smaller than %s", tc.largerOrEqualTo)
290+
assert.Falsef(t, r.Below(resource.MustParse(tc.largerOrEqualTo), tc.threshold), "should not be smaller than %s", tc.largerOrEqualTo)
279291
}
280292
})
281293
}
@@ -310,7 +322,7 @@ func FuzzRatio(f *testing.F) {
310322
out := r.Warn(&lim, "")
311323
assert.NotEmpty(t, out)
312324

313-
r.Below(lim)
325+
r.Below(lim, nil)
314326
})
315327
})
316328
}

webhooks/ratio_validator.go

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"net/http"
77
"strings"
88

9+
"gopkg.in/inf.v0"
910
admissionv1 "k8s.io/api/admission/v1"
1011
appsv1 "k8s.io/api/apps/v1"
1112
corev1 "k8s.io/api/core/v1"
@@ -30,8 +31,9 @@ type RatioValidator struct {
3031
Decoder admission.Decoder
3132
Client client.Client
3233

33-
Ratio ratioFetcher
34-
RatioLimits limits.Limits
34+
Ratio ratioFetcher
35+
RatioLimits limits.Limits
36+
RatioWarnThreshold *inf.Dec
3537

3638
// DefaultNodeSelector is the default node selector to apply to pods
3739
DefaultNodeSelector map[string]string
@@ -117,7 +119,7 @@ func (v *RatioValidator) Handle(ctx context.Context, req admission.Request) admi
117119
continue
118120
}
119121

120-
if r.Below(*limit) {
122+
if r.Below(*limit, v.RatioWarnThreshold) {
121123
l.Info("ratio too low", "node_selector", nodeSel, "ratio", r)
122124
warnings = append(warnings, r.Warn(limit, nodeSel))
123125
}

webhooks/ratio_validator_test.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"net/http"
99
"strings"
1010

11+
"gopkg.in/inf.v0"
1112
admissionv1 "k8s.io/api/admission/v1"
1213
appsv1 "k8s.io/api/apps/v1"
1314
authenticationv1 "k8s.io/api/authentication/v1"
@@ -41,6 +42,7 @@ func TestRatioValidator_Handle(t *testing.T) {
4142
mangleObject bool
4243
create bool
4344
limits limits.Limits
45+
threshold *inf.Dec
4446
warn bool
4547
fail bool
4648
statusCode int32
@@ -303,6 +305,24 @@ func TestRatioValidator_Handle(t *testing.T) {
303305
fail: true,
304306
statusCode: http.StatusBadRequest,
305307
},
308+
"Allow_UnfairNamespace_WithThreshold": {
309+
user: "appuio#foo",
310+
namespace: "bar",
311+
resources: []client.Object{
312+
podFromResources("pod1", "foo", podResource{
313+
{cpu: "1", memory: "4Gi"},
314+
}),
315+
podFromResources("pod2", "foo", podResource{
316+
{cpu: "1", memory: "4Gi"},
317+
}),
318+
podFromResources("slightlyunfair", "bar", podResource{
319+
{cpu: "1050m", memory: "4Gi"},
320+
}),
321+
},
322+
limits: limits.Limits{{Limit: requireParseQuantity(t, "4Gi")}},
323+
warn: false,
324+
threshold: inf.NewDec(95, 2),
325+
},
306326
}
307327

308328
for name, tc := range tests {
@@ -312,6 +332,7 @@ func TestRatioValidator_Handle(t *testing.T) {
312332

313333
v := prepareTest(t, tc.resources...)
314334
v.RatioLimits = tc.limits
335+
v.RatioWarnThreshold = tc.threshold
315336

316337
ar := admission.Request{
317338
AdmissionRequest: admissionv1.AdmissionRequest{

0 commit comments

Comments
 (0)