Skip to content

Commit 46c2933

Browse files
gantriorCopilot
andauthored
New SolarWinds Observability Scaler (#6577)
* feat: add `solarwinds` scaler Signed-off-by: Tomas Panik <tomas.panik@solarwinds.com> * feat: Fixing E2E tests Signed-off-by: Tomas Panik <tomas.panik@solarwinds.com> * feat: Fixing order in changelog Signed-off-by: Tomas Panik <tomas.panik@solarwinds.com> * feat: fixing solarwinds scaler tests Signed-off-by: Tomas Panik <tomas.panik@solarwinds.com> * feat: making GetMetricsAndActivity cleaner and fixing Changelog Signed-off-by: Tomas Panik <tomas.panik@solarwinds.com> * feat: adding triggerIndex as suggested in code review Signed-off-by: Tomas Panik <tomas.panik@solarwinds.com> * feat: fixing E2E tests Signed-off-by: Tomas Panik <tomas.panik@solarwinds.com> * feat: static analysis fix Signed-off-by: Tomas Panik <tomas.panik@solarwinds.com> * improving error handling Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Signed-off-by: Tomas Panik <tomas.panik@gmail.com> * feat: updating schema Signed-off-by: Tomas Panik <tomas.panik@solarwinds.com> * feat: Updating go.sum after merge Signed-off-by: Tomas Panik <tomas.panik@solarwinds.com> --------- Signed-off-by: Tomas Panik <tomas.panik@solarwinds.com> Signed-off-by: Tomas Panik <tomas.panik@gmail.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
1 parent 0d4cbfd commit 46c2933

235 files changed

Lines changed: 38431 additions & 0 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ To learn more about active deprecations, we recommend checking [GitHub Discussio
7171
- **General**: Implement Force Activation annotation ([#6903](https://github.com/kedacore/keda/issues/6903))
7272
- **General**: Introduce new Forgejo Scaler ([#6488](https://github.com/kedacore/keda/issues/6488))
7373
- **General**: Introduce new Solace Direct Messaging scaler ([#6545](https://github.com/kedacore/keda/issues/6545))
74+
- **General**: Introduce new SolarWinds Scaler ([#6576](https://github.com/kedacore/keda/issues/6576))
7475
- **General**: Introduce new Sumo Logic Scaler ([#6734](https://github.com/kedacore/keda/issues/6734))
7576
- **General**: Support for declarative e2e test setup and filtering ([#6989](https://github.com/kedacore/keda/issues/6989))
7677
- **General**: Trigger Schema Generated Tool ([#6345](https://github.com/kedacore/keda/issues/6345))

‎go.mod‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ require (
8484
github.com/robfig/cron/v3 v3.0.1
8585
github.com/segmentio/kafka-go v0.4.47
8686
github.com/segmentio/kafka-go/sasl/aws_msk_iam_v2 v0.1.0
87+
github.com/solarwinds/swo-sdk-go/swov1 v0.3.0
8788
github.com/spf13/cast v1.7.1
8889
github.com/spf13/pflag v1.0.6
8990
github.com/stretchr/testify v1.11.1
@@ -209,6 +210,7 @@ require (
209210
github.com/emicklei/go-restful/v3 v3.12.2 // indirect
210211
github.com/envoyproxy/go-control-plane/envoy v1.32.4 // indirect
211212
github.com/envoyproxy/protoc-gen-validate v1.2.1 // indirect
213+
github.com/ericlagergren/decimal v0.0.0-20221120152707-495c53812d05 // indirect
212214
github.com/evanphx/json-patch/v5 v5.9.11 // indirect
213215
github.com/facebookgo/clock v0.0.0-20150410010913-600d898af40a // indirect
214216
github.com/fatih/color v1.18.0 // indirect
@@ -317,6 +319,7 @@ require (
317319
github.com/spf13/afero v1.11.0 // indirect
318320
github.com/spf13/cobra v1.9.1 // indirect
319321
github.com/spiffe/go-spiffe/v2 v2.5.0 // indirect
322+
github.com/spyzhov/ajson v0.8.0 // indirect
320323
github.com/stoewer/go-strcase v1.3.1 // indirect
321324
github.com/stretchr/objx v0.5.2 // indirect
322325
github.com/tidwall/match v1.1.1 // indirect

‎go.sum‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -317,6 +317,8 @@ github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJP
317317
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
318318
github.com/envoyproxy/protoc-gen-validate v1.2.1 h1:DEo3O99U8j4hBFwbJfrz9VtgcDfUKS7KJ7spH3d86P8=
319319
github.com/envoyproxy/protoc-gen-validate v1.2.1/go.mod h1:d/C80l/jxXLdfEIhX1W2TmLfsJ31lvEjwamM4DxlWXU=
320+
github.com/ericlagergren/decimal v0.0.0-20221120152707-495c53812d05 h1:S92OBrGuLLZsyM5ybUzgc/mPjIYk2AZqufieooe98uw=
321+
github.com/ericlagergren/decimal v0.0.0-20221120152707-495c53812d05/go.mod h1:M9R1FoZ3y//hwwnJtO51ypFGwm8ZfpxPT/ZLtO1mcgQ=
320322
github.com/evanphx/json-patch v5.9.0+incompatible h1:fBXyNpNMuTTDdquAq/uisOr2lShz4oaXpDTX2bLe7ls=
321323
github.com/evanphx/json-patch v5.9.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk=
322324
github.com/evanphx/json-patch/v5 v5.9.11 h1:/8HVnzMq13/3x9TPvjG08wUGqBTmZBsCWzjTM0wiaDU=
@@ -826,6 +828,8 @@ github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9
826828
github.com/soheilhy/cmux v0.1.4/go.mod h1:IM3LyeVVIOuxMH7sFAkER9+bJ4dT7Ms6E4xg4kGIyLM=
827829
github.com/soheilhy/cmux v0.1.5 h1:jjzc5WVemNEDTLwv9tlmemhC73tI08BNOIGwBOo10Js=
828830
github.com/soheilhy/cmux v0.1.5/go.mod h1:T7TcVDs9LWfQgPlPsdngu6I6QIoyIFZDDC6sNE1GqG0=
831+
github.com/solarwinds/swo-sdk-go/swov1 v0.3.0 h1:mcdppybjW3hPdkAGPyFWtOUYxdi76vStvNpaL1jJtoI=
832+
github.com/solarwinds/swo-sdk-go/swov1 v0.3.0/go.mod h1:meAZLRWrtJTD9xD2ixBZRkC9Zsr/CPdvf/6E24C63PY=
829833
github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ=
830834
github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8=
831835
github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY=
@@ -844,6 +848,8 @@ github.com/spf13/viper v1.7.0/go.mod h1:8WkrPz2fc9jxqZNCJI/76HCieCp4Q8HaLFoCha5q
844848
github.com/spiffe/go-spiffe/v2 v2.5.0 h1:N2I01KCUkv1FAjZXJMwh95KK1ZIQLYbPfhaxw8WS0hE=
845849
github.com/spiffe/go-spiffe/v2 v2.5.0/go.mod h1:P+NxobPc6wXhVtINNtFjNWGBTreew1GBUCwT2wPmb7g=
846850
github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0=
851+
github.com/spyzhov/ajson v0.8.0 h1:sFXyMbi4Y/BKjrsfkUZHSjA2JM1184enheSjjoT/zCc=
852+
github.com/spyzhov/ajson v0.8.0/go.mod h1:63V+CGM6f1Bu/p4nLIN8885ojBdt88TbLoSFzyqMuVA=
847853
github.com/stoewer/go-strcase v1.3.1 h1:iS0MdW+kVTxgMoE1LAZyMiYJFKlOzLooE4MxjirtkAs=
848854
github.com/stoewer/go-strcase v1.3.1/go.mod h1:fAH5hQ5pehh+j3nZfvwdk2RgEgQjAoM8wodgtPmh1xo=
849855
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=

‎pkg/scalers/solarwinds_scaler.go‎

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
package scalers
2+
3+
import (
4+
"context"
5+
"errors"
6+
"fmt"
7+
"net/url"
8+
"strings"
9+
"time"
10+
11+
"github.com/solarwinds/swo-sdk-go/swov1"
12+
"github.com/solarwinds/swo-sdk-go/swov1/models/components"
13+
"github.com/solarwinds/swo-sdk-go/swov1/models/operations"
14+
v2 "k8s.io/api/autoscaling/v2"
15+
"k8s.io/metrics/pkg/apis/external_metrics"
16+
17+
"github.com/kedacore/keda/v2/pkg/scalers/scalersconfig"
18+
)
19+
20+
type solarWindsScaler struct {
21+
metricType v2.MetricTargetType
22+
metadata *solarWindsMetadata
23+
}
24+
25+
type solarWindsMetadata struct {
26+
APIToken string `keda:"name=apiToken, order=authParams, optional"`
27+
Host string `keda:"name=host, order=triggerMetadata"`
28+
TargetValue float64 `keda:"name=targetValue, order=triggerMetadata"`
29+
ActivationValue float64 `keda:"name=activationValue, order=triggerMetadata"`
30+
MetricName string `keda:"name=metricName, order=triggerMetadata"`
31+
Aggregation string `keda:"name=aggregation, order=triggerMetadata, enum=COUNT;MIN;MAX;AVG;SUM;LAST"`
32+
IntervalS int `keda:"name=intervalS, order=triggerMetadata"`
33+
Filter string `keda:"name=filter, order=triggerMetadata, optional"`
34+
35+
triggerIndex int
36+
}
37+
38+
func NewSolarWindsScaler(config *scalersconfig.ScalerConfig) (Scaler, error) {
39+
metricType, err := GetMetricTargetType(config)
40+
if err != nil {
41+
return nil, fmt.Errorf("error getting scaler metric type: %w", err)
42+
}
43+
44+
meta, err := parseSolarWindsMetadata(config)
45+
if err != nil {
46+
return nil, fmt.Errorf("error parsing SolarWinds metadata: %w", err)
47+
}
48+
49+
return &solarWindsScaler{
50+
metricType: metricType,
51+
metadata: meta,
52+
}, nil
53+
}
54+
55+
func parseSolarWindsMetadata(config *scalersconfig.ScalerConfig) (*solarWindsMetadata, error) {
56+
meta := &solarWindsMetadata{}
57+
meta.triggerIndex = config.TriggerIndex
58+
59+
if err := config.TypedConfig(meta); err != nil {
60+
return nil, fmt.Errorf("error parsing solarwinds metadata: %w", err)
61+
}
62+
63+
_, err := url.ParseRequestURI(meta.Host)
64+
if err != nil {
65+
return meta, errors.New("invalid value for host. Must be a valid URL such as 'https://api.na-01.cloud.solarwinds.com'")
66+
}
67+
68+
return meta, nil
69+
}
70+
71+
func (s *solarWindsScaler) Close(context.Context) error {
72+
return nil
73+
}
74+
75+
func (s *solarWindsScaler) GetMetricSpecForScaling(context.Context) []v2.MetricSpec {
76+
externalMetric := &v2.ExternalMetricSource{
77+
Metric: v2.MetricIdentifier{
78+
Name: GenerateMetricNameWithIndex(s.metadata.triggerIndex, "solarwinds"),
79+
},
80+
Target: GetMetricTargetMili(s.metricType, s.metadata.TargetValue),
81+
}
82+
metricSpec := v2.MetricSpec{External: externalMetric, Type: "External"}
83+
return []v2.MetricSpec{metricSpec}
84+
}
85+
86+
func (s *solarWindsScaler) GetMetricsAndActivity(ctx context.Context, metricName string) ([]external_metrics.ExternalMetricValue, bool, error) {
87+
value, err := s.getMetricValueViaSDK(ctx, metricName)
88+
if err != nil {
89+
return []external_metrics.ExternalMetricValue{}, false, fmt.Errorf("error getting SolarWinds metric: %w", err)
90+
}
91+
92+
metric := GenerateMetricInMili(metricName, value)
93+
94+
return []external_metrics.ExternalMetricValue{metric}, value > s.metadata.ActivationValue, nil
95+
}
96+
97+
func (s *solarWindsScaler) getMetricValueViaSDK(ctx context.Context, _ string) (float64, error) {
98+
session := swov1.New(
99+
swov1.WithSecurity(s.metadata.APIToken),
100+
swov1.WithServerURL(s.metadata.Host),
101+
)
102+
103+
now := time.Now()
104+
startTime := now.Add(-time.Duration(s.metadata.IntervalS) * time.Second).UTC()
105+
endTime := now.UTC()
106+
var filter *string
107+
if s.metadata.Filter != "" {
108+
filter = &s.metadata.Filter
109+
}
110+
111+
// Use the actual SolarWinds metric name, not the KEDA-generated metric name
112+
res, err := session.Metrics.ListMetricMeasurements(ctx, operations.ListMetricMeasurementsRequest{
113+
Name: s.metadata.MetricName,
114+
Filter: filter,
115+
AggregateBy: s.convertAggregation(s.metadata.Aggregation),
116+
StartTime: &startTime,
117+
EndTime: &endTime,
118+
SeriesType: components.MetricSeriesTypeScalar,
119+
})
120+
121+
if err != nil {
122+
return 0, err
123+
}
124+
125+
return s.getFirstMeasurement(res)
126+
}
127+
128+
func (s *solarWindsScaler) convertAggregation(aggregation string) *components.MetricsAggregationMethods {
129+
aggregation = strings.ToUpper(aggregation)
130+
switch aggregation {
131+
case "COUNT":
132+
return components.MetricsAggregationMethodsCount.ToPointer()
133+
case "MIN":
134+
return components.MetricsAggregationMethodsMin.ToPointer()
135+
case "MAX":
136+
return components.MetricsAggregationMethodsMax.ToPointer()
137+
case "AVG":
138+
return components.MetricsAggregationMethodsAvg.ToPointer()
139+
case "SUM":
140+
return components.MetricsAggregationMethodsSum.ToPointer()
141+
case "LAST":
142+
return components.MetricsAggregationMethodsLast.ToPointer()
143+
default:
144+
return nil
145+
}
146+
}
147+
148+
func (s *solarWindsScaler) getFirstMeasurement(res *operations.ListMetricMeasurementsResponse) (float64, error) {
149+
var firstValue float64
150+
if res.Object != nil {
151+
if res.Object.Groupings == nil {
152+
return 0, fmt.Errorf("no groupings found in response")
153+
}
154+
for _, group := range res.Object.Groupings {
155+
if group.Measurements == nil {
156+
return 0, fmt.Errorf("no measurements found in response")
157+
}
158+
159+
for _, measurement := range group.Measurements {
160+
firstValue = measurement.Value
161+
return firstValue, nil
162+
}
163+
}
164+
}
165+
return 0, fmt.Errorf("no measurements found in response")
166+
}
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
package scalers
2+
3+
import (
4+
"context"
5+
"testing"
6+
7+
"github.com/stretchr/testify/assert"
8+
v2 "k8s.io/api/autoscaling/v2"
9+
10+
"github.com/kedacore/keda/v2/pkg/scalers/scalersconfig"
11+
)
12+
13+
type parseSolarWindsMetadataTestData struct {
14+
metadata map[string]string
15+
authParams map[string]string
16+
isError bool
17+
description string
18+
}
19+
20+
var testSolarWindsMetadata = []parseSolarWindsMetadataTestData{
21+
// No metadata
22+
{metadata: map[string]string{}, authParams: map[string]string{}, isError: true, description: "No metadata"},
23+
// Valid metadata
24+
{
25+
metadata: map[string]string{
26+
"host": "https://api.na-01.cloud.solarwinds.com",
27+
"apiToken": "testToken",
28+
"targetValue": "10",
29+
"activationValue": "5",
30+
"metricName": "testMetric",
31+
"aggregation": "AVG",
32+
"intervalS": "60",
33+
"filter": "testFilter",
34+
},
35+
authParams: map[string]string{},
36+
isError: false,
37+
description: "Valid metadata",
38+
},
39+
// Valid metadata without filter (optional)
40+
{
41+
metadata: map[string]string{
42+
"host": "https://api.na-01.cloud.solarwinds.com",
43+
"apiToken": "testToken",
44+
"targetValue": "10",
45+
"activationValue": "5",
46+
"metricName": "testMetric",
47+
"aggregation": "AVG",
48+
"intervalS": "60",
49+
},
50+
authParams: map[string]string{},
51+
isError: false,
52+
description: "Valid metadata without filter (optional)",
53+
},
54+
// Invalid host
55+
{
56+
metadata: map[string]string{
57+
"host": "invalid-url",
58+
"apiToken": "testToken",
59+
"targetValue": "10",
60+
"activationValue": "5",
61+
"metricName": "testMetric",
62+
"aggregation": "AVG",
63+
"intervalS": "60",
64+
"filter": "testFilter",
65+
},
66+
authParams: map[string]string{},
67+
isError: true,
68+
description: "Invalid host",
69+
},
70+
}
71+
72+
func TestParseSolarWindsMetadata(t *testing.T) {
73+
for _, testData := range testSolarWindsMetadata {
74+
t.Run(testData.description, func(t *testing.T) {
75+
_, err := parseSolarWindsMetadata(&scalersconfig.ScalerConfig{
76+
TriggerMetadata: testData.metadata,
77+
AuthParams: testData.authParams,
78+
})
79+
if err != nil && !testData.isError {
80+
t.Errorf("Expected success but got error: %v", err)
81+
}
82+
if testData.isError && err == nil {
83+
t.Errorf("Expected error but got success")
84+
}
85+
})
86+
}
87+
}
88+
89+
func TestSolarWindsScalerGetMetricSpecForScaling(t *testing.T) {
90+
meta := &solarWindsMetadata{
91+
MetricName: "testMetric",
92+
TargetValue: 3,
93+
triggerIndex: 0,
94+
}
95+
scaler := &solarWindsScaler{
96+
metricType: v2.AverageValueMetricType,
97+
metadata: meta,
98+
}
99+
100+
metricSpec := scaler.GetMetricSpecForScaling(context.Background())
101+
assert.Equal(t, "s0-solarwinds", metricSpec[0].External.Metric.Name)
102+
assert.Equal(t, int64(3), metricSpec[0].External.Target.AverageValue.Value())
103+
}

‎pkg/scaling/scalers_builder.go‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -260,6 +260,8 @@ func buildScaler(ctx context.Context, client client.Client, triggerType string,
260260
return scalers.NewSolaceDMScaler(config)
261261
case "solace-event-queue":
262262
return scalers.NewSolaceScaler(config)
263+
case "solarwinds":
264+
return scalers.NewSolarWindsScaler(config)
263265
case "solr":
264266
return scalers.NewSolrScaler(config)
265267
case "splunk":

0 commit comments

Comments
 (0)