diff --git a/CHANGELOG.md b/CHANGELOG.md index 1e9e10e..cbd67ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,4 +2,12 @@ This project adheres to [Semantic Versioning](http://semver.org/). -Every release, along with the migration instructions, is documented on the Github [Releases page](https://github.com/SalesLoft/open-source-template/releases). \ No newline at end of file +Every release, along with the migration instructions, is documented on the Github [Releases page](https://github.com/SalesLoft/gorollout/releases). + +### v1.1.0 + +Added support for controlling the randomizing percentage between feature flags. Setting to false will ensure that features are active for the same teams when rolled out the same percentage. + +### v1.0.0 + +Initial release of library. Feature complete and ready for production use. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..a651d8f --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,30 @@ +# Introduction + +First off, thank you for considering contributing to gorollout. Following these guidelines helps to communicate that you respect the time of the developers managing and developing this open source project. In return, they should reciprocate that respect in addressing your issue, assessing changes, and helping you finalize your pull requests. + +Keep an open mind! Improving documentation, bug triaging, or writing tutorials are all examples of helpful contributions that mean less work for you. + +# Ground Rules + +This includes not just how to communicate with others (being respectful, considerate, etc) but also technical responsibilities (importance of testing, project dependencies, etc). + +Responsibilities + +* Ensure cross-platform compatibility for every change that's accepted. Windows, Mac, Debian & Ubuntu Linux. +* Code should be properly formatted using `gofmt`. +* Create issues for any major changes and enhancements that you wish to make. Discuss things transparently and get community feedback. +* Keep feature versions as small as possible, preferably one new feature per version. +* Semantic versioning will be used. +* Be welcoming to newcomers and encourage diverse new contributors from all backgrounds. + +# How to report a bug + +If you find a security vulnerability, do NOT open an issue. Email [security@salesloft.com](mailto:security@salesloft.com) instead. + +When filing an issue, make sure to answer these five questions: + +1. What version of Go are you using (go version)? +1. What operating system and processor architecture are you using? +1. What did you do? +1. What did you expect to see? +1. What did you see instead? diff --git a/README.md b/README.md index 1db3953..aa3616c 100644 --- a/README.md +++ b/README.md @@ -46,8 +46,8 @@ func main() { // check if a feature is active, globally manager.IsActive(apples) - // check if a feature is active for a specific team - manager.IsTeamActive(99, apples) + // check if a feature is active for a specific team (randomize percentage disabled) + manager.IsTeamActive(99, apples, false) // check multiple feature flags at once manager.IsActiveMulti(apples, bananas) diff --git a/cmd/rollout/main.go b/cmd/rollout/main.go index 70f3de1..f5e4546 100644 --- a/cmd/rollout/main.go +++ b/cmd/rollout/main.go @@ -186,6 +186,7 @@ func activatePercentageFeatureFlag(c *cli.Context) error { }, ), c.String("prefix"), + false, ) return manager.ActivatePercentage(ff, uint8(percentage)) @@ -204,6 +205,7 @@ func activateFeatureFlag(c *cli.Context) error { }, ), c.String("prefix"), + false, ) return manager.Activate(ff) @@ -222,6 +224,7 @@ func deactivateFeatureFlag(c *cli.Context) error { }, ), c.String("prefix"), + false, ) return manager.Deactivate(ff) @@ -250,6 +253,7 @@ func activateTeamFeatureFlag(c *cli.Context) error { }, ), c.String("prefix"), + false, ) return manager.ActivateTeam(teamID, ff) @@ -278,6 +282,7 @@ func deactivateTeamFeatureFlag(c *cli.Context) error { }, ), c.String("prefix"), + false, ) return manager.DeactivateTeam(teamID, ff) diff --git a/feature.go b/feature.go index b0c3a64..225f605 100644 --- a/feature.go +++ b/feature.go @@ -71,12 +71,18 @@ func (f *Feature) deactivateTeam(teamID int64) { delete(f.teamIDs, teamID) } -func (f *Feature) isTeamActive(teamID int64) bool { +func (f *Feature) isTeamActive(teamID int64, randomizePercentage bool) bool { if f.percentage == 100 { + // feature is globally active return true - } else if crc32.ChecksumIEEE([]byte(f.name+strconv.FormatInt(teamID, 10))) < randBase*uint32(f.percentage) { + } else if randomizePercentage && crc32.ChecksumIEEE([]byte(f.name+strconv.FormatInt(teamID, 10))) < randBase*uint32(f.percentage) { + // include the feature name in the checksum when randomizing percentage + return true + } else if !randomizePercentage && crc32.ChecksumIEEE([]byte(strconv.FormatInt(teamID, 10))) < randBase*uint32(f.percentage) { + // only use the team id for the checksum when not randomizing the percentage return true } else if _, active := f.teamIDs[teamID]; active { + // check if the team is explicitly active return true } diff --git a/feature_test.go b/feature_test.go index ae81577..1a911a0 100644 --- a/feature_test.go +++ b/feature_test.go @@ -33,9 +33,9 @@ func TestEncodeDecode(t *testing.T) { assert.EqualValues(t, in.Name(), out.Name()) assert.EqualValues(t, in.percentage, out.percentage) assert.EqualValues(t, in.teamIDs, out.teamIDs) - assert.True(t, out.isTeamActive(1)) - assert.True(t, out.isTeamActive(2)) - assert.True(t, out.isTeamActive(3)) + assert.True(t, out.isTeamActive(1, false)) + assert.True(t, out.isTeamActive(2, false)) + assert.True(t, out.isTeamActive(3, false)) // encode just percentage in = NewFeature("example") @@ -69,39 +69,39 @@ func TestEncodeDecode(t *testing.T) { assert.EqualValues(t, in.percentage, out.percentage) assert.EqualValues(t, in.teamIDs, out.teamIDs) - assert.True(t, out.isTeamActive(1)) - assert.True(t, out.isTeamActive(2)) - assert.True(t, out.isTeamActive(3)) + assert.True(t, out.isTeamActive(1, false)) + assert.True(t, out.isTeamActive(2, false)) + assert.True(t, out.isTeamActive(3, false)) } func TestEnableDisableTeam(t *testing.T) { f := NewFeature("example") - assert.False(t, f.isTeamActive(1)) + assert.False(t, f.isTeamActive(1, false)) f.activateTeam(1) f.activateTeam(2) - assert.True(t, f.isTeamActive(1)) - assert.True(t, f.isTeamActive(2)) - assert.False(t, f.isTeamActive(3)) + assert.True(t, f.isTeamActive(1, false)) + assert.True(t, f.isTeamActive(2, false)) + assert.False(t, f.isTeamActive(3, false)) f.deactivateTeam(1) - assert.False(t, f.isTeamActive(1)) - assert.True(t, f.isTeamActive(2)) + assert.False(t, f.isTeamActive(1, false)) + assert.True(t, f.isTeamActive(2, false)) } func TestEnableDisableFeature(t *testing.T) { f := NewFeature("example") - assert.False(t, f.isTeamActive(1)) + assert.False(t, f.isTeamActive(1, false)) f.activate() - assert.True(t, f.isTeamActive(1)) - assert.True(t, f.isTeamActive(999999999999)) + assert.True(t, f.isTeamActive(1, false)) + assert.True(t, f.isTeamActive(999999999999, false)) f.deactivate() - assert.False(t, f.isTeamActive(1)) - assert.False(t, f.isTeamActive(99999999999)) + assert.False(t, f.isTeamActive(1, false)) + assert.False(t, f.isTeamActive(99999999999, false)) } func TestRollout(t *testing.T) { @@ -111,33 +111,33 @@ func TestRollout(t *testing.T) { // 25% < Team 2 < 50% // 0 % < Team 3 < 25% - assert.False(t, f.isTeamActive(1)) - assert.False(t, f.isTeamActive(2)) - assert.False(t, f.isTeamActive(3)) + assert.False(t, f.isTeamActive(1, true)) + assert.False(t, f.isTeamActive(2, true)) + assert.False(t, f.isTeamActive(3, true)) f.activatePercentage(25) - assert.False(t, f.isTeamActive(1)) - assert.False(t, f.isTeamActive(2)) - assert.True(t, f.isTeamActive(3)) + assert.False(t, f.isTeamActive(1, true)) + assert.False(t, f.isTeamActive(2, true)) + assert.True(t, f.isTeamActive(3, true)) f.activatePercentage(50) - assert.False(t, f.isTeamActive(1)) - assert.True(t, f.isTeamActive(2)) - assert.True(t, f.isTeamActive(3)) + assert.False(t, f.isTeamActive(1, true)) + assert.True(t, f.isTeamActive(2, true)) + assert.True(t, f.isTeamActive(3, true)) f.activatePercentage(75) - assert.False(t, f.isTeamActive(1)) - assert.True(t, f.isTeamActive(2)) - assert.True(t, f.isTeamActive(3)) + assert.False(t, f.isTeamActive(1, true)) + assert.True(t, f.isTeamActive(2, true)) + assert.True(t, f.isTeamActive(3, true)) f.activatePercentage(100) - assert.True(t, f.isTeamActive(1)) - assert.True(t, f.isTeamActive(2)) - assert.True(t, f.isTeamActive(3)) + assert.True(t, f.isTeamActive(1, true)) + assert.True(t, f.isTeamActive(2, true)) + assert.True(t, f.isTeamActive(3, true)) } func TestRolloutactivateTeamMix(t *testing.T) { @@ -147,14 +147,25 @@ func TestRolloutactivateTeamMix(t *testing.T) { // 25% < Team 2 < 50% // 0 % < Team 3 < 25% - assert.False(t, f.isTeamActive(1)) - assert.False(t, f.isTeamActive(2)) - assert.False(t, f.isTeamActive(3)) + assert.False(t, f.isTeamActive(1, true)) + assert.False(t, f.isTeamActive(2, true)) + assert.False(t, f.isTeamActive(3, true)) f.activateTeam(1) f.activatePercentage(25) - assert.True(t, f.isTeamActive(1)) - assert.False(t, f.isTeamActive(2)) - assert.True(t, f.isTeamActive(3)) + assert.True(t, f.isTeamActive(1, true)) + assert.False(t, f.isTeamActive(2, true)) + assert.True(t, f.isTeamActive(3, true)) +} + +func TestRandomizePercentage(t *testing.T) { + f := NewFeature("example") + f.activatePercentage(50) + + // teamID == 10 will be active at 50% when randomized, but not when static + teamID := int64(10) + + assert.True(t, f.isTeamActive(teamID, true)) + assert.False(t, f.isTeamActive(teamID, false)) } diff --git a/manager.go b/manager.go index 9db19e2..5667555 100644 --- a/manager.go +++ b/manager.go @@ -9,17 +9,19 @@ import ( // Manager persists and fetches feature toggles to/from redis type Manager struct { - client redis.Cmdable - keyPrefix string + client redis.Cmdable + keyPrefix string + randomizePercentage bool } // NewManager constructs a new Manager instance -func NewManager(client redis.Cmdable, keyPrefix string) *Manager { +func NewManager(client redis.Cmdable, keyPrefix string, randomizePercentage bool) *Manager { // nothing is retrieved from redis at this point // everything is fetched on demand return &Manager{ - client: client, - keyPrefix: keyPrefix, + client: client, + keyPrefix: keyPrefix, + randomizePercentage: randomizePercentage, } } @@ -242,7 +244,7 @@ func (m *Manager) IsTeamActive(teamID int64, feature *Feature) (bool, error) { return false, err } - return feature.isTeamActive(teamID), nil + return feature.isTeamActive(teamID, m.randomizePercentage), nil } // IsTeamActiveMulti returns whether the given features are globally active @@ -279,7 +281,7 @@ func (m *Manager) IsTeamActiveMulti(teamID int64, features ...*Feature) ([]bool, if err := msgpack.Unmarshal([]byte(t), features[i]); err != nil { return nil, err } - results[i] = features[i].isTeamActive(teamID) + results[i] = features[i].isTeamActive(teamID, m.randomizePercentage) default: return nil, fmt.Errorf("unexpected type (%T) for msgpack value: %v", v, v) diff --git a/manager_test.go b/manager_test.go index 8941bb6..aa80cf3 100644 --- a/manager_test.go +++ b/manager_test.go @@ -100,13 +100,13 @@ func (c *MockClient) Set(key string, value interface{}, expiration time.Duration } func TestNewManager(t *testing.T) { - manager := NewManager(&MockClient{}, mockKeyPrefix) + manager := NewManager(&MockClient{}, mockKeyPrefix, false) assert.NotNil(t, manager) } func TestGet(t *testing.T) { client := &MockClient{} - manager := NewManager(client, mockKeyPrefix) + manager := NewManager(client, mockKeyPrefix, false) client.feature = Feature{ name: "example", @@ -124,7 +124,7 @@ func TestGet(t *testing.T) { func TestActivate(t *testing.T) { client := &MockClient{} - manager := NewManager(client, mockKeyPrefix) + manager := NewManager(client, mockKeyPrefix, false) f := NewFeature("example") @@ -145,7 +145,7 @@ func TestActivate(t *testing.T) { func TestDeactivate(t *testing.T) { client := &MockClient{} - manager := NewManager(client, mockKeyPrefix) + manager := NewManager(client, mockKeyPrefix, false) f := NewFeature("example") f.activate() @@ -171,7 +171,7 @@ func TestActivatePercentage(t *testing.T) { // 0 % < Team 3 < 25% client := &MockClient{} - manager := NewManager(client, mockKeyPrefix) + manager := NewManager(client, mockKeyPrefix, true) f := NewFeature("example") @@ -179,9 +179,9 @@ func TestActivatePercentage(t *testing.T) { err := manager.ActivatePercentage(f, 25) assert.NoError(t, err) - assert.False(t, f.isTeamActive(1)) - assert.False(t, f.isTeamActive(2)) - assert.True(t, f.isTeamActive(3)) + assert.False(t, f.isTeamActive(1, manager.randomizePercentage)) + assert.False(t, f.isTeamActive(2, manager.randomizePercentage)) + assert.True(t, f.isTeamActive(3, manager.randomizePercentage)) assert.True(t, client.setWasCalled) assert.Equal(t, mockKeyPrefix+":example", client.setKey) @@ -190,9 +190,9 @@ func TestActivatePercentage(t *testing.T) { err = manager.ActivatePercentage(f, 50) assert.NoError(t, err) - assert.False(t, f.isTeamActive(1)) - assert.True(t, f.isTeamActive(2)) - assert.True(t, f.isTeamActive(3)) + assert.False(t, f.isTeamActive(1, manager.randomizePercentage)) + assert.True(t, f.isTeamActive(2, manager.randomizePercentage)) + assert.True(t, f.isTeamActive(3, manager.randomizePercentage)) assert.True(t, client.setWasCalled) assert.Equal(t, mockKeyPrefix+":example", client.setKey) @@ -205,7 +205,7 @@ func TestActivatePercentage(t *testing.T) { func TestIsActive(t *testing.T) { client := &MockClient{} - manager := NewManager(client, mockKeyPrefix) + manager := NewManager(client, mockKeyPrefix, false) f := NewFeature("example") @@ -241,7 +241,7 @@ func TestIsActive(t *testing.T) { func TestIsActiveMulti(t *testing.T) { client := &MockClient{} - manager := NewManager(client, mockKeyPrefix) + manager := NewManager(client, mockKeyPrefix, false) features := []*Feature{ NewFeature("example1"), @@ -293,7 +293,7 @@ func TestIsActiveMulti(t *testing.T) { func TestActivateTeam(t *testing.T) { client := &MockClient{} - manager := NewManager(client, mockKeyPrefix) + manager := NewManager(client, mockKeyPrefix, false) f := NewFeature("example") @@ -301,7 +301,7 @@ func TestActivateTeam(t *testing.T) { err := manager.ActivateTeam(1, f) assert.NoError(t, err) - assert.True(t, f.isTeamActive(1)) + assert.True(t, f.isTeamActive(1, manager.randomizePercentage)) assert.True(t, client.setWasCalled) assert.Equal(t, mockKeyPrefix+":example", client.setKey) @@ -314,7 +314,7 @@ func TestActivateTeam(t *testing.T) { func TestDeactivateTeam(t *testing.T) { client := &MockClient{} - manager := NewManager(client, mockKeyPrefix) + manager := NewManager(client, mockKeyPrefix, false) f := NewFeature("example") f.activateTeam(1) @@ -323,7 +323,7 @@ func TestDeactivateTeam(t *testing.T) { err := manager.DeactivateTeam(1, f) assert.NoError(t, err) - assert.False(t, f.isTeamActive(1)) + assert.False(t, f.isTeamActive(1, manager.randomizePercentage)) assert.True(t, client.setWasCalled) assert.Equal(t, mockKeyPrefix+":example", client.setKey) @@ -336,7 +336,7 @@ func TestDeactivateTeam(t *testing.T) { func TestIsTeamActive(t *testing.T) { client := &MockClient{} - manager := NewManager(client, mockKeyPrefix) + manager := NewManager(client, mockKeyPrefix, false) f := NewFeature("example") @@ -389,7 +389,7 @@ func TestIsTeamActive(t *testing.T) { func TestIsTeamActiveMulti(t *testing.T) { client := &MockClient{} - manager := NewManager(client, mockKeyPrefix) + manager := NewManager(client, mockKeyPrefix, false) features := []*Feature{ NewFeature("example1"), @@ -442,7 +442,7 @@ func TestIsTeamActiveMulti(t *testing.T) { func TestFeatureDifferentThanServer(t *testing.T) { // mock a scenario where the state of the database is different than the feature variable client := &MockClient{} - manager := NewManager(client, mockKeyPrefix) + manager := NewManager(client, mockKeyPrefix, false) client.feature = Feature{ name: "example",