diff --git a/netmap/attributes.go b/netmap/attributes.go new file mode 100644 index 0000000..07ce268 --- /dev/null +++ b/netmap/attributes.go @@ -0,0 +1,208 @@ +package netmap + +import ( + "errors" + "fmt" + "strings" + + "github.com/nspcc-dev/neofs-api-go/v2/refs" +) + +// prefix of keys to subnet attributes. +const attrSubnetPrefix = "__NEOFS__SUBNET_" + +const ( + // subnet attribute's value denoting subnet entry + attrSubnetValEntry = "True" + + // subnet attribute's value denoting subnet exit + attrSubnetValExit = "False" +) + +// NodeSubnetInfo groups information about subnet which can be written to NodeInfo. +// +// Zero value represents entry to zero subnet. +type NodeSubnetInfo struct { + exit bool + + id *refs.SubnetID +} + +// Enters returns true iff node enters the subnet. +func (x NodeSubnetInfo) Enters() bool { + return !x.exit +} + +// SetEntryFlag sets the subnet entry flag. +func (x *NodeSubnetInfo) SetEntryFlag(enters bool) { + x.exit = !enters +} + +// ID returns identifier of the subnet. +func (x NodeSubnetInfo) ID() *refs.SubnetID { + return x.id +} + +// SetID sets identifier of the subnet. +func (x *NodeSubnetInfo) SetID(id *refs.SubnetID) { + x.id = id +} + +func subnetAttributeKey(id *refs.SubnetID) string { + txt, _ := id.MarshalText() // never returns an error + + return attrSubnetPrefix + string(txt) +} + +// WriteSubnetInfo writes NodeSubnetInfo to NodeInfo via attributes. NodeInfo must not be nil. +// +// Does not add (removes existing) attribute if node: +// * exists non-zero subnet; +// * enters zero subnet. +// +// Attribute key is calculated from ID using format `__NEOFS__SUBNET_%s`. +// Attribute Value is: +// * `True` if node enters the subnet; +// * `False`, otherwise. +func WriteSubnetInfo(node *NodeInfo, info NodeSubnetInfo) { + attrs := node.GetAttributes() + + id := info.ID() + enters := info.Enters() + + // calculate attribute key + key := subnetAttributeKey(id) + + if refs.IsZeroSubnet(id) == enters { + for i := range attrs { + if attrs[i].GetKey() == key { + attrs = append(attrs[:i], attrs[i+1:]...) + } + } + } else { + var val string + + if enters { + val = attrSubnetValEntry + } else { + val = attrSubnetValExit + } + + presented := false + + for i := range attrs { + if attrs[i].GetKey() == key { + attrs[i].SetValue(val) + presented = true + } + } + + if !presented { + var attr Attribute + + attr.SetKey(key) + attr.SetValue(val) + + attrs = append(attrs, &attr) + } + } + + node.SetAttributes(attrs) +} + +// ErrRemoveSubnet is returned when a node needs to leave the subnet. +var ErrRemoveSubnet = errors.New("remove subnet") + +// IterateSubnets iterates over all subnets the node belongs to and passes the IDs to f. +// Handler must not be nil. +// +// If f returns ErrRemoveSubnet, then removes subnet entry. Breaks on any other non-nil error and returns it. +// +// Returns an error if any subnet attribute has wrong format. +func IterateSubnets(node *NodeInfo, f func(refs.SubnetID) error) error { + attrs := node.GetAttributes() + + var ( + err error + id refs.SubnetID + metZero bool // if zero subnet's attribute was met in for-loop + ) + + for i := 0; i < len(attrs); i++ { // range must not be used because of attrs mutation in body + key := attrs[i].GetKey() + + // cut subnet ID string + idTxt := strings.TrimPrefix(key, attrSubnetPrefix) + if idTxt == key { + // not a subnet attribute + continue + } + + // check value + switch val := attrs[i].GetValue(); val { + default: + return fmt.Errorf("invalid attribute value: %s", val) + case attrSubnetValExit: + // node is outside the subnet + continue + case attrSubnetValEntry: + // required to avoid default case + } + + // decode subnet ID + if err = id.UnmarshalText([]byte(idTxt)); err != nil { + return fmt.Errorf("invalid ID text: %w", err) + } + + // pass ID to the handler + err = f(id) + + isRemoveErr := errors.Is(err, ErrRemoveSubnet) + + if err != nil && !isRemoveErr { + return err + } + + if !metZero { // in order to not reset if has been already set + metZero = refs.IsZeroSubnet(&id) + + if !isRemoveErr { + // no handler's error and non-zero subnet + continue + } else if metZero { + // removal error and zero subnet. + // we don't remove attribute of zero subnet because it means entry + attrs[i].SetValue(attrSubnetValExit) + + continue + } + } + + if isRemoveErr { + // removal error and non-zero subnet. + // we can set False or remove attribute, latter is more memory/network efficient. + attrs = append(attrs[:i], attrs[i+1:]...) + i-- + } + } + + if !metZero { + // missing attribute of zero subnet equivalent to entry + refs.MakeZeroSubnet(&id) + + err = f(id) + if errors.Is(err, ErrRemoveSubnet) { + // zero subnet should be clearly removed with False value + var attr Attribute + + attr.SetKey(subnetAttributeKey(&id)) + attr.SetValue(attrSubnetValExit) + + attrs = append(attrs, &attr) + } + } + + node.SetAttributes(attrs) + + return nil +} diff --git a/netmap/attributes_test.go b/netmap/attributes_test.go new file mode 100644 index 0000000..5cee47e --- /dev/null +++ b/netmap/attributes_test.go @@ -0,0 +1,226 @@ +package netmap_test + +import ( + "strconv" + "testing" + + "github.com/nspcc-dev/neofs-api-go/v2/netmap" + "github.com/nspcc-dev/neofs-api-go/v2/refs" + "github.com/stretchr/testify/require" +) + +func subnetAttrKey(val string) string { + return "__NEOFS__SUBNET_" + val +} + +func assertSubnetAttrKey(t *testing.T, attr *netmap.Attribute, num uint32) { + require.Equal(t, subnetAttrKey(strconv.FormatUint(uint64(num), 10)), attr.GetKey()) +} + +func TestWriteSubnetInfo(t *testing.T) { + t.Run("entry", func(t *testing.T) { + t.Run("zero subnet", func(t *testing.T) { + var ( + node netmap.NodeInfo + info netmap.NodeSubnetInfo + ) + + netmap.WriteSubnetInfo(&node, info) + + // entry to zero subnet does not require an attribute + attrs := node.GetAttributes() + require.Empty(t, attrs) + + // exit the subnet + info.SetEntryFlag(false) + + netmap.WriteSubnetInfo(&node, info) + + // exit from zero subnet should be clearly reflected in attributes + attrs = node.GetAttributes() + require.Len(t, attrs, 1) + + attr := attrs[0] + assertSubnetAttrKey(t, attr, 0) + require.Equal(t, "False", attr.GetValue()) + + // again enter to zero subnet + info.SetEntryFlag(true) + + netmap.WriteSubnetInfo(&node, info) + + // attribute should be removed + attrs = node.GetAttributes() + require.Empty(t, attrs) + }) + + t.Run("non-zero subnet", func(t *testing.T) { + var ( + node netmap.NodeInfo + info netmap.NodeSubnetInfo + id refs.SubnetID + ) + + // create non-zero subnet ID + const num = 15 + + id.SetValue(num) + + // enter to the subnet + info.SetID(&id) + info.SetEntryFlag(true) + + netmap.WriteSubnetInfo(&node, info) + + // check attribute format + attrs := node.GetAttributes() + require.Len(t, attrs, 1) + + attr := attrs[0] + assertSubnetAttrKey(t, attr, num) + require.Equal(t, "True", attr.GetValue()) + + // again exit the subnet + info.SetEntryFlag(false) + + netmap.WriteSubnetInfo(&node, info) + + // attribute should be removed + attrs = node.GetAttributes() + require.Empty(t, attrs) + }) + }) +} + +func TestSubnets(t *testing.T) { + t.Run("empty", func(t *testing.T) { + var node netmap.NodeInfo + + called := 0 + + err := netmap.IterateSubnets(&node, func(id refs.SubnetID) error { + called++ + + require.True(t, refs.IsZeroSubnet(&id)) + + return nil + }) + + require.NoError(t, err) + require.EqualValues(t, 1, called) + }) + + t.Run("with correct attribute", func(t *testing.T) { + var ( + node netmap.NodeInfo + attr netmap.Attribute + ) + + attr.SetKey(subnetAttrKey("13")) + attr.SetValue("True") + + attrs := []*netmap.Attribute{&attr} + + node.SetAttributes(attrs) + + called := 0 + + err := netmap.IterateSubnets(&node, func(id refs.SubnetID) error { + if !refs.IsZeroSubnet(&id) { + called++ + require.EqualValues(t, 13, id.GetValue()) + } + + return nil + }) + + require.NoError(t, err) + require.EqualValues(t, 1, called) + }) + + t.Run("with incorrect attribute", func(t *testing.T) { + assertErr := func(attr netmap.Attribute) { + var node netmap.NodeInfo + + node.SetAttributes([]*netmap.Attribute{&attr}) + + require.Error(t, netmap.IterateSubnets(&node, func(refs.SubnetID) error { + return nil + })) + } + + t.Run("incorrect key", func(t *testing.T) { + var attr netmap.Attribute + + attr.SetKey(subnetAttrKey("one-two-three")) + + assertErr(attr) + }) + + t.Run("incorrect value", func(t *testing.T) { + var attr netmap.Attribute + + attr.SetKey(subnetAttrKey("1")) + + for _, invalidVal := range []string{ + "", + "Troo", + "Fols", + } { + attr.SetValue(invalidVal) + assertErr(attr) + } + + assertErr(attr) + }) + }) + + t.Run("remove entry", func(t *testing.T) { + t.Run("zero", func(t *testing.T) { + var node netmap.NodeInfo + + err := netmap.IterateSubnets(&node, func(id refs.SubnetID) error { + if refs.IsZeroSubnet(&id) { + return netmap.ErrRemoveSubnet + } + + return nil + }) + + require.NoError(t, err) + + attrs := node.GetAttributes() + require.Len(t, attrs, 1) + + attr := attrs[0] + assertSubnetAttrKey(t, attr, 0) + require.Equal(t, "False", attr.GetValue()) + }) + + t.Run("non-zero", func(t *testing.T) { + var ( + node netmap.NodeInfo + attr netmap.Attribute + ) + + attr.SetKey(subnetAttrKey("99")) + attr.SetValue("True") + + attrs := []*netmap.Attribute{&attr} + node.SetAttributes(attrs) + + err := netmap.IterateSubnets(&node, func(id refs.SubnetID) error { + if !refs.IsZeroSubnet(&id) { + return netmap.ErrRemoveSubnet + } + + return nil + }) + + require.NoError(t, err) + + attrs = node.GetAttributes() + require.Empty(t, attrs) + }) + }) +}