{versioncontrol,internal/version}: add rollout to versioncontrol server (#3176)

This commit is contained in:
Bryan White 2019-10-16 10:16:59 +02:00 committed by GitHub
parent ed6b88a12d
commit 951c2891b9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 431 additions and 33 deletions

View File

@ -128,6 +128,11 @@ func (srv *Service) checkVersion(ctx context.Context) (allowed bool) {
return false
}
// isAcceptedVersion compares and checks if the passed version is greater/equal than the minimum required version
func isAcceptedVersion(test SemVer, target SemVer) bool {
return test.Major > target.Major || (test.Major == target.Major && (test.Minor > target.Minor || (test.Minor == target.Minor && test.Patch >= target.Patch)))
}
// QueryVersionFromControlServer handles the HTTP request to gather the allowed and latest version information
func (srv *Service) queryVersionFromControlServer(ctx context.Context) (ver AllowedVersions, err error) {
defer mon.Task()(&ctx)(&err)

View File

@ -4,6 +4,7 @@
package version
import (
"encoding/hex"
"encoding/json"
"fmt"
"regexp"
@ -17,10 +18,19 @@ import (
"storj.io/storj/pkg/pb"
)
// semVerRegex is the regular expression used to parse a semantic version.
// https://github.com/Masterminds/semver/blob/master/LICENSE.txt
const (
semVerRegex string = `v?([0-9]+)\.([0-9]+)\.([0-9]+)`
quote = byte('"')
)
var (
mon = monkit.Package()
verError = errs.Class("version error")
// VerError is the error class for version-related errors.
VerError = errs.Class("version error")
// the following fields are set by linker flags. if any of them
// are set and fail to parse, the program will fail to start
buildTimestamp string // unix seconds since epoch
@ -30,6 +40,8 @@ var (
// Build is a struct containing all relevant build information associated with the binary
Build Info
versionRegex = regexp.MustCompile("^" + semVerRegex + "$")
)
// Info is the versioning information for a binary
@ -51,6 +63,7 @@ type SemVer struct {
}
// AllowedVersions provides the Minimum SemVer per Service
// TODO: I don't think this name is representative of what this struct now holds.
type AllowedVersions struct {
Satellite SemVer
Storagenode SemVer
@ -62,6 +75,7 @@ type AllowedVersions struct {
}
// Processes describes versions for each binary.
// TODO: this name is inconsistent with the versioncontrol server pkg's analogue, `Versions`.
type Processes struct {
Satellite Process `json:"satellite"`
Storagenode Process `json:"storagenode"`
@ -74,6 +88,7 @@ type Processes struct {
type Process struct {
Minimum Version `json:"minimum"`
Suggested Version `json:"suggested"`
Rollout Rollout `json:"rollout"`
}
// Version represents version and download URL for binary.
@ -82,34 +97,54 @@ type Version struct {
URL string `json:"url"`
}
// SemVerRegex is the regular expression used to parse a semantic version.
// https://github.com/Masterminds/semver/blob/master/LICENSE.txt
const SemVerRegex string = `v?([0-9]+)\.([0-9]+)\.([0-9]+)`
// Rollout represents the state of a version rollout.
type Rollout struct {
Seed RolloutBytes `json:"seed"`
Cursor RolloutBytes `json:"cursor"`
}
var versionRegex = regexp.MustCompile("^" + SemVerRegex + "$")
// RolloutBytes implements json un/marshalling using hex de/encoding.
type RolloutBytes [32]byte
// MarshalJSON hex-encodes RolloutBytes and pre/appends JSON string literal quotes.
func (rb RolloutBytes) MarshalJSON() ([]byte, error) {
hexBytes := make([]byte, hex.EncodedLen(len(rb)))
hex.Encode(hexBytes, rb[:])
encoded := append([]byte{quote}, hexBytes...)
encoded = append(encoded, quote)
return encoded, nil
}
// UnmarshalJSON drops the JSON string literal quotes and hex-decodes RolloutBytes .
func (rb *RolloutBytes) UnmarshalJSON(b []byte) error {
if _, err := hex.Decode(rb[:], b[1:len(b)-1]); err != nil {
return VerError.Wrap(err)
}
return nil
}
// NewSemVer parses a given version and returns an instance of SemVer or
// an error if unable to parse the version.
func NewSemVer(v string) (sv SemVer, err error) {
m := versionRegex.FindStringSubmatch(v)
if m == nil {
return SemVer{}, verError.New("invalid semantic version for build %s", v)
return SemVer{}, VerError.New("invalid semantic version for build %s", v)
}
// first entry of m is the entire version string
sv.Major, err = strconv.ParseInt(m[1], 10, 64)
if err != nil {
return SemVer{}, err
return SemVer{}, VerError.Wrap(err)
}
sv.Minor, err = strconv.ParseInt(m[2], 10, 64)
if err != nil {
return SemVer{}, err
return SemVer{}, VerError.Wrap(err)
}
sv.Patch, err = strconv.ParseInt(m[3], 10, 64)
if err != nil {
return SemVer{}, err
return SemVer{}, VerError.Wrap(err)
}
return sv, nil
@ -146,13 +181,16 @@ func (sem *SemVer) String() (version string) {
// New creates Version_Info from a json byte array
func New(data []byte) (v Info, err error) {
err = json.Unmarshal(data, &v)
return v, err
return v, VerError.Wrap(err)
}
// Marshal converts the existing Version Info to any json byte array
func (v Info) Marshal() (data []byte, err error) {
data, err = json.Marshal(v)
return
func (v Info) Marshal() ([]byte, error) {
data, err := json.Marshal(v)
if err != nil {
return nil, VerError.Wrap(err)
}
return data, nil
}
// Proto converts an Info struct to a pb.NodeVersion
@ -167,18 +205,13 @@ func (v Info) Proto() (*pb.NodeVersion, error) {
}, nil
}
// isAcceptedVersion compares and checks if the passed version is greater/equal than the minimum required version
func isAcceptedVersion(test SemVer, target SemVer) bool {
return test.Major > target.Major || (test.Major == target.Major && (test.Minor > target.Minor || (test.Minor == target.Minor && test.Patch >= target.Patch)))
}
func init() {
if buildVersion == "" && buildTimestamp == "" && buildCommitHash == "" && buildRelease == "" {
return
}
timestamp, err := strconv.ParseInt(buildTimestamp, 10, 64)
if err != nil {
panic(verError.Wrap(err))
panic(VerError.Wrap(err))
}
Build = Info{
Timestamp: time.Unix(timestamp, 0),

View File

@ -4,6 +4,7 @@
package version_test
import (
"encoding/json"
"testing"
"github.com/stretchr/testify/require"
@ -42,3 +43,26 @@ func TestSemVer_Compare(t *testing.T) {
require.True(t, version030.Compare(version002) > 0)
require.True(t, version600.Compare(version040) > 0)
}
func TestRollout_MarshalJSON_UnmarshalJSON(t *testing.T) {
var expectedRollout, actualRollout version.Rollout
for i := 0; i < len(version.RolloutBytes{}); i++ {
expectedRollout.Seed[i] = byte(i)
expectedRollout.Cursor[i] = byte(i * 2)
}
_, err := json.Marshal(actualRollout.Seed)
require.NoError(t, err)
emptyJSONRollout, err := json.Marshal(actualRollout)
require.NoError(t, err)
jsonRollout, err := json.Marshal(expectedRollout)
require.NoError(t, err)
require.NotEqual(t, emptyJSONRollout, jsonRollout)
err = json.Unmarshal(jsonRollout, &actualRollout)
require.NoError(t, err)
require.Equal(t, expectedRollout, actualRollout)
}

View File

@ -5,9 +5,12 @@ package versioncontrol
import (
"context"
"encoding/hex"
"encoding/json"
"math/big"
"net"
"net/http"
"reflect"
"github.com/zeebo/errs"
"go.uber.org/zap"
@ -17,7 +20,17 @@ import (
"storj.io/storj/internal/version"
)
// Config is all the configuration parameters for a Version Control Server
// seedLength is the number of bytes in a rollout seed.
const seedLength = 32
var (
// RolloutErr defines the rollout config error class.
RolloutErr = errs.Class("rollout config error")
// EmptySeedErr is used when the rollout contains an empty seed value.
EmptySeedErr = RolloutErr.New("empty seed")
)
// Config is all the configuration parameters for a Version Control Server.
type Config struct {
Address string `user:"true" help:"public address to listen on" default:":8080"`
Versions ServiceVersions
@ -25,7 +38,7 @@ type Config struct {
Binary Versions
}
// ServiceVersions provides a list of allowed Versions per Service
// ServiceVersions provides a list of allowed Versions per Service.
type ServiceVersions struct {
Satellite string `user:"true" help:"Allowed Satellite Versions" default:"v0.0.1"`
Storagenode string `user:"true" help:"Allowed Storagenode Versions" default:"v0.0.1"`
@ -34,7 +47,8 @@ type ServiceVersions struct {
Identity string `user:"true" help:"Allowed Identity Versions" default:"v0.0.1"`
}
// Versions represents versions for all binaries
// Versions represents versions for all binaries.
// TODO: this name is inconsistent with the internal/version pkg's analogue, `Processes`.
type Versions struct {
Satellite Binary
Storagenode Binary
@ -43,18 +57,26 @@ type Versions struct {
Identity Binary
}
// Binary represents versions for single binary
// Binary represents versions for single binary.
// TODO: This name is inconsistent with the internal/version pkg's analogue, `Process`.
type Binary struct {
Minimum Version
Suggested Version
Rollout Rollout
}
// Version single version
// Version single version.
type Version struct {
Version string `user:"true" help:"peer version" default:"v0.0.1"`
URL string `user:"true" help:"URL for specific binary" default:""`
}
// Rollout represents the state of a version rollout of a binary to the suggested version.
type Rollout struct {
Seed string `user:"true" help:"random 32 byte, hex-encoded string"`
Cursor int `user:"true" help:"percentage of nodes which should roll-out to the suggested version" default:"0"`
}
// Peer is the representation of a VersionControl Server.
type Peer struct {
// core dependencies
@ -71,7 +93,7 @@ type Peer struct {
response []byte
}
// HandleGet contains the request handler for the version control web server
// HandleGet contains the request handler for the version control web server.
func (peer *Peer) HandleGet(w http.ResponseWriter, r *http.Request) {
// Only handle GET Requests
if r.Method != http.MethodGet {
@ -94,6 +116,10 @@ func (peer *Peer) HandleGet(w http.ResponseWriter, r *http.Request) {
// New creates a new VersionControl Server.
func New(log *zap.Logger, config *Config) (peer *Peer, err error) {
if err := config.Binary.ValidateRollouts(log); err != nil {
return nil, RolloutErr.Wrap(err)
}
peer = &Peer{
Log: log,
}
@ -125,14 +151,32 @@ func New(log *zap.Logger, config *Config) (peer *Peer, err error) {
}
peer.Versions.Processes = version.Processes{}
peer.Versions.Processes.Satellite = configToProcess(config.Binary.Satellite)
peer.Versions.Processes.Storagenode = configToProcess(config.Binary.Storagenode)
peer.Versions.Processes.Uplink = configToProcess(config.Binary.Uplink)
peer.Versions.Processes.Gateway = configToProcess(config.Binary.Gateway)
peer.Versions.Processes.Identity = configToProcess(config.Binary.Identity)
peer.Versions.Processes.Satellite, err = configToProcess(config.Binary.Satellite)
if err != nil {
return nil, RolloutErr.Wrap(err)
}
peer.Versions.Processes.Storagenode, err = configToProcess(config.Binary.Storagenode)
if err != nil {
return nil, RolloutErr.Wrap(err)
}
peer.Versions.Processes.Uplink, err = configToProcess(config.Binary.Uplink)
if err != nil {
return nil, RolloutErr.Wrap(err)
}
peer.Versions.Processes.Gateway, err = configToProcess(config.Binary.Gateway)
if err != nil {
return nil, RolloutErr.Wrap(err)
}
peer.Versions.Processes.Identity, err = configToProcess(config.Binary.Identity)
if err != nil {
return nil, RolloutErr.Wrap(err)
}
peer.response, err = json.Marshal(peer.Versions)
if err != nil {
peer.Log.Sugar().Fatalf("Error marshalling version info: %v", err)
}
@ -178,8 +222,67 @@ func (peer *Peer) Close() (err error) {
// Addr returns the public address.
func (peer *Peer) Addr() string { return peer.Server.Listener.Addr().String() }
func configToProcess(binary Binary) version.Process {
return version.Process{
// ValidateRollouts validates the rollout field of each field in the Versions struct.
func (versions Versions) ValidateRollouts(log *zap.Logger) error {
value := reflect.ValueOf(versions)
fieldCount := value.NumField()
validationErrs := errs.Group{}
for i := 1; i < fieldCount; i++ {
binary, ok := value.Field(i).Interface().(Binary)
if !ok {
log.Warn("non-binary field in versions config struct", zap.String("field name", value.Type().Field(i).Name))
continue
}
if err := binary.Rollout.Validate(); err != nil {
if err == EmptySeedErr {
log.Warn(err.Error(), zap.String("binary", value.Type().Field(i).Name))
continue
}
validationErrs.Add(err)
}
}
return validationErrs.Err()
}
// Validate validates the rollout seed and cursor config values.
func (rollout Rollout) Validate() error {
seedLen := len(rollout.Seed)
if seedLen == 0 {
return EmptySeedErr
}
if seedLen != hex.EncodedLen(seedLength) {
return RolloutErr.New("invalid seed length: %d", seedLen)
}
if rollout.Cursor < 0 || rollout.Cursor > 100 {
return RolloutErr.New("invalid cursor percentage: %d", rollout.Cursor)
}
if _, err := hex.DecodeString(rollout.Seed); err != nil {
return RolloutErr.New("invalid seed: %s", rollout.Seed)
}
return nil
}
func percentageToCursor(pct int) version.RolloutBytes {
// NB: convert the max value to a number, multiply by the percentage, convert back.
var maxInt, maskInt big.Int
var maxBytes version.RolloutBytes
for i := 0; i < len(maxBytes); i++ {
maxBytes[i] = 255
}
maxInt.SetBytes(maxBytes[:])
maskInt.Div(maskInt.Mul(&maxInt, big.NewInt(int64(pct))), big.NewInt(100))
var cursor version.RolloutBytes
copy(cursor[:], maskInt.Bytes())
return cursor
}
func configToProcess(binary Binary) (version.Process, error) {
process := version.Process{
Minimum: version.Version{
Version: binary.Minimum.Version,
URL: binary.Minimum.URL,
@ -188,5 +291,15 @@ func configToProcess(binary Binary) version.Process {
Version: binary.Suggested.Version,
URL: binary.Suggested.URL,
},
Rollout: version.Rollout{
Cursor: percentageToCursor(binary.Rollout.Cursor),
},
}
seedBytes, err := hex.DecodeString(binary.Rollout.Seed)
if err != nil {
return version.Process{}, err
}
copy(process.Rollout.Seed[:], seedBytes)
return process, nil
}

223
versioncontrol/peer_test.go Normal file
View File

@ -0,0 +1,223 @@
// Copyright (C) 2019 Storj Labs, Inc.
// See LICENSE for copying information.
package versioncontrol_test
import (
"encoding/hex"
"math/rand"
"reflect"
"testing"
"github.com/stretchr/testify/require"
"go.uber.org/zap/zaptest"
"storj.io/storj/versioncontrol"
)
var rolloutErrScenarios = []struct {
name string
rollout versioncontrol.Rollout
errContains string
}{
{
"short seed",
versioncontrol.Rollout{
// 31 byte seed
Seed: "00000000000000000000000000000000000000000000000000000000000000",
Cursor: 0,
},
"invalid seed length:",
},
{
"long seed",
versioncontrol.Rollout{
// 33 byte seed
Seed: "000000000000000000000000000000000000000000000000000000000000000000",
Cursor: 0,
},
"invalid seed length:",
},
{
"invalid seed",
versioncontrol.Rollout{
// non-hex seed
Seed: "G000000000000000000000000000000000000000000000000000000000000000",
Cursor: 0,
},
"invalid seed:",
},
{
"negative cursor",
versioncontrol.Rollout{
Seed: "0000000000000000000000000000000000000000000000000000000000000000",
Cursor: -1,
},
"invalid cursor percentage:",
},
{
"cursor too big",
versioncontrol.Rollout{
Seed: "0000000000000000000000000000000000000000000000000000000000000000",
Cursor: 101,
},
"invalid cursor percentage:",
},
}
func TestPeer_Run(t *testing.T) {
testVersion := "v0.0.1"
testServiceVersions := versioncontrol.ServiceVersions{
Gateway: testVersion,
Identity: testVersion,
Satellite: testVersion,
Storagenode: testVersion,
Uplink: testVersion,
}
t.Run("random rollouts", func(t *testing.T) {
for i := 0; i < 100; i++ {
config := versioncontrol.Config{
Versions: testServiceVersions,
Binary: validRandVersions(t),
}
peer, err := versioncontrol.New(zaptest.NewLogger(t), &config)
require.NoError(t, err)
require.NotNil(t, peer)
}
})
t.Run("empty rollout seed", func(t *testing.T) {
versionsType := reflect.TypeOf(versioncontrol.Versions{})
fieldCount := versionsType.NumField()
// test invalid rollout for each binary
for i := 1; i < fieldCount; i++ {
versions := versioncontrol.Versions{}
versionsValue := reflect.ValueOf(&versions)
field := reflect.Indirect(versionsValue).Field(i)
binary := versioncontrol.Binary{
Rollout: versioncontrol.Rollout{
Seed: "",
Cursor: 0,
},
}
field.Set(reflect.ValueOf(binary))
config := versioncontrol.Config{
Versions: testServiceVersions,
Binary: versions,
}
peer, err := versioncontrol.New(zaptest.NewLogger(t), &config)
require.NoError(t, err)
require.NotNil(t, peer)
}
})
}
func TestPeer_Run_error(t *testing.T) {
for _, scenario := range rolloutErrScenarios {
scenario := scenario
t.Run(scenario.name, func(t *testing.T) {
versionsType := reflect.TypeOf(versioncontrol.Versions{})
fieldCount := versionsType.NumField()
// test invalid rollout for each binary
for i := 1; i < fieldCount; i++ {
versions := versioncontrol.Versions{}
versionsValue := reflect.ValueOf(&versions)
field := reflect.Indirect(versionsValue).Field(i)
binary := versioncontrol.Binary{
Rollout: scenario.rollout,
}
field.Set(reflect.ValueOf(binary))
config := versioncontrol.Config{
Binary: versions,
}
peer, err := versioncontrol.New(zaptest.NewLogger(t), &config)
require.Nil(t, peer)
require.Error(t, err)
require.Contains(t, err.Error(), scenario.errContains)
}
})
}
}
func TestVersions_ValidateRollouts(t *testing.T) {
versions := validRandVersions(t)
err := versions.ValidateRollouts(zaptest.NewLogger(t))
require.NoError(t, err)
}
func TestRollout_Validate(t *testing.T) {
for i := 0; i < 100; i++ {
rollout := versioncontrol.Rollout{
Seed: randSeedString(t),
Cursor: i,
}
err := rollout.Validate()
require.NoError(t, err)
}
}
func TestRollout_Validate_error(t *testing.T) {
for _, scenario := range rolloutErrScenarios {
scenario := scenario
t.Run(scenario.name, func(t *testing.T) {
err := scenario.rollout.Validate()
require.Error(t, err)
require.True(t, versioncontrol.RolloutErr.Has(err))
require.Contains(t, err.Error(), scenario.errContains)
})
}
}
func validRandVersions(t *testing.T) versioncontrol.Versions {
t.Helper()
return versioncontrol.Versions{
Satellite: versioncontrol.Binary{
Rollout: randRollout(t),
},
Storagenode: versioncontrol.Binary{
Rollout: randRollout(t),
},
Uplink: versioncontrol.Binary{
Rollout: randRollout(t),
},
Gateway: versioncontrol.Binary{
Rollout: randRollout(t),
},
Identity: versioncontrol.Binary{
Rollout: randRollout(t),
},
}
}
func randRollout(t *testing.T) versioncontrol.Rollout {
t.Helper()
return versioncontrol.Rollout{
Seed: randSeedString(t),
Cursor: rand.Intn(101),
}
}
func randSeedString(t *testing.T) string {
t.Helper()
seed := make([]byte, 32)
_, err := rand.Read(seed)
require.NoError(t, err)
return hex.EncodeToString(seed)
}