Skip to content

Commit

Permalink
satellite/nodeselection: YAML based placement configuration
Browse files Browse the repository at this point in the history
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
elek authored and mniewrzal committed Jan 17, 2024
1 parent f104fc9 commit 17af0f0
Show file tree
Hide file tree
Showing 5 changed files with 449 additions and 0 deletions.
215 changes: 215 additions & 0 deletions satellite/nodeselection/config.go
@@ -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
}
89 changes: 89 additions & 0 deletions satellite/nodeselection/config_test.go
@@ -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)

}

}
14 changes: 14 additions & 0 deletions satellite/nodeselection/config_test.yaml
@@ -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")
6 changes: 6 additions & 0 deletions satellite/nodeselection/placement.go
Expand Up @@ -104,6 +104,11 @@ func (c ConfigurablePlacementRule) Parse(defaultPlacement func() (Placement, err
}
rules := c.PlacementRules
if _, err := os.Stat(rules); err == nil {
if strings.HasSuffix(rules, ".yaml") {
// new style of config, all others are deprecated
return LoadConfig(rules)

}
ruleBytes, err := os.ReadFile(rules)
if err != nil {
return nil, ErrPlacement.New("Placement definition file couldn't be read: %s %v", rules, err)
Expand Down Expand Up @@ -193,6 +198,7 @@ func (d PlacementDefinitions) AddPlacementRule(id storj.PlacementConstraint, fil
type stringNotMatch string

// AddPlacementFromString parses placement definition form string representations from id:definition;id:definition;...
// Deprecated: we will switch to the YAML based configuration.
func (d PlacementDefinitions) AddPlacementFromString(definitions string) error {
env := map[any]any{
"country": func(countries ...string) (NodeFilter, error) {
Expand Down

0 comments on commit 17af0f0

Please sign in to comment.