Skip to content

Commit f594777

Browse files
author
Jesse Proudman
authored
v1.5.0: Permit required ownership of flags (#62)
Add support for tagging of ownership of a specific feature flag. This works either through the presence of a default ownership file (`testtrack/owners.yml`) or a file specified via an ENV variable (`TESTTRACK_OWNERSHIP_FILE`). If the file exists, `--owner` is required to be set and to match one of the owners in the file. If the file does not exist, `--owner` is required to be blank.
1 parent d24aecc commit f594777

File tree

9 files changed

+189
-16
lines changed

9 files changed

+189
-16
lines changed

Makefile

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
SHELL = /bin/sh
22

3-
VERSION=1.4.0
3+
VERSION=1.5.0
44
BUILD=`git rev-parse HEAD`
55

66
LDFLAGS=-ldflags "-w -s \
@@ -38,7 +38,7 @@ test:
3838
then\
3939
echo "Style violations found. Run the following command to fix:";\
4040
echo;\
41-
echo "goimports -w" $$GOIMPORTS_RESULT;\
41+
echo "$$(go env GOPATH)/bin/goimports -w" $$GOIMPORTS_RESULT;\
4242
echo;\
4343
exit 1;\
4444
fi

README.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,18 @@ Run `testtrack help` for more documentation on how to configure splits and other
8888

8989
Happy TestTracking!
9090

91+
## Additional Configuration
92+
93+
The following configuration options are available:
94+
95+
### Split ownership
96+
If you have a large organization, you may wish to tag ownership of splits to a specific team to help provide accountability for clean up. This is supported natively in test_track.
97+
98+
1. You must specify an ownership file. The default file exists at `testtrack/owners.yml` though that can be overwritten with the TESTTRACK_OWNERSHIP_FILE environment variable.
99+
2. This file should contain a list of team names, one per line. Any sub-values of the key names will be ignored for the purposes of test track.
100+
3. If the test track client is able to find this file, it will require an `--owner` flag be set when creating new splits and experiements.
101+
4. This data will be passed to the test track server where it can be recorded on the split records
102+
91103
## How to Contribute
92104

93105
We would love for you to contribute! Anything that benefits the majority of TestTrack users—from a documentation fix to an entirely new feature—is encouraged.

cmds/create_experiment.go

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,9 +29,10 @@ destroyed split if it was destroyed by mistake, but the migration will fail if
2929
you attempt to create a new split without a prefix.
3030
`
3131

32-
var createExperimentWeights string
32+
var createExperimentWeights, createExperimentOwner string
3333

3434
func init() {
35+
createExperimentCmd.Flags().StringVar(&createExperimentOwner, "owner", "", "Who owns this feature flag?")
3536
createExperimentCmd.Flags().StringVar(&createExperimentWeights, "weights", "control: 50, treatment: 50", "Variant weights to use")
3637
createExperimentCmd.Flags().BoolVar(&noPrefix, "no-prefix", false, "Don't prefix experiment with app_name (supports existing legacy splits)")
3738
createCmd.AddCommand(createExperimentCmd)
@@ -43,11 +44,11 @@ var createExperimentCmd = &cobra.Command{
4344
Long: createExperimentDoc,
4445
Args: cobra.ExactArgs(1),
4546
RunE: func(cmd *cobra.Command, args []string) error {
46-
return createExperiment(args[0], createExperimentWeights)
47+
return createExperiment(args[0], createExperimentWeights, createExperimentOwner)
4748
},
4849
}
4950

50-
func createExperiment(name, weights string) error {
51+
func createExperiment(name, weights string, owner string) error {
5152
schema, err := schema.Read()
5253
if err != nil {
5354
return err
@@ -63,6 +64,11 @@ func createExperiment(name, weights string) error {
6364
return err
6465
}
6566

67+
err = validations.ValidateOwnerName(owner)
68+
if err != nil {
69+
return err
70+
}
71+
6672
err = validations.AutoPrefixAndValidateSplit("name", &name, appName, schema, noPrefix, force)
6773
if err != nil {
6874
// if this errors, we know this is a create (not an update), so maybe prefix
@@ -76,7 +82,7 @@ func createExperiment(name, weights string) error {
7682
return err
7783
}
7884

79-
split, err := splits.New(&name, weightsMap)
85+
split, err := splits.New(&name, weightsMap, &owner)
8086
if err != nil {
8187
return err
8288
}

cmds/create_feature_gate.go

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -33,9 +33,10 @@ destroyed split if it was destroyed by mistake, but the migration will fail if
3333
you attempt to create a new split without a prefix.
3434
`
3535

36-
var createFeatureGateDefault, createFeatureGateWeights string
36+
var createFeatureGateDefault, createFeatureGateWeights, createFeatureGateOwner string
3737

3838
func init() {
39+
createFeatureGateCmd.Flags().StringVar(&createFeatureGateOwner, "owner", "", "Who owns this feature flag?")
3940
createFeatureGateCmd.Flags().StringVar(&createFeatureGateDefault, "default", "false", "Default variant for your feature flag")
4041
createFeatureGateCmd.Flags().StringVar(&createFeatureGateWeights, "weights", "", "Variant weights to use (overrides default)")
4142
createFeatureGateCmd.Flags().BoolVar(&noPrefix, "no-prefix", false, "Don't prefix feature gate with app_name (supports existing legacy splits)")
@@ -48,11 +49,11 @@ var createFeatureGateCmd = &cobra.Command{
4849
Long: createFeatureGateDoc,
4950
Args: cobra.ExactArgs(1),
5051
RunE: func(cmd *cobra.Command, args []string) error {
51-
return createFeatureGate(args[0], createFeatureGateDefault, createFeatureGateWeights)
52+
return createFeatureGate(args[0], createFeatureGateDefault, createFeatureGateWeights, createFeatureGateOwner)
5253
},
5354
}
5455

55-
func createFeatureGate(name, defaultVariant, weights string) error {
56+
func createFeatureGate(name, defaultVariant, weights string, owner string) error {
5657
schema, err := schema.Read()
5758
if err != nil {
5859
return err
@@ -76,6 +77,11 @@ func createFeatureGate(name, defaultVariant, weights string) error {
7677
}
7778
}
7879

80+
err = validations.ValidateOwnerName(owner)
81+
if err != nil {
82+
return err
83+
}
84+
7985
if len(weights) == 0 {
8086
switch defaultVariant {
8187
case "true":
@@ -104,7 +110,7 @@ func createFeatureGate(name, defaultVariant, weights string) error {
104110
return fmt.Errorf("weights %v are missing false variant", *weightsMap)
105111
}
106112

107-
split, err := splits.New(&name, weightsMap)
113+
split, err := splits.New(&name, weightsMap, &createFeatureGateOwner)
108114
if err != nil {
109115
return err
110116
}

cmds/root.go

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,10 @@ var build string
1616
var arch string = fmt.Sprintf("%s-%s", runtime.GOOS, runtime.GOARCH)
1717
var noPrefix bool
1818
var force bool
19+
var ownershipFilename string
1920

2021
func init() {
21-
_, urlSet := os.LookupEnv("TESTTRACK_CLI_URL")
22-
_, appNameSet := os.LookupEnv("TESTTRACK_APP_NAME")
23-
if !urlSet && !appNameSet {
24-
godotenv.Load()
25-
}
22+
godotenv.Load()
2623
}
2724

2825
var rootCmd = &cobra.Command{

serializers/serializers.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ type RemoteKill struct {
4242
type SplitYAML struct {
4343
Name string `yaml:"name"`
4444
Weights yaml.MapSlice `yaml:"weights"`
45+
Owner string `yaml:"owner,omitempty"`
4546
}
4647

4748
// SplitJSON is is the JSON-marshalabe representation of a Split
@@ -72,6 +73,7 @@ type SchemaSplit struct {
7273
Name string `yaml:"name"`
7374
Weights yaml.MapSlice `yaml:"weights"`
7475
Decided bool `yaml:"decided,omitempty"`
76+
Owner string `yaml:"owner,omitempty"`
7577
}
7678

7779
// Schema is the YAML-marshalable representation of the TestTrack schema for

splits/splits.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,10 +25,11 @@ type Split struct {
2525
migrationVersion *string
2626
name *string
2727
weights *Weights
28+
owner *string
2829
}
2930

3031
// New returns a migration object
31-
func New(name *string, weights *Weights) (migrations.IMigration, error) {
32+
func New(name *string, weights *Weights, owner *string) (migrations.IMigration, error) {
3233
migrationVersion, err := migrations.GenerateMigrationVersion()
3334
if err != nil {
3435
return nil, err
@@ -38,6 +39,7 @@ func New(name *string, weights *Weights) (migrations.IMigration, error) {
3839
migrationVersion: migrationVersion,
3940
name: name,
4041
weights: weights,
42+
owner: owner,
4143
}, nil
4244
}
4345

@@ -110,6 +112,7 @@ func (s *Split) File() *serializers.MigrationFile {
110112
Split: &serializers.SplitYAML{
111113
Name: *s.name,
112114
Weights: s.weights.ToYAML(),
115+
Owner: *s.owner,
113116
},
114117
}
115118
}
@@ -176,6 +179,7 @@ func (s *Split) ApplyToSchema(schema *serializers.Schema, migrationRepo migratio
176179
Name: *s.name,
177180
Weights: s.weights.ToYAML(),
178181
Decided: false,
182+
Owner: *s.owner,
179183
}
180184
schema.Splits = append(schema.Splits, schemaSplit)
181185
return nil

validations/validations.go

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,21 @@ package validations
22

33
import (
44
"fmt"
5+
"io/ioutil"
6+
"os"
57
"regexp"
68
"strings"
79

810
"github.com/Betterment/testtrack-cli/serializers"
11+
"gopkg.in/yaml.v2"
912
)
1013

1114
const appVersionMaxLength = 18 // This conforms to iOS version numering rules
1215
const splitMaxLength = 128 // This is arbitrary but way bigger than you need and smaller than the column will fit
1316

17+
// DefaultOwnershipFilePath defines the default path to a YML file listing the possible split owners
18+
const DefaultOwnershipFilePath = "testtrack/owners.yml"
19+
1420
var prefixedSplitRegex = regexp.MustCompile(`^([a-z_\-\d]+)\.[a-z_\d]+$`)
1521
var nonPrefixedSplitRegex = regexp.MustCompile(`^[a-z_\d]+$`)
1622
var ambiPrefixedSplitRegex = regexp.MustCompile(`^(?:[a-z_\-\d]+\.)?[a-z_\d]+$`)
@@ -65,6 +71,56 @@ func AutoPrefixAndValidateSplit(paramName string, value *string, currentAppName
6571
return SplitExistsInSchema(paramName, value, schema)
6672
}
6773

74+
// ValidateOwnerName ensures that if a testtrack/owners.yml file is present, the owner matches
75+
// the list of owners in that file.
76+
func ValidateOwnerName(owner string) error {
77+
ownershipFilePath, ok := os.LookupEnv("TESTTRACK_OWNERSHIP_FILE")
78+
if !ok {
79+
ownershipFilePath = DefaultOwnershipFilePath
80+
}
81+
82+
// If no ownership file exists, force owner to be empty. Otherwise pass validations.
83+
_, err := os.Stat(ownershipFilePath)
84+
if os.IsNotExist(err) {
85+
if owner != "" {
86+
return fmt.Errorf("owner must be blank because ownership file (%s) could not be found", ownershipFilePath)
87+
}
88+
89+
return nil
90+
}
91+
92+
// When the ownership file exists, owner must be specified and must be in the ownership file.
93+
if owner == "" {
94+
return fmt.Errorf("owner must be specified when ownership file (%s) exists", ownershipFilePath)
95+
}
96+
97+
fileBytes, err := ioutil.ReadFile(ownershipFilePath)
98+
if err != nil {
99+
return err
100+
}
101+
102+
ownersMap := make(map[string]*struct{})
103+
err = yaml.Unmarshal(fileBytes, ownersMap)
104+
if err != nil {
105+
return err
106+
}
107+
108+
if !mapContainsValue(owner, ownersMap) {
109+
return fmt.Errorf("owner '%s' is not defined in ownership file (%s)", owner, ownershipFilePath)
110+
}
111+
112+
return nil
113+
}
114+
115+
func mapContainsValue(value string, m map[string]*struct{}) bool {
116+
for key := range m {
117+
if key == value {
118+
return true
119+
}
120+
}
121+
return false
122+
}
123+
68124
// NonPrefixedSplit validates that a split name param is valid with no app prefix
69125
func NonPrefixedSplit(paramName string, value *string) error {
70126
err := Presence(paramName, value)

validations/validations_test.go

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
package validations_test
22

33
import (
4+
"os"
5+
"path/filepath"
46
"testing"
57

68
"github.com/Betterment/testtrack-cli/serializers"
@@ -130,6 +132,94 @@ func TestAutoPrefixAndValidateSplit(t *testing.T) {
130132
})
131133
}
132134

135+
func TestValidateOwnerName(t *testing.T) {
136+
t.Run("it succeeds with no owner if ownershipFilename is undefined and the default file does not exist", func(t *testing.T) {
137+
err := validations.ValidateOwnerName("")
138+
require.NoError(t, err)
139+
})
140+
141+
t.Run("it fails with an owner if ownershipFilename is undefined and the default file does not exist ", func(t *testing.T) {
142+
err := validations.ValidateOwnerName("super_owner")
143+
require.Error(t, err)
144+
require.Contains(t, err.Error(), "owner must be blank because ownership file (testtrack/owners.yml) could not be found")
145+
})
146+
147+
t.Run("it fails if using default ownership file and owner is blank", func(t *testing.T) {
148+
WriteOwnershipFile(validations.DefaultOwnershipFilePath)
149+
150+
err := validations.ValidateOwnerName("")
151+
require.Error(t, err)
152+
require.Contains(t, err.Error(), "owner must be specified when ownership file (testtrack/owners.yml) exists")
153+
154+
RemoveOwnershipFile(validations.DefaultOwnershipFilePath)
155+
})
156+
157+
t.Run("it fails if using specified ownership file and owner is blank", func(t *testing.T) {
158+
WriteOwnershipFile(".owners.yml")
159+
t.Setenv("TESTTRACK_OWNERSHIP_FILE", ".owners.yml")
160+
161+
err := validations.ValidateOwnerName("")
162+
require.Error(t, err)
163+
require.Contains(t, err.Error(), "owner must be specified when ownership file (.owners.yml) exists")
164+
165+
RemoveOwnershipFile(".owners.yml")
166+
})
167+
168+
t.Run("it succeeds if using default ownership file and owner exists", func(t *testing.T) {
169+
WriteOwnershipFile(validations.DefaultOwnershipFilePath)
170+
171+
err := validations.ValidateOwnerName("super_owner")
172+
require.NoError(t, err)
173+
174+
RemoveOwnershipFile(validations.DefaultOwnershipFilePath)
175+
})
176+
177+
t.Run("it succeeds if using specified ownership file and owner exists", func(t *testing.T) {
178+
WriteOwnershipFile(".owners.yml")
179+
t.Setenv("TESTTRACK_OWNERSHIP_FILE", ".owners.yml")
180+
181+
err := validations.ValidateOwnerName("super_owner")
182+
require.NoError(t, err)
183+
184+
RemoveOwnershipFile(".owners.yml")
185+
})
186+
187+
t.Run("it fails if using default ownership file and owner does not exist", func(t *testing.T) {
188+
WriteOwnershipFile(validations.DefaultOwnershipFilePath)
189+
190+
err := validations.ValidateOwnerName("superb_owner")
191+
require.Error(t, err)
192+
require.Contains(t, err.Error(), "owner 'superb_owner' is not defined in ownership file (testtrack/owners.yml)")
193+
194+
RemoveOwnershipFile(validations.DefaultOwnershipFilePath)
195+
})
196+
197+
t.Run("it fails if using specified ownership file and owner does not exist", func(t *testing.T) {
198+
WriteOwnershipFile(".owners.yml")
199+
t.Setenv("TESTTRACK_OWNERSHIP_FILE", ".owners.yml")
200+
201+
err := validations.ValidateOwnerName("superb_owner")
202+
require.Error(t, err)
203+
require.Contains(t, err.Error(), "owner 'superb_owner' is not defined in ownership file (.owners.yml)")
204+
205+
RemoveOwnershipFile(".owners.yml")
206+
})
207+
}
208+
133209
func StrPtr(value string) *string {
134210
return &value
135211
}
212+
213+
func WriteOwnershipFile(ownershipFilename string) {
214+
if _, err := os.Stat(ownershipFilename); os.IsNotExist(err) {
215+
os.MkdirAll(filepath.Dir(ownershipFilename), 0700)
216+
}
217+
218+
ownerContent := []byte("super_owner:\n delayed_job_alert_slack_channel: '#super_owner'\n")
219+
os.WriteFile(ownershipFilename, ownerContent, 0644)
220+
}
221+
222+
func RemoveOwnershipFile(ownershipFilename string) {
223+
os.Remove(ownershipFilename)
224+
os.RemoveAll(filepath.Dir(ownershipFilename))
225+
}

0 commit comments

Comments
 (0)