Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 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 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 and an error if neither exists
func findSchemaPath() (string, error) {
if _, err := os.Stat("testtrack/schema.json"); err == nil {
return "testtrack/schema.json", nil
}
if _, err := os.Stat("testtrack/schema.yml"); err == nil {
return "testtrack/schema.yml", nil
}
return "testtrack/schema.json", errors.New("testtrack/schema.{json,yml} does not exist. Are you in your app root dir? If so, call testtrack init_project first")
}

// Read a schema from disk or generate one
func Read() (*serializers.Schema, error) {
if _, err := os.Stat("testtrack/schema.yml"); os.IsNotExist(err) {
schemaPath, err := findSchemaPath()
if err != nil {
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, err := findSchemaPath()
if err != nil {
return err
}
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
Copy Markdown
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
13 changes: 7 additions & 6 deletions splits/splits.go
Original file line number Diff line number Diff line change
Expand Up @@ -83,13 +83,14 @@ func IsFeatureGateFromName(name string) bool {

// FromFile reifies a migration from the yaml serializable representation
func FromFile(migrationVersion *string, serializable *serializers.SplitYAML) (migrations.IMigration, error) {
weights, err := WeightsFromYAML(serializable.Weights)
weights, err := NewWeights(serializable.Weights)
if err != nil {
return nil, err
}
return &Split{
migrationVersion: migrationVersion,
name: &serializable.Name,
owner: &serializable.Owner,
Copy link
Copy Markdown
Contributor Author

@rzane rzane Jan 21, 2026

Choose a reason for hiding this comment

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

Manual testing revealed that the testtrack schema generate command is broken on main. This is the fix.

panic: runtime error: invalid memory address or nil pointer dereference
[signal SIGSEGV: segmentation violation code=0x2 addr=0x0 pc=0x100356c24]

weights: weights,
}, nil
}
Expand All @@ -111,7 +112,7 @@ func (s *Split) File() *serializers.MigrationFile {
SerializerVersion: serializers.SerializerVersion,
Split: &serializers.SplitYAML{
Name: *s.name,
Weights: s.weights.ToYAML(),
Weights: *s.weights,
Owner: *s.owner,
},
}
Expand Down Expand Up @@ -152,13 +153,13 @@ func (s *Split) SameResourceAs(other migrations.IMigration) bool {
func (s *Split) ApplyToSchema(schema *serializers.Schema, migrationRepo migrations.Repository, _idempotently bool) error {
for i, candidate := range schema.Splits { // Replace
if candidate.Name == *s.name {
schemaWeights, err := WeightsFromYAML(candidate.Weights)
schemaWeights, err := NewWeights(candidate.Weights)
if err != nil {
return err
}
schemaWeights.Merge(*s.weights)
schema.Splits[i].Decided = false
schema.Splits[i].Weights = schemaWeights.ToYAML()
schema.Splits[i].Weights = *schemaWeights
return nil
}
}
Expand All @@ -169,15 +170,15 @@ func (s *Split) ApplyToSchema(schema *serializers.Schema, migrationRepo migratio
weights.Merge(*s.weights)
schema.Splits = append(schema.Splits, serializers.SchemaSplit{
Name: *s.name,
Weights: weights.ToYAML(),
Weights: *weights,
Decided: false,
})
return nil
}
}
schemaSplit := serializers.SchemaSplit{ // Create
Name: *s.name,
Weights: s.weights.ToYAML(),
Weights: *s.weights,
Decided: false,
Owner: *s.owner,
}
Expand Down
Loading