Skip to content
Open
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
22 changes: 4 additions & 18 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -3,28 +3,14 @@ module github.com/jsiebens/nomad-autoscaler-plugin-strategy-cron
go 1.17

require (
github.com/hashicorp/cronexpr v1.1.1
github.com/hashicorp/go-hclog v1.0.0
github.com/hashicorp/nomad-autoscaler v0.3.3
github.com/stretchr/testify v1.7.0
)

require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/fatih/color v1.10.0 // indirect
github.com/golang/protobuf v1.4.3 // indirect
github.com/hashicorp/go-plugin v1.0.1 // indirect
github.com/hashicorp/yamux v0.0.0-20180604194846-3520598351bb // indirect
github.com/mattn/go-colorable v0.1.8 // indirect
github.com/mattn/go-isatty v0.0.12 // indirect
github.com/mitchellh/go-testing-interface v1.0.0 // indirect
github.com/oklog/run v1.0.0 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110 // indirect
golang.org/x/sys v0.0.0-20210426230700-d19ff857e887 // indirect
golang.org/x/text v0.3.6 // indirect
google.golang.org/genproto v0.0.0-20210119180700-e258113e47cc // indirect
google.golang.org/grpc v1.35.0 // indirect
google.golang.org/protobuf v1.25.0 // indirect
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c // indirect
github.com/antonmedv/expr v1.9.0
github.com/hashicorp/cronexpr v1.1.1
golang.org/x/text v0.3.7 // indirect
google.golang.org/genproto v0.0.0-20220310185008-1973136f34c6 // indirect
)
57 changes: 49 additions & 8 deletions go.sum

Large diffs are not rendered by default.

121 changes: 121 additions & 0 deletions plugin/expression.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
package plugin

import (
"fmt"
"sort"
"strconv"

"github.com/antonmedv/expr"
"github.com/hashicorp/nomad-autoscaler/sdk"
)

type env struct {
Count int64 // current count
Metrics sdk.TimestampedMetrics
MetricsAvg float64
MetricsMin float64
MetricsMax float64
}

func sum(metrics sdk.TimestampedMetrics) float64 {
s := 0.0
for _, v := range metrics {
s += v.Value
}
return s
}

func avg(metrics sdk.TimestampedMetrics) float64 {
if len(metrics) == 0 {
return 0
}
return sum(metrics) / float64(len(metrics))
}

func max(metrics sdk.TimestampedMetrics) float64 {
if len(metrics) == 0 {
return 0
}
sortedMetrics := make(sdk.TimestampedMetrics, len(metrics))
copy(sortedMetrics, metrics)
sort.Slice(sortedMetrics, func(i, j int) bool {
return sortedMetrics[i].Value > sortedMetrics[j].Value
})
return sortedMetrics[0].Value
}

func min(metrics sdk.TimestampedMetrics) float64 {
if len(metrics) == 0 {
return 0
}
sortedMetrics := make(sdk.TimestampedMetrics, len(metrics))
copy(sortedMetrics, metrics)
sort.Slice(sortedMetrics, func(i, j int) bool {
return sortedMetrics[i].Value < sortedMetrics[j].Value
})
return sortedMetrics[0].Value
}

// evaluateExpression parses the given expression and evaluates the resulting program. The expected result is
// the target count.
// Available variables are:
// "Count" - the current count
// "Metrics" - sdk.TimestampedMetrics
// "MetricsAvg" - average of Metrics
// "MetricsMin" - min of Metrics
// "MetricsMax" - max of Metrics
func evaluateExpression(expression string, count int64, metrics sdk.TimestampedMetrics) (int64, error) {
env := env{
Count: count,
Metrics: metrics,
MetricsAvg: avg(metrics),
MetricsMax: max(metrics),
MetricsMin: min(metrics),
}
res, err := expr.Eval(expression, env)
if err != nil {
return 0, err
}
switch res.(type) {
case string:
return strconv.ParseInt(res.(string), 10, 64)

case bool:
if res.(bool) {
return count, nil
} else {
return 0, nil
}

case uint8:
return int64(res.(uint8)), nil

case uint16:
return int64(res.(uint16)), nil

case uint32:
return int64(res.(uint32)), nil

case uint64:
return int64(res.(uint64)), nil

case int8:
return int64(res.(int8)), nil

case int16:
return int64(res.(int16)), nil

case int32:
return int64(res.(int32)), nil

case int64:
return res.(int64), nil

case int:
return int64(res.(int)), nil

case uint:
return int64(res.(uint)), nil
}
return 0, fmt.Errorf("could not parse expression result")
}
40 changes: 40 additions & 0 deletions plugin/expression_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package plugin

import (
"testing"

"github.com/hashicorp/nomad-autoscaler/sdk"
"github.com/stretchr/testify/assert"
)

func TestExpression(t *testing.T) {
testCases := []struct {
expression string
count int64
metrics sdk.TimestampedMetrics
expectedCount int64
}{
{
expression: "Count - 1",
count: 5,
metrics: nil,
expectedCount: 4,
},
{
expression: "MetricsAvg == 9 ? 15 : 3",
count: 5,
metrics: sdk.TimestampedMetrics{{Value: 17}, {Value: 1}},
expectedCount: 15,
},
{
expression: "Metrics.Len() == 2 ? 15 : 3",
count: 5,
metrics: sdk.TimestampedMetrics{{Value: 17}, {Value: 1}},
expectedCount: 15,
},
}
for _, tc := range testCases {
res, _ := evaluateExpression(tc.expression, tc.count, tc.metrics)
assert.Equal(t, tc.expectedCount, res)
}
}
25 changes: 20 additions & 5 deletions plugin/plugin.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,9 @@ const (
configKeySeparator = "separator"

// These are the keys read from the RunRequest.Config map.
runConfigKeyCount = "count"
runConfigKeyPeriodPrefix = "period_"
runConfigKeyCount = "count"
runConfigKeyPeriodPrefix = "period_"
runConfigKeyExpressionPrefix = "expression_"
)

var (
Expand Down Expand Up @@ -81,7 +82,7 @@ func (s *StrategyPlugin) SetConfig(config map[string]string) error {

// Run satisfies the Run function on the strategy.Strategy interface.
func (s *StrategyPlugin) Run(eval *sdk.ScalingCheckEvaluation, count int64) (*sdk.ScalingCheckEvaluation, error) {
targetCount, err := s.calculateTargetCount(eval.Check.Strategy.Config, time.Now)
targetCount, err := s.calculateTargetCount(eval.Check.Strategy.Config, count, eval.Metrics, time.Now)
if err != nil {
return eval, err
}
Expand Down Expand Up @@ -116,12 +117,26 @@ func (s *StrategyPlugin) calculateDirection(count, target int64) sdk.ScaleDirect
return sdk.ScaleDirectionDown
}

func (s *StrategyPlugin) calculateTargetCount(config map[string]string, timer func() time.Time) (int64, error) {
func (s *StrategyPlugin) calculateTargetCount(config map[string]string, count int64, metrics sdk.TimestampedMetrics, timer func() time.Time) (int64, error) {
now := timer()

var value int64 = 1
var rules []*Rule

expressionMap := make(map[string]int64)
// 1st pass, pick out the expressions
for k, element := range config {
if strings.HasPrefix(k, runConfigKeyExpressionPrefix) && len(k) > len(runConfigKeyExpressionPrefix) {
exprName := k[len(runConfigKeyExpressionPrefix):]
val, err := evaluateExpression(element, count, metrics)
if err != nil {
s.logger.Warn("could not evaluate expression", "expression", element, "error", err)
continue
}
expressionMap[exprName] = val
}
}

for k, element := range config {
if k == runConfigKeyCount {
v, err := strconv.ParseInt(element, 10, 64)
Expand All @@ -132,7 +147,7 @@ func (s *StrategyPlugin) calculateTargetCount(config map[string]string, timer fu
}

if strings.HasPrefix(k, runConfigKeyPeriodPrefix) {
rule, err := parsePeriodRule(k, element, s.separator)
rule, err := parsePeriodRule(k, element, s.separator, expressionMap)
if err != nil {
return -1, err
}
Expand Down
11 changes: 9 additions & 2 deletions plugin/plugin_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,31 +59,38 @@ func TestStrategyPlugin_calculateTargetCount(t *testing.T) {
"count": "1",
"period_business": "* * 9-17 * * mon-fri * -> 10",
"period_mon_100": "* * 9-17 * * mon * -> 7",
"period_sat": "* * * * * sat * -> 5",
"period_sat": "* * * * * sat * -> a",
"expression_a": "Count - 1",
}

testCases := []struct {
now time.Time
metrics sdk.TimestampedMetrics
expectedCount int64
}{
{
now: time.Date(2021, time.March, 30, 8, 0, 0, 0, location),
metrics: sdk.TimestampedMetrics{{Value: 7}},
expectedCount: 1,
},
{
now: time.Date(2021, time.March, 30, 10, 0, 0, 0, location),
metrics: sdk.TimestampedMetrics{{Value: 7}},
expectedCount: 10,
},
{
now: time.Date(2021, time.March, 29, 10, 0, 0, 0, location),
metrics: sdk.TimestampedMetrics{{Value: 7}},
expectedCount: 7,
},
{
now: time.Date(2021, time.March, 28, 10, 0, 0, 0, location),
metrics: sdk.TimestampedMetrics{{Value: 7}},
expectedCount: 1,
},
{
now: time.Date(2021, time.March, 27, 10, 0, 0, 0, location),
metrics: sdk.TimestampedMetrics{{Value: 7}},
expectedCount: 5,
},
}
Expand All @@ -93,7 +100,7 @@ func TestStrategyPlugin_calculateTargetCount(t *testing.T) {
separator: defaultSeparator,
logger: hclog.NewNullLogger(),
}
count, _ := s.calculateTargetCount(config, fromTime(tc.now))
count, _ := s.calculateTargetCount(config, 6, tc.metrics, fromTime(tc.now))
assert.Equal(t, tc.expectedCount, count)
}
}
Expand Down
8 changes: 6 additions & 2 deletions plugin/rule.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import (
"github.com/hashicorp/cronexpr"
)

func parsePeriodRule(key, value, separator string) (*Rule, error) {
func parsePeriodRule(key, value, separator string, expressionMap map[string]int64) (*Rule, error) {
var count int64 = 1
var priority int64 = 0

Expand All @@ -23,7 +23,11 @@ func parsePeriodRule(key, value, separator string) (*Rule, error) {
if len(entries) > 1 {
v, err := strconv.ParseInt(strings.TrimSpace(entries[1]), 10, 64)
if err != nil {
return nil, err
if exprValue, ok := expressionMap[strings.TrimSpace(entries[1])]; ok {
v = exprValue
} else {
return nil, err
}
}
count = v
}
Expand Down
12 changes: 7 additions & 5 deletions plugin/rule_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,9 @@ func TestRule_New(t *testing.T) {
},
}

exprMap := make(map[string]int64)
for _, tc := range testCases {
rule, err := parsePeriodRule(tc.name, tc.inputValue, ";")
rule, err := parsePeriodRule(tc.name, tc.inputValue, ";", exprMap)
if tc.expectedError {
assert.NotNil(t, err)
assert.Nil(t, rule)
Expand All @@ -49,10 +50,11 @@ func TestRule_New(t *testing.T) {
}

func TestRule_Sort(t *testing.T) {
rule1, _ := parsePeriodRule("a", "* 1 * * *;1", ";")
rule2, _ := parsePeriodRule("b", "* 1 * * *;6", ";")
rule3, _ := parsePeriodRule("c", "* 1 * * *;5", ";")
rule4, _ := parsePeriodRule("c_100", "* 1 * * *;4", ";")
exprMap := make(map[string]int64)
rule1, _ := parsePeriodRule("a", "* 1 * * *;1", ";", exprMap)
rule2, _ := parsePeriodRule("b", "* 1 * * *;6", ";", exprMap)
rule3, _ := parsePeriodRule("c", "* 1 * * *;5", ";", exprMap)
rule4, _ := parsePeriodRule("c_100", "* 1 * * *;4", ";", exprMap)

rules := []*Rule{
rule1,
Expand Down