Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
satellite/nodeselection: YAML based placement configuration
Existing placement configuration proven to be hard to read. And we also added more functionalities to Placement (like selectors or invariants). It's time to use external file, which is easier to read (and easier to maintain the same values for multiple microservice instances). Legacy configuration loading will be removed after 1-2 releases. Change-Id: I8ef4c001b60fb96f04a34eb3acc370349f620dd4
- Loading branch information
Showing
5 changed files
with
449 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,215 @@ | ||
// Copyright (C) 2024 Storj Labs, Inc. | ||
// See LICENSE for copying information. | ||
|
||
package nodeselection | ||
|
||
import ( | ||
"bytes" | ||
"os" | ||
"strings" | ||
|
||
"github.com/jtolio/mito" | ||
"github.com/zeebo/errs" | ||
"gopkg.in/yaml.v3" | ||
|
||
"storj.io/common/storj" | ||
) | ||
|
||
// placementConfig is the representation of YAML based placement configuration. | ||
type placementConfig struct { | ||
|
||
// helpers which can be re-used later to simplify config | ||
Templates map[string]string | ||
|
||
// the placement definitions | ||
Placements []placementDefinition | ||
} | ||
|
||
type placementDefinition struct { | ||
ID storj.PlacementConstraint | ||
Name string | ||
Filter string | ||
Invariant string | ||
Selector string | ||
} | ||
|
||
// LoadConfig loads the placement yaml file and creates the Placement definitions. | ||
func LoadConfig(configFile string) (PlacementDefinitions, error) { | ||
placements := make(PlacementDefinitions) | ||
|
||
cfg := &placementConfig{} | ||
raw, err := os.ReadFile(configFile) | ||
if err != nil { | ||
return placements, errs.New("Couldn't load placement config from file %s: %v", configFile, err) | ||
} | ||
err = yaml.Unmarshal(raw, &cfg) | ||
if err != nil { | ||
return placements, errs.New("Couldn't parse placement config as YAML from file %s: %v", configFile, err) | ||
} | ||
|
||
templates := map[string]string{} | ||
for k, v := range cfg.Templates { | ||
value := v | ||
for a, b := range cfg.Templates { | ||
value = strings.ReplaceAll(value, "$"+a, b) | ||
} | ||
templates[k] = value | ||
} | ||
|
||
resolveTemplates := func(orig string) string { | ||
val := orig | ||
for k, v := range templates { | ||
val = strings.ReplaceAll(val, "$"+k, v) | ||
} | ||
return val | ||
} | ||
|
||
for _, def := range cfg.Placements { | ||
p := Placement{ | ||
ID: def.ID, | ||
Name: def.Name, | ||
} | ||
|
||
filter := resolveTemplates(def.Filter) | ||
p.NodeFilter, err = filterFromString(filter) | ||
if err != nil { | ||
return placements, errs.New("Filter definition '%s' of placement %d is invalid: %v", filter, def.ID, err) | ||
} | ||
|
||
invariant := resolveTemplates(def.Invariant) | ||
p.Invariant, err = invariantFromString(invariant) | ||
if err != nil { | ||
return placements, errs.New("Invariant definition '%s' of placement %d is invalid: %v", invariant, def.ID, err) | ||
} | ||
|
||
selector := resolveTemplates(def.Selector) | ||
p.Selector, err = selectorFromString(selector) | ||
if err != nil { | ||
return placements, errs.New("Selector definition '%s' of placement %d is invalid: %v", selector, def.ID, err) | ||
} | ||
|
||
placements[def.ID] = p | ||
} | ||
return placements, nil | ||
} | ||
|
||
func filterFromString(expr string) (NodeFilter, error) { | ||
if expr == "" { | ||
expr = "all()" | ||
} | ||
env := map[any]any{ | ||
"country": func(countries ...string) (NodeFilter, error) { | ||
return NewCountryFilterFromString(countries) | ||
}, | ||
"all": func(filters ...NodeFilter) (NodeFilters, error) { | ||
res := NodeFilters{} | ||
for _, filter := range filters { | ||
res = append(res, filter) | ||
} | ||
return res, nil | ||
}, | ||
mito.OpAnd: func(env map[any]any, a, b any) (any, error) { | ||
filter1, ok1 := a.(NodeFilter) | ||
filter2, ok2 := b.(NodeFilter) | ||
if !ok1 || !ok2 { | ||
return nil, ErrPlacement.New("&& is supported only between NodeFilter instances") | ||
} | ||
res := NodeFilters{filter1, filter2} | ||
return res, nil | ||
}, | ||
mito.OpOr: func(env map[any]any, a, b any) (any, error) { | ||
filter1, ok1 := a.(NodeFilter) | ||
filter2, ok2 := b.(NodeFilter) | ||
if !ok1 || !ok2 { | ||
return nil, errs.New("OR is supported only between NodeFilter instances") | ||
} | ||
return OrFilter{filter1, filter2}, nil | ||
}, | ||
"tag": func(nodeIDstr string, key string, value any) (NodeFilters, error) { | ||
nodeID, err := storj.NodeIDFromString(nodeIDstr) | ||
if err != nil { | ||
return nil, err | ||
} | ||
|
||
var rawValue []byte | ||
match := bytes.Equal | ||
switch v := value.(type) { | ||
case string: | ||
rawValue = []byte(v) | ||
case []byte: | ||
rawValue = v | ||
case stringNotMatch: | ||
match = func(a, b []byte) bool { | ||
return !bytes.Equal(a, b) | ||
} | ||
rawValue = []byte(v) | ||
default: | ||
return nil, ErrPlacement.New("3rd argument of tag() should be string or []byte") | ||
} | ||
res := NodeFilters{ | ||
NewTagFilter(nodeID, key, rawValue, match), | ||
} | ||
return res, nil | ||
}, | ||
"exclude": func(filter NodeFilter) (NodeFilter, error) { | ||
return NewExcludeFilter(filter), nil | ||
}, | ||
"empty": func() string { | ||
return "" | ||
}, | ||
"notEmpty": func() any { | ||
return stringNotMatch("") | ||
}, | ||
} | ||
filter, err := mito.Eval(expr, env) | ||
if err != nil { | ||
return nil, errs.New("Invalid filter definition '%s', %v", expr, err) | ||
} | ||
return filter.(NodeFilter), nil | ||
} | ||
|
||
func selectorFromString(expr string) (NodeSelectorInit, error) { | ||
if expr == "" { | ||
expr = "random()" | ||
} | ||
env := map[any]any{ | ||
"attribute": func(attribute string) (NodeSelectorInit, error) { | ||
attr, err := CreateNodeAttribute(attribute) | ||
if err != nil { | ||
return nil, err | ||
} | ||
return AttributeGroupSelector(attr), nil | ||
}, | ||
"random": func() (NodeSelectorInit, error) { | ||
return RandomSelector(), nil | ||
}, | ||
"unvetted": func(newNodeRatio float64, def NodeSelectorInit) (NodeSelectorInit, error) { | ||
return UnvettedSelector(newNodeRatio, def), nil | ||
}, | ||
} | ||
selector, err := mito.Eval(expr, env) | ||
if err != nil { | ||
return nil, errs.New("Invalid selector definition '%s', %v", expr, err) | ||
} | ||
return selector.(NodeSelectorInit), nil | ||
} | ||
|
||
func invariantFromString(expr string) (Invariant, error) { | ||
if expr == "" { | ||
return AllGood(), nil | ||
} | ||
env := map[any]any{ | ||
"maxcontrol": func(attribute string, max int64) (Invariant, error) { | ||
attr, err := CreateNodeAttribute(attribute) | ||
if err != nil { | ||
return nil, err | ||
} | ||
return ClumpingByAttribute(attr, int(max)), nil | ||
}, | ||
} | ||
filter, err := mito.Eval(expr, env) | ||
if err != nil { | ||
return nil, errs.New("Invalid invariant definition '%s', %v", expr, err) | ||
} | ||
return filter.(Invariant), nil | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,89 @@ | ||
// Copyright (C) 2024 Storj Labs, Inc. | ||
// See LICENSE for copying information. | ||
|
||
package nodeselection | ||
|
||
import ( | ||
"testing" | ||
|
||
"github.com/stretchr/testify/require" | ||
|
||
"storj.io/common/identity/testidentity" | ||
"storj.io/common/storj" | ||
"storj.io/common/storj/location" | ||
"storj.io/storj/satellite/metabase" | ||
) | ||
|
||
func TestParsedConfig(t *testing.T) { | ||
config, err := LoadConfig("config_test.yaml") | ||
require.NoError(t, err) | ||
require.Len(t, config, 2) | ||
|
||
{ | ||
// checking filters | ||
require.True(t, config[1].NodeFilter.Match(&SelectedNode{ | ||
CountryCode: location.Germany, | ||
})) | ||
require.False(t, config[1].NodeFilter.Match(&SelectedNode{ | ||
CountryCode: location.Russia, | ||
})) | ||
require.Equal(t, "eu-1", config[1].Name) | ||
} | ||
|
||
{ | ||
// checking one invariant | ||
node := func(ix int, owner string) SelectedNode { | ||
return SelectedNode{ | ||
ID: testidentity.MustPregeneratedSignedIdentity(ix, storj.LatestIDVersion()).ID, | ||
Tags: NodeTags{ | ||
{ | ||
Name: "owner", | ||
Value: []byte(owner), | ||
}, | ||
}, | ||
} | ||
} | ||
|
||
piece := func(ix int, nodeIx int) metabase.Piece { | ||
return metabase.Piece{ | ||
Number: uint16(ix), StorageNode: testidentity.MustPregeneratedSignedIdentity(nodeIx, storj.LatestIDVersion()).ID, | ||
} | ||
} | ||
|
||
result := config[0].Invariant( | ||
metabase.Pieces{ | ||
piece(1, 1), | ||
piece(3, 2), | ||
piece(5, 3), | ||
piece(9, 4), | ||
piece(10, 5), | ||
piece(11, 6), | ||
}, | ||
[]SelectedNode{ | ||
node(1, "dery"), | ||
node(2, "blathy"), | ||
node(3, "blathy"), | ||
node(4, "zipernowsky"), | ||
node(5, "zipernowsky"), | ||
node(6, "zipernowsky"), | ||
}) | ||
|
||
// last zipernowsky is too much, as we allow only 2 | ||
require.Equal(t, 1, result.Count()) | ||
} | ||
|
||
{ | ||
// checking a selector | ||
selected, err := config[0].Selector([]*SelectedNode{ | ||
{ | ||
Vetted: false, | ||
}, | ||
}, nil)(1, nil) | ||
|
||
// having: new, requires: 0% unvetted = 100% vetted | ||
require.Len(t, selected, 0) | ||
require.NoError(t, err) | ||
|
||
} | ||
|
||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,14 @@ | ||
templates: | ||
SIGNER_ZERO: 1111111111111111111111111111111VyS547o | ||
NORMAL: exclude(tag("$SIGNER_ZERO","soc2","true")) && exclude(tag("$SIGNER_ZERO","datacenter","true")) | ||
placements: | ||
- id: 0 | ||
name: global | ||
filter: $NORMAL | ||
invariant: maxcontrol("tag:owner",2) | ||
selector: unvetted(0.0,random()) | ||
- id: 1 | ||
name: eu-1 | ||
filter: country("EU") && $NORMAL | ||
invariant: maxcontrol("last_net",1) | ||
selector: attribute("last_net") |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.