diff --git a/go.mod b/go.mod index 9c4c54d..bbbcba3 100644 --- a/go.mod +++ b/go.mod @@ -7,7 +7,9 @@ require ( github.com/nuclio/errors v0.0.4 github.com/nuclio/logger v0.0.1 github.com/nuclio/zap v0.3.1 - github.com/stretchr/testify v1.10.0 + github.com/prometheus/client_golang v1.23.2 + github.com/prometheus/common v0.67.5 + github.com/stretchr/testify v1.11.1 k8s.io/api v0.29.8 k8s.io/apimachinery v0.29.8 k8s.io/client-go v0.29.8 @@ -25,7 +27,7 @@ require ( github.com/gogo/protobuf v1.3.2 // indirect github.com/golang/protobuf v1.5.4 // indirect github.com/google/gnostic-models v0.6.9 // indirect - github.com/google/go-cmp v0.6.0 // indirect + github.com/google/go-cmp v0.7.0 // indirect github.com/google/gofuzz v1.2.0 // indirect github.com/google/uuid v1.6.0 // indirect github.com/imdario/mergo v0.3.13 // indirect @@ -38,17 +40,19 @@ require ( github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/prometheus/client_model v0.6.2 // indirect github.com/spf13/pflag v1.0.6 // indirect github.com/stretchr/objx v0.5.2 // indirect go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.27.0 // indirect - golang.org/x/net v0.42.0 // indirect - golang.org/x/oauth2 v0.30.0 // indirect - golang.org/x/sys v0.34.0 // indirect - golang.org/x/term v0.33.0 // indirect - golang.org/x/text v0.27.0 // indirect + go.yaml.in/yaml/v2 v2.4.3 // indirect + golang.org/x/net v0.48.0 // indirect + golang.org/x/oauth2 v0.34.0 // indirect + golang.org/x/sys v0.39.0 // indirect + golang.org/x/term v0.38.0 // indirect + golang.org/x/text v0.32.0 // indirect golang.org/x/time v0.12.0 // indirect - google.golang.org/protobuf v1.36.6 // indirect + google.golang.org/protobuf v1.36.11 // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect k8s.io/klog/v2 v2.130.1 // indirect diff --git a/go.sum b/go.sum index 8a9f64c..ecfc685 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,7 @@ +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -20,13 +24,15 @@ github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1v github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= +github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/gnostic-models v0.6.9 h1:MU/8wDLif2qCXZmzncUQ/BOfxWfthHi63KqpoNbWqVw= github.com/google/gnostic-models v0.6.9/go.mod h1:CiWsm0s6BSQd1hRn8/QmxqB6BesYcbSZxsz9b0KuDBw= github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= -github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= -github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= @@ -38,6 +44,8 @@ github.com/imdario/mergo v0.3.13 h1:lFzP57bqS/wsqKssCGmtLAb8A0wKjLGrve2q3PPVcBk= github.com/imdario/mergo v0.3.13/go.mod h1:4lJ1jqUDcsbIECGy0RUJAXNIhg+6ocWgb1ALK2O4oXg= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/jpillora/backoff v1.0.0 h1:uvFg412JmmHBHw7iwprIxkPMI+sGQ4kzOWsMeHnm2EA= +github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= @@ -57,6 +65,8 @@ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9G github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f h1:KUppIJq7/+SVif2QVs3tOP0zanoHgBEVAwHxUSIzRqU= +github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= github.com/nuclio/errors v0.0.4 h1:Uf/Kfje0VJGYeuNAhuFNaL6bm0O1WCQOg8vEjiY85oQ= github.com/nuclio/errors v0.0.4/go.mod h1:KV56dHK50bOG4+fSUvCZA9D9Ky4utc5LBGGDCpxa8dY= github.com/nuclio/logger v0.0.1 h1:e+vT/Ug65RC+u0QX2J+lq3P57ZBwJ1ZA6Q2LCEcViwE= @@ -71,6 +81,14 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= +github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4= +github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw= +github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= +github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= @@ -79,8 +97,8 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+ github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= -github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= -github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= @@ -89,6 +107,8 @@ go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= +go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= @@ -98,38 +118,38 @@ golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs= -golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8= -golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= -golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= +golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= +golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= +golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= +golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA= -golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= -golang.org/x/term v0.33.0 h1:NuFncQrRcaRvVmgRkvM3j/F00gWIAlcmlB8ACEKmGIg= -golang.org/x/term v0.33.0/go.mod h1:s18+ql9tYWp1IfpV9DmCtQDDSRBUjKaw9M1eAv5UeF0= +golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= +golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q= +golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4= -golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU= +golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= +golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo= -golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg= +golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ= +golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= -google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= diff --git a/pkg/autoscaler/autoscaler.go b/pkg/autoscaler/autoscaler.go index 30a30dd..1ac6f1c 100644 --- a/pkg/autoscaler/autoscaler.go +++ b/pkg/autoscaler/autoscaler.go @@ -23,7 +23,6 @@ package autoscaler import ( "time" - "github.com/v3io/scaler/pkg/common" "github.com/v3io/scaler/pkg/scalertypes" "github.com/nuclio/errors" @@ -85,17 +84,6 @@ func (as *Autoscaler) Stop() error { return nil } -func (as *Autoscaler) getMetricNames(resources []scalertypes.Resource) []string { - var metricNames []string - for _, resource := range resources { - for _, scaleResource := range resource.ScaleResources { - metricNames = append(metricNames, scaleResource.GetKubernetesMetricName()) - } - } - metricNames = common.UniquifyStringSlice(metricNames) - return metricNames -} - func (as *Autoscaler) checkResourceToScale(resource scalertypes.Resource, resourcesMetricsMap map[string]map[string]int) bool { if _, found := resourcesMetricsMap[resource.Name]; !found { as.logger.DebugWith("Resource does not have metrics data yet, keeping up", "resourceName", resource.Name) @@ -147,9 +135,7 @@ func (as *Autoscaler) checkResourcesToScale() error { if len(activeResources) == 0 { return nil } - metricNames := as.getMetricNames(activeResources) - as.logger.DebugWith("Got metric names", "metricNames", metricNames) - resourceMetricsMap, err := as.metricsClient.GetResourceMetrics(metricNames) + resourceMetricsMap, err := as.metricsClient.GetResourceMetrics(activeResources) if err != nil { return errors.Wrap(err, "Failed to get resources metrics") } diff --git a/pkg/autoscaler/metricsclient/factory.go b/pkg/autoscaler/metricsclient/factory.go index b2f610a..4da5a17 100644 --- a/pkg/autoscaler/metricsclient/factory.go +++ b/pkg/autoscaler/metricsclient/factory.go @@ -38,6 +38,17 @@ func NewMetricsClient(logger logger.Logger, restConfig, autoScalerConf.Namespace, autoScalerConf.GroupKind) + case scalertypes.KindPrometheusClient: + prometheusMetricsClient, err := NewPrometheusClient( + logger, + autoScalerConf.MetricsClientOptions.URL, + autoScalerConf.Namespace, + autoScalerConf.MetricsClientOptions.QueryTemplates, + autoScalerConf.ScaleInterval.Duration) + if err != nil { + return nil, errors.Wrap(err, "failed to create Prometheus metric client") + } + return prometheusMetricsClient, nil default: return nil, errors.Errorf("unsupported metrics client kind: %s", autoScalerConf.MetricsClientOptions.MetricsClientKind) } diff --git a/pkg/autoscaler/metricsclient/k8s.go b/pkg/autoscaler/metricsclient/k8s.go index 7aa6839..aa526a4 100644 --- a/pkg/autoscaler/metricsclient/k8s.go +++ b/pkg/autoscaler/metricsclient/k8s.go @@ -21,6 +21,9 @@ such restriction. package metricsclient import ( + "github.com/v3io/scaler/pkg/common" + "github.com/v3io/scaler/pkg/scalertypes" + "github.com/nuclio/errors" "github.com/nuclio/logger" k8serrors "k8s.io/apimachinery/pkg/api/errors" @@ -52,15 +55,20 @@ func NewCustomMetricsClient( availableAPIsGetter := k8scustommetrics.NewAvailableAPIsGetter(discoveryClient) restMapper := restmapper.NewDeferredDiscoveryRESTMapper(memory.NewMemCacheClient(discoveryClient)) customMetricsClient := k8scustommetrics.NewForConfig(restConfig, restMapper, availableAPIsGetter) + + childLogger := parentLogger.GetChild("custom-metrics") + childLogger.Info("Creating custom-metrics client") + return &K8sCustomMetricsClient{ - logger: parentLogger.GetChild("custom-metrics"), + logger: childLogger, CustomMetricsClient: customMetricsClient, namespace: namespace, groupKind: groupKind, }, nil } -func (cmw *K8sCustomMetricsClient) GetResourceMetrics(metricNames []string) (map[string]map[string]int, error) { +func (cmw *K8sCustomMetricsClient) GetResourceMetrics(resources []scalertypes.Resource) (map[string]map[string]int, error) { + metricNames := cmw.getMetricNames(resources) resourcesMetricsMap := make(map[string]map[string]int) resourceLabels := labels.Everything() metricSelectorLabels := labels.Everything() @@ -99,6 +107,17 @@ func (cmw *K8sCustomMetricsClient) GetResourceMetrics(metricNames []string) (map resourcesMetricsMap[resourceName][metricName] = value } } - return resourcesMetricsMap, nil } + +// getMetricNames extracts unique metric names from resources +func (cmw *K8sCustomMetricsClient) getMetricNames(resources []scalertypes.Resource) []string { + var metricNames []string + for _, resource := range resources { + for _, scaleResource := range resource.ScaleResources { + metricNames = append(metricNames, scaleResource.GetKubernetesMetricName()) + } + } + metricNames = common.UniquifyStringSlice(metricNames) + return metricNames +} diff --git a/pkg/autoscaler/metricsclient/prometheus.go b/pkg/autoscaler/metricsclient/prometheus.go new file mode 100644 index 0000000..0abbd00 --- /dev/null +++ b/pkg/autoscaler/metricsclient/prometheus.go @@ -0,0 +1,310 @@ +/* +Copyright 2026 Iguazio Systems Ltd. + +Licensed under the Apache License, Version 2.0 (the "License") with +an addition restriction as set forth herein. You may not use this +file except in compliance with the License. You may obtain a copy of +the License at http://www.apache.org/licenses/LICENSE-2.0. + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +implied. See the License for the specific language governing +permissions and limitations under the License. + +In addition, you may not use the software for any purposes that are +illegal under applicable law, and the grant of the foregoing license +under the Apache 2.0 license is conditioned upon your compliance with +such restriction. +*/ + +package metricsclient + +import ( + "bytes" + "context" + "fmt" + "math" + "strings" + "sync" + "text/template" + "time" + + "github.com/v3io/scaler/pkg/scalertypes" + + "github.com/nuclio/errors" + "github.com/nuclio/logger" + prometheusapi "github.com/prometheus/client_golang/api" + prometheusv1 "github.com/prometheus/client_golang/api/prometheus/v1" + "github.com/prometheus/common/model" +) + +const ( + functionLabelName = "function" // For nuclio functions (nuclio_processor_handled_events_total) - maps to nuclio function resource + serviceLabelName = "service_name" // For deployments (num_of_requests, jupyter_kernel_busyness) - maps to deployment resource + podLabelName = "pod" // For pod-based metrics (DCGM_FI_DEV_GPU_UTIL) - maps to pod resource +) + +// windowSizeLookup maps windowSize → set of resourceNames +type windowSizeLookup map[string]map[string]struct{} + +// metricLookup maps metricName → windowSizeLookup +type metricLookup map[string]windowSizeLookup + +// metricResult holds the result of a single metric query for a resource +type metricResult struct { + resourceName string + fullMetricName string + metricValue int +} + +// PrometheusMetricsClient implements MetricsClient interface using Prometheus as the backend +type PrometheusMetricsClient struct { + logger logger.Logger + apiClient prometheusv1.API + namespace string + queryTemplates map[string]*template.Template + interval time.Duration +} + +// NewPrometheusClient creates a new PrometheusMetricsClient instance +func NewPrometheusClient(parentLogger logger.Logger, prometheusURL, namespace string, templates []scalertypes.QueryTemplate, interval time.Duration) (*PrometheusMetricsClient, error) { + if len(templates) == 0 { + return nil, errors.New("query templates cannot be empty") + } + + if prometheusURL == "" { + return nil, errors.New("prometheus URL cannot be empty") + } + + if namespace == "" { + return nil, errors.New("namespace cannot be empty") + } + + client, err := prometheusapi.NewClient(prometheusapi.Config{ + Address: prometheusURL, + }) + if err != nil { + return nil, errors.Wrap(err, "failed to create prometheus API client") + } + + queryTemplates := make(map[string]*template.Template) + for _, queryTemplate := range templates { + tmpl, err := queryTemplate.CreateQueryTemplate() + if err != nil { + return nil, errors.Wrap(err, "failed to create query template") + } + queryTemplates[queryTemplate.Name] = tmpl + } + + childLogger := parentLogger.GetChild("prometheus-client") + childLogger.Info("Creating prometheus metrics client") + + return &PrometheusMetricsClient{ + logger: childLogger, + apiClient: prometheusv1.NewAPI(client), + namespace: namespace, + queryTemplates: queryTemplates, + interval: interval, + }, nil +} + +// GetResourceMetrics retrieves metrics for multiple resources +func (pc *PrometheusMetricsClient) GetResourceMetrics(resources []scalertypes.Resource) (map[string]map[string]int, error) { + ctx, cancel := context.WithTimeout(context.Background(), pc.interval) + defer cancel() + metricToWindowSizes := pc.buildMetricLookup(resources) + + return pc.getResourceMetrics(ctx, metricToWindowSizes) +} + +func (pc *PrometheusMetricsClient) getResourceMetrics(ctx context.Context, metricToWindowSizes metricLookup) (map[string]map[string]int, error) { + metricsByResource := make(map[string]map[string]int) + + resultChan := make(chan *metricResult) + wg := sync.WaitGroup{} + + for metricName, queryTemplate := range pc.queryTemplates { + windowSizeToResources := metricToWindowSizes[metricName] + + // maximum goroutines is limited to the number of unique windowSize values across all metrics (5 by default). + for windowSize, resourcesInWindowSize := range windowSizeToResources { + wg.Add(1) + go func(resourcesInWindowSize map[string]struct{}, metricName, windowSize string, resultChan chan<- *metricResult) { + defer wg.Done() + // create resource name regex for Prometheus query based on the resources in this window size + resourceNameRegex := pc.createResourceNameRegex(resourcesInWindowSize) + fullMetricName := scalertypes.GetKubernetesMetricName(metricName, windowSize) + query, err := pc.renderQuery(queryTemplate, windowSize, resourceNameRegex) + if err != nil { + pc.logger.WarnWith("Failed to render query, skipping", + "metricName", metricName, + "windowSize", windowSize, + "error", err.Error()) + return + } + + rawResult, warnings, err := pc.apiClient.Query(ctx, query, time.Now()) + if err != nil { + pc.logger.WarnWith("Failed to execute Prometheus query, skipping", + "metricName", metricName, + "windowSize", windowSize, + "error", err.Error()) + return + } + + if len(warnings) > 0 { + pc.logger.WarnWith("Prometheus query returned warnings", + "metricName", metricName, + "windowSize", windowSize, + "warnings", warnings) + } + + metricSamples, ok := rawResult.(model.Vector) + if !ok { + pc.logger.WarnWith("Unexpected Prometheus result type, skipping", + "metricName", metricName, + "windowSize", windowSize) + return + } + + for _, metricSample := range metricSamples { + resourceName, err := pc.extractResourceName(metricSample.Metric) + if err != nil { + pc.logger.WarnWith("Failed to extract resource name from prometheus metric labels, skipping", + "metricName", metricName, + "windowSize", windowSize, + "error", err.Error()) + continue + } + + if _, exists := resourcesInWindowSize[resourceName]; !exists { + pc.logger.DebugWith("Received metric for unconfigured resource, skipping", + "resourceName", resourceName, + "metricName", metricName, + "windowSize", windowSize) + continue + } + + // Round up values to ensure any fractional value > 0 becomes at least 1 + // This prevents incorrect scale-to-zero decisions for resources with low activity + metricValue := int(math.Ceil(float64(metricSample.Value))) + + pc.logger.DebugWith("Retrieved metric", + "resourceName", resourceName, + "metricName", fullMetricName, + "windowSize", windowSize, + "value", metricValue) + + // finished processing this metric sample, send the result + resultChan <- &metricResult{ + resourceName: resourceName, + fullMetricName: fullMetricName, + metricValue: metricValue, + } + } + }(resourcesInWindowSize, metricName, windowSize, resultChan) + } + } + + var collectorErr error + collectorDone := make(chan struct{}) + // Collect results + go func(resultChan chan *metricResult) { + defer close(collectorDone) + for result := range resultChan { + if _, exists := metricsByResource[result.resourceName]; !exists { + metricsByResource[result.resourceName] = make(map[string]int) + } + if existingValue, exists := metricsByResource[result.resourceName][result.fullMetricName]; exists { + if existingValue == result.metricValue { + continue + } + collectorErr = errors.Errorf("conflicting metric values for resource: resourceName=%s, metricName=%s, existingValue=%d, newValue=%d", + result.resourceName, result.fullMetricName, existingValue, result.metricValue) + return + } + metricsByResource[result.resourceName][result.fullMetricName] = result.metricValue + } + }(resultChan) + + // wait for all queries to complete + wg.Wait() + close(resultChan) + // wait for collector to finish processing results + <-collectorDone + + if collectorErr != nil { + return nil, collectorErr + } + + if len(metricsByResource) == 0 { + return nil, errors.New("no metrics retrieved for any resource") + } + + return metricsByResource, nil +} + +// renderQuery renders the Prometheus query template +func (pc *PrometheusMetricsClient) renderQuery(queryTemplate *template.Template, windowSize, resourceNameRegex string) (string, error) { + templateData := make(map[string]string) + templateData["Namespace"] = pc.namespace + templateData["WindowSize"] = windowSize + templateData["Resources"] = resourceNameRegex + + var queryBuffer bytes.Buffer + if err := queryTemplate.Execute(&queryBuffer, templateData); err != nil { + return "", fmt.Errorf("error executing template: %w", err) + } + + return queryBuffer.String(), nil +} + +// buildMetricLookup builds a nested lookup structure from resources +func (pc *PrometheusMetricsClient) buildMetricLookup(resources []scalertypes.Resource) metricLookup { + lookup := make(metricLookup) + + for _, resource := range resources { + for _, scaleResource := range resource.ScaleResources { + metricName := scaleResource.MetricName + windowSize := scalertypes.ShortDurationString(scaleResource.WindowSize) + + if lookup[metricName] == nil { + lookup[metricName] = make(windowSizeLookup) + } + if lookup[metricName][windowSize] == nil { + lookup[metricName][windowSize] = make(map[string]struct{}) + } + + lookup[metricName][windowSize][resource.Name] = struct{}{} + } + } + + return lookup +} + +// extractResourceName extracts the resource name from Prometheus metric labels. +func (pc *PrometheusMetricsClient) extractResourceName(labels model.Metric) (string, error) { + labelNames := []model.LabelName{ + functionLabelName, + serviceLabelName, + podLabelName, + } + for _, labelName := range labelNames { + if value, ok := labels[labelName]; ok { + return string(value), nil + } + } + return "", errors.Errorf("could not extract resource name from labels: %v", labels) +} + +// createResourceNameRegex creates a regex string for Prometheus query from resource names +func (pc *PrometheusMetricsClient) createResourceNameRegex(resources map[string]struct{}) string { + resourcesNames := make([]string, len(resources)) + i := 0 + for resourceName := range resources { + resourcesNames[i] = resourceName + i++ + } + return strings.Join(resourcesNames, "|") +} diff --git a/pkg/autoscaler/metricsclient/prometheus_test.go b/pkg/autoscaler/metricsclient/prometheus_test.go new file mode 100644 index 0000000..81a30da --- /dev/null +++ b/pkg/autoscaler/metricsclient/prometheus_test.go @@ -0,0 +1,544 @@ +/* +Copyright 2026 Iguazio Systems Ltd. + +Licensed under the Apache License, Version 2.0 (the "License") with +an addition restriction as set forth herein. You may not use this +file except in compliance with the License. You may obtain a copy of +the License at http://www.apache.org/licenses/LICENSE-2.0. + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +implied. See the License for the specific language governing +permissions and limitations under the License. + +In addition, you may not use the software for any purposes that are +illegal under applicable law, and the grant of the foregoing license +under the Apache 2.0 license is conditioned upon your compliance with +such restriction. +*/ + +package metricsclient + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "text/template" + "time" + + "github.com/v3io/scaler/pkg/scalertypes" + + "github.com/nuclio/logger" + nucliozap "github.com/nuclio/zap" + "github.com/stretchr/testify/suite" +) + +const ( + testQueryTemplateWithResources = `sum(rate(handled_events_total{namespace="{{ .Namespace }}", trigger_kind="http", function=~"{{ .Resources }}"}[{{ .WindowSize }}])) by (function)` +) + +type PrometheusClientTestSuite struct { + suite.Suite + logger logger.Logger +} + +func (suite *PrometheusClientTestSuite) SetupTest() { + var err error + suite.logger, err = nucliozap.NewNuclioZapTest("test") + suite.Require().NoError(err) +} + +// createMockPrometheusServer creates an HTTP test server with a custom response result per window size +func createMockPrometheusServer(serverResultPerWindowSize map[string][]map[string]interface{}) *httptest.Server { + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/v1/query": + timestamp := float64(time.Now().Unix()) + + // Extract window size from query (e.g., "...[1m]..." -> "1m") and lookup results + query := r.FormValue("query") + start, end := strings.Index(query, "["), strings.Index(query, "]") + windowSize := query[start+1 : end] + serverResult := serverResultPerWindowSize[windowSize] + + // Update timestamps in serverResult + for _, result := range serverResult { + if value, ok := result["value"].([]interface{}); ok && len(value) > 1 { + result["value"] = []interface{}{timestamp, value[1]} + } + } + + response := map[string]interface{}{ + "status": "success", + "data": map[string]interface{}{ + "resultType": "vector", + "result": serverResult, + }, + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(response) + default: + w.WriteHeader(http.StatusNotFound) + } + }) + + return httptest.NewServer(handler) +} + +func (suite *PrometheusClientTestSuite) TestGetResourceMetrics() { + timestamp := float64(time.Now().Unix()) + tests := []struct { + name string + resources []scalertypes.Resource + serverResultPerWindowSize map[string][]map[string]interface{} + expectedResult map[string]map[string]int + }{ + { + name: "single resource with metrics", + resources: []scalertypes.Resource{ + { + Name: "test-resource1", + ScaleResources: []scalertypes.ScaleResource{ + { + MetricName: "handled_events_total", + WindowSize: scalertypes.Duration{Duration: 1 * time.Minute}, + }, + }, + }, + }, + serverResultPerWindowSize: map[string][]map[string]interface{}{ + "1m": { + { + "metric": map[string]string{ + "function": "test-resource1", + }, + "value": []interface{}{timestamp, "0"}, + }, + }, + }, + expectedResult: map[string]map[string]int{ + "test-resource1": { + "handled_events_total_per_1m": 0, + }, + }, + }, + { + name: "multiple resources with same window size", + resources: []scalertypes.Resource{ + { + Name: "test-resource1", + ScaleResources: []scalertypes.ScaleResource{ + { + MetricName: "handled_events_total", + WindowSize: scalertypes.Duration{Duration: 1 * time.Minute}, + }, + }, + }, + { + Name: "test-resource3", + ScaleResources: []scalertypes.ScaleResource{ + { + MetricName: "handled_events_total", + WindowSize: scalertypes.Duration{Duration: 1 * time.Minute}, + }, + }, + }, + }, + serverResultPerWindowSize: map[string][]map[string]interface{}{ + "1m": { + { + "metric": map[string]string{ + "function": "test-resource1", + }, + "value": []interface{}{timestamp, "0"}, + }, + { + "metric": map[string]string{ + "function": "test-resource3", + }, + "value": []interface{}{timestamp, "0.5"}, + }, + }, + }, + expectedResult: map[string]map[string]int{ + "test-resource1": { + "handled_events_total_per_1m": 0, + }, + "test-resource3": { + "handled_events_total_per_1m": 1, + }, + }, + }, + { + name: "multiple resources with different window size", + resources: []scalertypes.Resource{ + { + Name: "test-resource1", + ScaleResources: []scalertypes.ScaleResource{ + { + MetricName: "handled_events_total", + WindowSize: scalertypes.Duration{Duration: 1 * time.Minute}, + }, + }, + }, + { + Name: "test-resource3", + ScaleResources: []scalertypes.ScaleResource{ + { + MetricName: "handled_events_total", + WindowSize: scalertypes.Duration{Duration: 3 * time.Minute}, + }, + }, + }, + }, + serverResultPerWindowSize: map[string][]map[string]interface{}{ + "1m": { + { + "metric": map[string]string{ + "function": "test-resource1", + }, + "value": []interface{}{timestamp, "0.1"}, + }, + }, + "3m": { + { + "metric": map[string]string{ + "function": "test-resource3", + }, + "value": []interface{}{timestamp, "0.1"}, + }, + }, + }, + expectedResult: map[string]map[string]int{ + "test-resource1": { + "handled_events_total_per_1m": 1, + }, + "test-resource3": { + "handled_events_total_per_3m": 1, + }, + }, + }, + } + + for _, testCase := range tests { + suite.Run(testCase.name, func() { + mockServer := createMockPrometheusServer(testCase.serverResultPerWindowSize) + defer mockServer.Close() + + // Create Prometheus client pointing to mock server + // the client creation is done inside the test because each test have a unique mock server URL + client, err := NewPrometheusClient(suite.logger, + mockServer.URL, + "test-namespace", + []scalertypes.QueryTemplate{ + { + Name: "handled_events_total", + Template: testQueryTemplateWithResources, + }, + }, + 10*time.Second, + ) + suite.Require().NoError(err) + + results, err := client.GetResourceMetrics(testCase.resources) + suite.Require().NoError(err) + suite.Require().Equal(testCase.expectedResult, results, "GetResourceMetrics() result mismatch") + }) + } +} + +func (suite *PrometheusClientTestSuite) TestRenderQuery() { + tests := []struct { + name string + namespace string + templateStr string + windowSize string + resourceNameRegex string + expectedQuery string + expectError bool + }{ + { + name: "basic query with all fields", + namespace: "test-namespace", + templateStr: testQueryTemplateWithResources, + windowSize: "1m", + resourceNameRegex: "test-resource1|test-resource2", + expectedQuery: `sum(rate(handled_events_total{namespace="test-namespace", trigger_kind="http", function=~"test-resource1|test-resource2"}[1m])) by (function)`, + expectError: false, + }, + { + name: "query with different namespace", + namespace: "my-namespace", + templateStr: `sum(rate(handled_events_total{namespace="{{ .Namespace }}"}[{{ .WindowSize }}])) by (function)`, + windowSize: "5m", + resourceNameRegex: "resource1", + expectedQuery: `sum(rate(handled_events_total{namespace="my-namespace"}[5m])) by (function)`, + expectError: false, + }, + { + name: "query with single resource", + namespace: "test-namespace", + templateStr: testQueryTemplateWithResources, + windowSize: "2m", + resourceNameRegex: "test-resource1", + expectedQuery: `sum(rate(handled_events_total{namespace="test-namespace", trigger_kind="http", function=~"test-resource1"}[2m])) by (function)`, + expectError: false, + }, + { + name: "query with no resource regex", + namespace: "test-namespace", + templateStr: `sum(rate(handled_events_total{namespace="{{ .Namespace }}"}[{{ .WindowSize }}])) by (function)`, + windowSize: "1m", + resourceNameRegex: "", + expectedQuery: `sum(rate(handled_events_total{namespace="test-namespace"}[1m])) by (function)`, + expectError: false, + }, + { + name: "query with special characters in resource names", + namespace: "test-namespace", + templateStr: `sum(rate(handled_events_total{namespace="{{ .Namespace }}", function=~"{{ .Resources }}"}[{{ .WindowSize }}])) by (function)`, + windowSize: "30m", + resourceNameRegex: "function-with-long-stz|hello-world", + expectedQuery: `sum(rate(handled_events_total{namespace="test-namespace", function=~"function-with-long-stz|hello-world"}[30m])) by (function)`, + expectError: false, + }, + { + name: "invalid template syntax", + namespace: "test-namespace", + templateStr: `sum(rate(handled_events_total{namespace="{{ .Namespace }}"}[{{ .WindowSize }}])) by (function){{ .InvalidField }}`, + windowSize: "1m", + resourceNameRegex: "test-resource1", + expectedQuery: "", + expectError: false, // Template execution doesn't fail on missing fields, just renders empty + }, + } + + for _, testCase := range tests { + suite.Run(testCase.name, func() { + client := &PrometheusMetricsClient{ + namespace: testCase.namespace, + } + + tmpl, err := template.New("test").Parse(testCase.templateStr) + suite.Require().NoError(err, "Failed to parse template") + result, err := client.renderQuery(tmpl, testCase.windowSize, testCase.resourceNameRegex) + + if testCase.expectError { + suite.Require().Error(err, "Expected error but got none") + } else { + suite.Require().NoError(err, "Unexpected error") + if testCase.expectedQuery != "" { + suite.Require().Equal(testCase.expectedQuery, result, "Query mismatch") + } + } + }) + } +} + +func (suite *PrometheusClientTestSuite) TestBuildMetricLookup() { + tests := []struct { + name string + resources []scalertypes.Resource + expected metricLookup + }{ + { + name: "single resource with single window size", + resources: []scalertypes.Resource{ + { + Name: "test-resource1", + Namespace: "test-namespace", + ScaleResources: []scalertypes.ScaleResource{ + { + MetricName: "handled_events_total", + WindowSize: scalertypes.Duration{Duration: 1 * time.Minute}, + }, + }, + }, + }, + expected: metricLookup{ + "handled_events_total": windowSizeLookup{ + "1m": map[string]struct{}{"test-resource1": {}}, + }, + }, + }, + { + name: "multiple resources with same window size", + resources: []scalertypes.Resource{ + { + Name: "test-resource1", + Namespace: "test-namespace", + ScaleResources: []scalertypes.ScaleResource{ + { + MetricName: "handled_events_total", + WindowSize: scalertypes.Duration{Duration: 1 * time.Minute}, + }, + }, + }, + { + Name: "test-resource2", + Namespace: "test-namespace", + ScaleResources: []scalertypes.ScaleResource{ + { + MetricName: "handled_events_total", + WindowSize: scalertypes.Duration{Duration: 1 * time.Minute}, + }, + }, + }, + }, + expected: metricLookup{ + "handled_events_total": windowSizeLookup{ + "1m": map[string]struct{}{"test-resource1": {}, "test-resource2": {}}, + }, + }, + }, + { + name: "multiple resources with different window sizes", + resources: []scalertypes.Resource{ + { + Name: "test-resource1", + Namespace: "test-namespace", + ScaleResources: []scalertypes.ScaleResource{ + { + MetricName: "handled_events_total", + WindowSize: scalertypes.Duration{Duration: 1 * time.Minute}, + }, + }, + }, + { + Name: "test-resource2", + Namespace: "test-namespace", + ScaleResources: []scalertypes.ScaleResource{ + { + MetricName: "handled_events_total", + WindowSize: scalertypes.Duration{Duration: 2 * time.Minute}, + }, + }, + }, + }, + expected: metricLookup{ + "handled_events_total": windowSizeLookup{ + "1m": map[string]struct{}{"test-resource1": {}}, + "2m": map[string]struct{}{"test-resource2": {}}, + }, + }, + }, + { + name: "resource with multiple scale resources for same metric", + resources: []scalertypes.Resource{ + { + Name: "test-resource1", + Namespace: "test-namespace", + ScaleResources: []scalertypes.ScaleResource{ + { + MetricName: "handled_events_total", + WindowSize: scalertypes.Duration{Duration: 1 * time.Minute}, + }, + { + MetricName: "handled_events_total", + WindowSize: scalertypes.Duration{Duration: 5 * time.Minute}, + }, + }, + }, + }, + expected: metricLookup{ + "handled_events_total": windowSizeLookup{ + "1m": map[string]struct{}{"test-resource1": {}}, + "5m": map[string]struct{}{"test-resource1": {}}, + }, + }, + }, + { + name: "resources with different metric names", + resources: []scalertypes.Resource{ + { + Name: "test-resource1", + Namespace: "test-namespace", + ScaleResources: []scalertypes.ScaleResource{ + { + MetricName: "handled_events_total", + WindowSize: scalertypes.Duration{Duration: 1 * time.Minute}, + }, + { + MetricName: "other_metric", + WindowSize: scalertypes.Duration{Duration: 1 * time.Minute}, + }, + }, + }, + }, + expected: metricLookup{ + "handled_events_total": windowSizeLookup{ + "1m": map[string]struct{}{"test-resource1": {}}, + }, + "other_metric": windowSizeLookup{ + "1m": map[string]struct{}{"test-resource1": {}}, + }, + }, + }, + { + name: "empty resources", + resources: []scalertypes.Resource{}, + expected: metricLookup{}, + }, + { + name: "window sizes with different formats", + resources: []scalertypes.Resource{ + { + Name: "test-resource1", + Namespace: "test-namespace", + ScaleResources: []scalertypes.ScaleResource{ + { + MetricName: "handled_events_total", + WindowSize: scalertypes.Duration{Duration: 30 * time.Minute}, + }, + }, + }, + { + Name: "test-resource2", + Namespace: "test-namespace", + ScaleResources: []scalertypes.ScaleResource{ + { + MetricName: "handled_events_total", + WindowSize: scalertypes.Duration{Duration: 1 * time.Hour}, + }, + }, + }, + }, + expected: metricLookup{ + "handled_events_total": windowSizeLookup{ + "30m": map[string]struct{}{"test-resource1": {}}, + "1h": map[string]struct{}{"test-resource2": {}}, + }, + }, + }, + } + + for _, testCase := range tests { + suite.Run(testCase.name, func() { + client := &PrometheusMetricsClient{ + namespace: "test-namespace", + } + + result := client.buildMetricLookup(testCase.resources) + + suite.Require().Equal(len(testCase.expected), len(result), "Metric count mismatch") + for metricName, expectedWindowSizes := range testCase.expected { + suite.Require().Contains(result, metricName, "Expected metric %s not found", metricName) + resultWindowSizes := result[metricName] + suite.Require().Equal(len(expectedWindowSizes), len(resultWindowSizes), "Window sizes count mismatch for metric %s", metricName) + for windowSize, expectedResources := range expectedWindowSizes { + suite.Require().Contains(resultWindowSizes, windowSize, "Expected window size %s not found for metric %s", windowSize, metricName) + suite.Require().Equal(expectedResources, resultWindowSizes[windowSize], "Resources mismatch for metric %s, window size %s", metricName, windowSize) + } + } + }) + } +} + +func TestPrometheusClientSuite(t *testing.T) { + suite.Run(t, new(PrometheusClientTestSuite)) +} diff --git a/pkg/scalertypes/types.go b/pkg/scalertypes/types.go index 7103a07..20233c4 100644 --- a/pkg/scalertypes/types.go +++ b/pkg/scalertypes/types.go @@ -25,6 +25,7 @@ import ( "encoding/json" "fmt" "strings" + "text/template" "time" "github.com/nuclio/errors" @@ -37,12 +38,34 @@ type MetricsClientKind string const ( KindK8sMetricsClient = "k8sMetricsClient" + KindPrometheusClient = "prometheusClient" ) +// QueryTemplate defines a named Prometheus query template. +type QueryTemplate struct { + Name string + Template string +} + +// CreateQueryTemplate parses and validates the query template +func (q *QueryTemplate) CreateQueryTemplate() (*template.Template, error) { + if q.Name == "" { + return nil, errors.New("template name cannot be empty") + } + if q.Template == "" { + return nil, errors.New("query template cannot be empty") + } + tmpl, err := template.New(q.Name).Parse(q.Template) + if err != nil { + return nil, errors.Wrap(err, "failed to parse query template") + } + return tmpl, nil +} + type MetricsClientOptions struct { MetricsClientKind MetricsClientKind URL string - Template string + QueryTemplates []QueryTemplate } type AutoScalerOptions struct { @@ -135,8 +158,14 @@ type ScaleResource struct { Threshold int `json:"threshold,omitempty"` } +// GetKubernetesMetricName constructs a Kubernetes metric name from a base metric name and window size func (sr ScaleResource) GetKubernetesMetricName() string { - return fmt.Sprintf("%s_per_%s", sr.MetricName, shortDurationString(sr.WindowSize)) + return GetKubernetesMetricName(sr.MetricName, ShortDurationString(sr.WindowSize)) +} + +// GetKubernetesMetricName constructs a Kubernetes metric name from a base metric name and window size +func GetKubernetesMetricName(metricName, windowSize string) string { + return fmt.Sprintf("%s_per_%s", metricName, windowSize) } func (sr ScaleResource) String() string { @@ -202,7 +231,8 @@ func (d *Duration) UnmarshalJSON(b []byte) error { } } -func shortDurationString(d Duration) string { +// ShortDurationString formats a Duration into a short string representation by removing trailing zeros +func ShortDurationString(d Duration) string { s := d.String() if strings.HasSuffix(s, "m0s") { s = s[:len(s)-2] @@ -215,10 +245,10 @@ func shortDurationString(d Duration) string { // MetricsClient defines an interface for retrieving resource metrics used by the autoscaler. type MetricsClient interface { - // GetResourceMetrics retrieves metrics for multiple resources and metric names. + // GetResourceMetrics retrieves metrics for multiple resources. // // Parameters: - // - metricNames: A slice of metric names to retrieve (e.g., "requests_per_minute", "cpu_usage_per_hour") + // - resources: A slice of resources to retrieve metrics for // // Returns: // - map[string]map[string]int: A nested map structure where: @@ -231,5 +261,5 @@ type MetricsClient interface { // The dual map structure allows efficient lookup of metric values by resource name // and then by metric name, enabling the autoscaler to check multiple metrics // per resource when making scaling decisions. - GetResourceMetrics(metricNames []string) (map[string]map[string]int, error) + GetResourceMetrics(resources []Resource) (map[string]map[string]int, error) }