Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
SHELL = /bin/sh

VERSION=1.7.1
VERSION=1.8.0
BUILD=`git rev-parse HEAD`

LDFLAGS=-ldflags "-w -s \
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ If you have a large organization, you may wish to tag ownership of splits to a s

### Syncing split assignments

If you want to ensure that your local split assignments are in sync with your remote (production) assignments, you can run `TESTTRACK_CLI_URL=<base_url> testtrack sync` (e.g. `TESTTRACK_CLI_URL=https://tt.example.com testtrack sync`) from your project directory to pull the assignments from your remote server into your local `schema.yml` file.
If you want to ensure that your local split assignments are in sync with your remote (production) assignments, you can run `TESTTRACK_CLI_URL=<base_url> testtrack sync` (e.g. `TESTTRACK_CLI_URL=https://tt.example.com testtrack sync`) from your project directory to pull the assignments from your remote server into your local `schema.{json,yml}` file.

## How to Contribute

Expand Down
6 changes: 3 additions & 3 deletions cmds/schema_generate.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,10 @@ import (

var schemaGenerateDoc = `
Reads the migrations in testtrack/migrate and writes the resulting schema state
to testtrack/schema.yml, overwriting the file if it already exists. Generate
to testtrack/schema.{json,yml}, overwriting the file if it already exists. Generate
makes no TestTrack API calls.

In addition to refreshing a schema.yml file that may have been corrupted due to
In addition to refreshing a schema file that may have been corrupted due to
a bad merge or bug that produced incorrect schema state, 'schema generate' will
also validate that migrations merged from multiple development branches don't
logically conflict, or else it will fail with errors.
Expand All @@ -30,7 +30,7 @@ func init() {

var schemaGenerateCmd = &cobra.Command{
Use: "generate",
Short: "Generate schema.yml from migration files",
Short: "Generate schema.{json,yml} from migration files",
Long: schemaGenerateDoc,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
Expand Down
4 changes: 2 additions & 2 deletions cmds/schema_load.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import (
)

var schemaLoadDoc = `
Loads the testtrack/schema.yml state into TestTrack server. This operation is
Loads the testtrack/schema.{json,yml} state into TestTrack server. This operation is
idempotent with a valid, consistent schema file, though might fail if your
schema file became invalid due to a bad merge or a bug.

Expand All @@ -27,7 +27,7 @@ func init() {

var schemaLoadCmd = &cobra.Command{
Use: "load",
Short: "Load schema.yml state into TestTrack server",
Short: "Load schema.{json,yml} state into TestTrack server",
Long: schemaLoadDoc,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
Expand Down
2 changes: 1 addition & 1 deletion cmds/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import (
)

var serverDoc = `
Run a fake TestTrack server for local development, backed by schema.yml files
Run a fake TestTrack server for local development, backed by schema.{json,yml} files
and nonsense.
`

Expand Down
2 changes: 1 addition & 1 deletion cmds/sync.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ func Sync() error {
remoteSplit, exists := splitRegistry.Splits[localSplit.Name]
if exists {
remoteWeights := splits.Weights(remoteSplit.Weights)
localSchema.Splits[ind].Weights = remoteWeights.ToYAML()
localSchema.Splits[ind].Weights = remoteWeights
}
}

Expand Down
6 changes: 3 additions & 3 deletions fakeserver/routes.go
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,7 @@ func getV1SplitRegistry() (interface{}, error) {
}
splitRegistry := map[string]*splits.Weights{}
for _, split := range schema.Splits {
splitRegistry[split.Name], err = splits.WeightsFromYAML(split.Weights)
splitRegistry[split.Name], err = splits.NewWeights(split.Weights)
if err != nil {
return nil, err
}
Expand All @@ -208,7 +208,7 @@ func getV2PlusSplitRegistry() (interface{}, error) {
splitRegistry := map[string]*v2Split{}
for _, split := range schema.Splits {
isFeatureGate := splits.IsFeatureGateFromName(split.Name)
weights, err := splits.WeightsFromYAML(split.Weights)
weights, err := splits.NewWeights(split.Weights)
if err != nil {
return nil, err
}
Expand All @@ -231,7 +231,7 @@ func getV4SplitRegistry() (interface{}, error) {
v4Splits := make([]v4Split, 0, len(schema.Splits))
for _, split := range schema.Splits {
isFeatureGate := splits.IsFeatureGateFromName(split.Name)
weights, err := splits.WeightsFromYAML(split.Weights)
weights, err := splits.NewWeights(split.Weights)
if err != nil {
return nil, err
}
Expand Down
34 changes: 33 additions & 1 deletion fakeserver/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,17 @@ splits:
treatment: 40
`

var otherTestSchema = `{
"serializer_version": 1,
"schema_version": "2020011774023",
"splits": [
{
"name": "test.json_experiment",
"weights": { "control": 50, "treatment": 50 }
}
]
}`

var testAssignments = `
something_something_enabled: "true"
`
Expand All @@ -52,7 +63,12 @@ func TestMain(m *testing.M) {
}

schemaContent := []byte(testSchema)
if err := os.WriteFile(filepath.Join(schemasDir, "test.yml"), schemaContent, 0644); err != nil {
if err := os.WriteFile(filepath.Join(schemasDir, "a.yml"), schemaContent, 0644); err != nil {
log.Fatal(err)
}

otherSchemaContent := []byte(otherTestSchema)
if err := os.WriteFile(filepath.Join(schemasDir, "b.json"), otherSchemaContent, 0644); err != nil {
log.Fatal(err)
}

Expand Down Expand Up @@ -140,6 +156,22 @@ func TestSplitRegistry(t *testing.T) {
require.Equal(t, 40, treatment.Weight)
require.Equal(t, false, split.FeatureGate)
})

t.Run("it loads JSON schemas from home directory", func(t *testing.T) {
w := httptest.NewRecorder()
h := createHandler()

h.ServeHTTP(w, httptest.NewRequest("GET", "/api/v2/split_registry", nil))

require.Equal(t, http.StatusOK, w.Code)

registry := v2SplitRegistry{}
err := json.Unmarshal(w.Body.Bytes(), &registry)
require.Nil(t, err)

require.Equal(t, 50, registry.Splits["test.json_experiment"].Weights["control"])
require.Equal(t, 50, registry.Splits["test.json_experiment"].Weights["treatment"])
})
}

func TestVisitorConfig(t *testing.T) {
Expand Down
64 changes: 40 additions & 24 deletions schema/schema.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package schema

import (
"encoding/json"
"errors"
"fmt"
"os"
Expand All @@ -15,12 +16,24 @@ import (
"gopkg.in/yaml.v2"
)

// Finds the path to the schema file (preferring JSON), or returns testtrack/schema.json
func findSchemaPath() (string, bool) {
if _, err := os.Stat("testtrack/schema.json"); err == nil {
return "testtrack/schema.json", true
}
if _, err := os.Stat("testtrack/schema.yml"); err == nil {
return "testtrack/schema.yml", true
}
return "testtrack/schema.json", false
}

// Read a schema from disk or generate one
func Read() (*serializers.Schema, error) {
if _, err := os.Stat("testtrack/schema.yml"); os.IsNotExist(err) {
schemaPath, exists := findSchemaPath()
if !exists {
return Generate()
}
schemaBytes, err := os.ReadFile("testtrack/schema.yml")
schemaBytes, err := os.ReadFile(schemaPath)
if err != nil {
return nil, err
}
Expand Down Expand Up @@ -53,12 +66,21 @@ func Generate() (*serializers.Schema, error) {
// Write a schema to disk after alpha-sorting its resources
func Write(schema *serializers.Schema) error {
SortAlphabetically(schema)
out, err := yaml.Marshal(schema)

schemaPath, _ := findSchemaPath()

var out []byte
var err error
if filepath.Ext(schemaPath) == ".yml" {
out, err = yaml.Marshal(schema)
} else {
out, err = json.MarshalIndent(schema, "", " ")
}
if err != nil {
return err
}

err = os.WriteFile("testtrack/schema.yml", out, 0644)
err = os.WriteFile(schemaPath, out, 0644)
if err != nil {
return err
}
Expand All @@ -68,8 +90,9 @@ func Write(schema *serializers.Schema) error {

// Link a schema to the user's home dir
func Link(force bool) error {
if _, err := os.Stat("testtrack/schema.yml"); os.IsNotExist(err) {
return errors.New("testtrack/schema.yml does not exist. Are you in your app root dir? If so, call testtrack init_project first")
schemaPath, exists := findSchemaPath()
if !exists {
return errors.New("testtrack/schema.{json,yml} does not exist. Are you in your app root dir? If so, call testtrack init_project first")
}
dir, err := os.Getwd()
if err != nil {
Expand All @@ -84,11 +107,12 @@ func Link(force bool) error {
if err != nil {
return err
}
path := fmt.Sprintf("%s/schemas/%s.yml", *configDir, dirname)
ext := filepath.Ext(schemaPath)
path := fmt.Sprintf("%s/schemas/%s%s", *configDir, dirname, ext)
if force {
os.Remove(path) // If this fails it might just not exist, we'll error on the next line if something else is up
}
return os.Symlink(dir+"/testtrack/schema.yml", path)
return os.Symlink(dir+"/"+schemaPath, path)
}

// ReadMerged merges schemas linked at ~/testtrack/schemas into a single virtual schema
Expand All @@ -97,28 +121,20 @@ func ReadMerged() (*serializers.Schema, error) {
if err != nil {
return nil, err
}
paths, err := filepath.Glob(*configDir + "/schemas/*.yml")
paths, err := filepath.Glob(*configDir + "/schemas/*.*")
if err != nil {
return nil, err
}
var mergedSchema serializers.Schema
for _, path := range paths {
// Deref symlink
fi, err := os.Lstat(path)
if err != nil {
return nil, err
}
if fi.Mode()&os.ModeSymlink != 0 {
path, err = os.Readlink(path)
if err != nil {
continue // It's OK if this symlink isn't traversable (e.g. app was uninstalled), we'll just skip it.
}
}
// Read file
schemaBytes, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
continue // It's OK if this file doesn't exist (e.g. broken symlink, app was uninstalled), we'll just skip it.
}
return nil, err
}
Comment on lines -106 to 136
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This code is trying to detect a broken symlink, and it isn't working correctly on main.

$ ln -s broken ~/.testtrack/schemas/broken.yml
$ testtrack create feature_gate foo_enabled --owner web_platform
$ testtrack create feature_completion retail.foo_enabled --app_version 0.0.0
Error: open broken: no such file or directory

The reason it wasn't working is because os.Readlink succeeds, even for broken symlinks, which causes os.ReadFile to be called, which fails.

I removed the symlink detection stuff. I don't think we really care if these are symlinks or not, and os.ReadFile is capable of reading a symlink. If the os.ReadFile suggests that the file doesn't exist, we'll skip it.


var schema serializers.Schema
err = yaml.Unmarshal(schemaBytes, &schema)
if err != nil {
Expand Down Expand Up @@ -156,18 +172,18 @@ func mergeLegacySchema(schema *serializers.Schema) error {
if !ok {
return fmt.Errorf("expected split name, got %v", mapSlice.Key)
}
weightsYAML, ok := mapSlice.Value.(yaml.MapSlice)
weightsYAML, ok := mapSlice.Value.(map[string]int)
if !ok {
return fmt.Errorf("expected weights, got %v", mapSlice.Value)
}
weights, err := splits.WeightsFromYAML(weightsYAML)
weights, err := splits.NewWeights(weightsYAML)
if err != nil {
return err
}

schema.Splits = append(schema.Splits, serializers.SchemaSplit{
Name: name,
Weights: weights.ToYAML(),
Weights: *weights,
Decided: false,
})
}
Expand Down
2 changes: 1 addition & 1 deletion schemaloaders/schemaloaders.go
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ func schemaSplitMigrations(schemaSplit serializers.SchemaSplit) ([]migrations.IM

if schemaSplit.Decided {
var decision *string
weights, err := splits.WeightsFromYAML(schemaSplit.Weights)
weights, err := splits.NewWeights(schemaSplit.Weights)
if err != nil {
return nil, fmt.Errorf("schema split %s invalid: %w", schemaSplit.Name, err)
}
Expand Down
26 changes: 13 additions & 13 deletions serializers/serializers.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,9 +40,9 @@ type RemoteKill struct {

// SplitYAML is the YAML-marshalable representation of a Split
type SplitYAML struct {
Name string `yaml:"name"`
Weights yaml.MapSlice `yaml:"weights"`
Owner string `yaml:"owner,omitempty"`
Name string `yaml:"name"`
Weights map[string]int `yaml:"weights"`
Owner string `yaml:"owner,omitempty"`
}

// SplitJSON is the JSON-marshalabe representation of a Split
Expand Down Expand Up @@ -80,21 +80,21 @@ type IdentifierType struct {

// SchemaSplit is the schema-file YAML-marshalable representation of a split's state
type SchemaSplit struct {
Name string `yaml:"name"`
Weights yaml.MapSlice `yaml:"weights"`
Decided bool `yaml:"decided,omitempty"`
Owner string `yaml:"owner,omitempty"`
Name string `yaml:"name" json:"name"`
Weights map[string]int `yaml:"weights" json:"weights"`
Decided bool `yaml:"decided,omitempty" json:"decided,omitempty"`
Owner string `yaml:"owner,omitempty" json:"owner,omitempty"`
}

// Schema is the YAML-marshalable representation of the TestTrack schema for
// migration validation and bootstrapping of new ecosystems
type Schema struct {
SerializerVersion int `yaml:"serializer_version"`
SchemaVersion string `yaml:"schema_version"`
Splits []SchemaSplit `yaml:"splits,omitempty"`
IdentifierTypes []IdentifierType `yaml:"identifier_types,omitempty"`
RemoteKills []RemoteKill `yaml:"remote_kills,omitempty"`
FeatureCompletions []FeatureCompletion `yaml:"feature_completions,omitempty"`
SerializerVersion int `yaml:"serializer_version" json:"serializer_version"`
SchemaVersion string `yaml:"schema_version" json:"schema_version"`
Splits []SchemaSplit `yaml:"splits,omitempty" json:"splits,omitempty"`
IdentifierTypes []IdentifierType `yaml:"identifier_types,omitempty" json:"identifier_types,omitempty"`
RemoteKills []RemoteKill `yaml:"remote_kills,omitempty" json:"remote_kills,omitempty"`
FeatureCompletions []FeatureCompletion `yaml:"feature_completions,omitempty" json:"feature_completions,omitempty"`
}

// LegacySchema represents the Rails migration-piggybacked testtrack schema files of old
Expand Down
6 changes: 3 additions & 3 deletions splitdecisions/splitdecisions.go
Original file line number Diff line number Diff line change
Expand Up @@ -97,15 +97,15 @@ func (s *SplitDecision) ApplyToSchema(schema *serializers.Schema, migrationRepo
for i, candidate := range schema.Splits {
if candidate.Name == *s.split {
schema.Splits[i].Decided = true
weights, err := splits.WeightsFromYAML(candidate.Weights)
weights, err := splits.NewWeights(candidate.Weights)
if err != nil {
return err
}
err = weights.ReweightToDecision(*s.variant)
if err != nil {
return fmt.Errorf("in split %s in schema: %w", *s.split, err)
}
schema.Splits[i].Weights = weights.ToYAML()
schema.Splits[i].Weights = *weights
return nil
}
}
Expand All @@ -119,7 +119,7 @@ func (s *SplitDecision) ApplyToSchema(schema *serializers.Schema, migrationRepo
}
schema.Splits = append(schema.Splits, serializers.SchemaSplit{
Name: *s.split,
Weights: weights.ToYAML(),
Weights: *weights,
Decided: true,
})
return nil
Expand Down
2 changes: 1 addition & 1 deletion splitretirements/splitretirements.go
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ func (s *SplitRetirement) SameResourceAs(other migrations.IMigration) bool {
func (s *SplitRetirement) ApplyToSchema(schema *serializers.Schema, _ migrations.Repository, _idempotently bool) error {
for i, candidate := range schema.Splits {
if candidate.Name == *s.split {
weights, err := splits.WeightsFromYAML(candidate.Weights)
weights, err := splits.NewWeights(candidate.Weights)
if err != nil {
return err
}
Expand Down
Loading