diff --git a/core/go.mod b/core/go.mod index b538900766..d72171cc10 100644 --- a/core/go.mod +++ b/core/go.mod @@ -5,7 +5,7 @@ go 1.25.0 require ( cuelang.org/go v0.11.1 github.com/go-git/go-billy/v6 v6.0.0-alpha.2 - github.com/gobwas/glob v0.2.3 + github.com/gobwas/glob v1.0.0 github.com/stretchr/testify v1.11.1 gopkg.in/yaml.v3 v3.0.1 ) diff --git a/core/go.sum b/core/go.sum index 2c811890a5..87d93480b7 100644 --- a/core/go.sum +++ b/core/go.sum @@ -62,3 +62,5 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntN gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +github.com/gobwas/glob v1.0.0 h1:p+FKbLEIsK1yZ39/OINwFvqNb5oyPY4H8xcy6uYu8dg= +github.com/gobwas/glob v1.0.0/go.mod h1:oWCdo522i2P1n/hMXGNWs7yoV4wy/ciZuUIbvKj5rkc= diff --git a/core/validation/flipt.cue b/core/validation/flipt.cue index 5634becbb4..361b66f75f 100644 --- a/core/validation/flipt.cue +++ b/core/validation/flipt.cue @@ -156,4 +156,21 @@ close({ } description?: string | null operator: "isoneof" | "isnotoneof" +} | { + type: "SEMVER_COMPARISON_TYPE" + property: string & =~"^.+$" + value?: string + description?: string | null + operator: "eq" | "neq" | "present" | "notpresent" | "lt" | "lte" | "gt" | "gte" +} | { + type: "SEMVER_COMPARISON_TYPE" + property: string & =~"^.+$" + if list.Contains(#versions[:6], version) { // before 1.6 + value: string + } + if !list.Contains(#versions[:6], version) { // 1.6 or later + value: [...string] + } + description?: string | null + operator: "isoneof" | "isnotoneof" }) diff --git a/core/validation/flipt.json b/core/validation/flipt.json index 57e089ac4b..85f9c902ae 100644 --- a/core/validation/flipt.json +++ b/core/validation/flipt.json @@ -507,6 +507,106 @@ } ] }, + "semverComparisonOperator": { + "allOf": [ + { + "if": { + "properties": { + "type": { + "const": "SEMVER_COMPARISON_TYPE" + } + }, + "required": [ + "operator" + ] + }, + "then": { + "properties": { + "operator": { + "enum": [ + "eq", + "neq", + "present", + "notpresent", + "lt", + "lte", + "gt", + "gte", + "isoneof", + "isnotoneof" + ] + } + } + } + }, + { + "if": { + "properties": { + "type": { + "const": "SEMVER_COMPARISON_TYPE" + }, + "operator": { + "not": { + "enum": [ + "isoneof", + "isnotoneof" + ] + } + } + }, + "required": [ + "type" + ] + }, + "then": { + "properties": { + "value": { + "type": "string" + } + } + } + }, + { + "if": { + "properties": { + "type": { + "const": "SEMVER_COMPARISON_TYPE" + }, + "operator": { + "enum": [ + "isoneof", + "isnotoneof" + ] + } + }, + "required": [ + "type", + "operator" + ] + }, + "then": { + "properties": { + "value": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + } + }, + "required": [ + "value" + ] + } + } + ] + }, "constraint": { "required": [ "type", @@ -520,7 +620,8 @@ "NUMBER_COMPARISON_TYPE", "BOOLEAN_COMPARISON_TYPE", "DATETIME_COMPARISON_TYPE", - "ENTITY_ID_COMPARISON_TYPE" + "ENTITY_ID_COMPARISON_TYPE", + "SEMVER_COMPARISON_TYPE" ] }, "property": { @@ -564,6 +665,9 @@ }, { "$ref": "#/$defs/entityIdComparisonOperator" + }, + { + "$ref": "#/$defs/semverComparisonOperator" } ] }, @@ -610,6 +714,20 @@ "value": { "type": "array", "items": { "type": "string" } } } } + }, + { + "if": { + "properties": { + "type": { "const": "SEMVER_COMPARISON_TYPE" }, + "operator": { "enum": ["isoneof", "isnotoneof"] } + }, + "required": ["type", "operator"] + }, + "then": { + "properties": { + "value": { "type": "array", "items": { "type": "string" } } + } + } } ] }, diff --git a/core/validation/testdata/valid_constraint_semver_operator.yaml b/core/validation/testdata/valid_constraint_semver_operator.yaml new file mode 100644 index 0000000000..078adc4ec4 --- /dev/null +++ b/core/validation/testdata/valid_constraint_semver_operator.yaml @@ -0,0 +1,16 @@ +version: "1.2" +namespace: default +segments: +- key: semver-users + name: Semver Users + description: Users matched with semantic version comparisons. + match_type: ALL_MATCH_TYPE + constraints: + - type: SEMVER_COMPARISON_TYPE + property: app_version + operator: gte + value: "2.1.0-alpha.1" + - type: SEMVER_COMPARISON_TYPE + property: client_version + operator: lt + value: "3.0.0" diff --git a/core/validation/validate_test.go b/core/validation/validate_test.go index c50b601b7c..7a33306085 100644 --- a/core/validation/validate_test.go +++ b/core/validation/validate_test.go @@ -80,6 +80,20 @@ func TestValidate_ConstraintLTOperator(t *testing.T) { assert.NoError(t, err) } +func TestValidate_ConstraintSemverOperator(t *testing.T) { + const file = "testdata/valid_constraint_semver_operator.yaml" + f, err := os.Open(file) + require.NoError(t, err) + + defer f.Close() + + v, err := NewFeaturesValidator() + require.NoError(t, err) + + err = v.Validate(file, f) + assert.NoError(t, err) +} + func TestValidate_DefaultVariant_V3(t *testing.T) { const file = "testdata/valid_default_variant_v3.yaml" f, err := os.Open(file) diff --git a/go.mod b/go.mod index 743b064103..97a525ccb2 100644 --- a/go.mod +++ b/go.mod @@ -12,6 +12,7 @@ require ( github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azsecrets v1.5.0 github.com/ClickHouse/clickhouse-go/v2 v2.48.0 github.com/MakeNowJust/heredoc v1.0.0 + github.com/Masterminds/semver/v3 v3.5.0 github.com/ProtonMail/go-crypto v1.5.2 github.com/aws/aws-sdk-go-v2 v1.47.1 github.com/aws/aws-sdk-go-v2/config v1.33.6 diff --git a/go.sum b/go.sum index 7ed5e2d110..5d6ece1f3b 100644 --- a/go.sum +++ b/go.sum @@ -649,7 +649,7 @@ github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= -github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.9/go.mod h1:yqV2xTwBQgBgDqYHkCZ0IZ/8luupq476yKdcuybb8xE= github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= @@ -950,3 +950,5 @@ k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 h1:hwvWFiBzdWw1FhfY1FooPn3kzWuJ8 k8s.io/utils v0.0.0-20250604170112-4c0f3b243397/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk= pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04= +github.com/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAwZ/2OOE= +github.com/Masterminds/semver/v3 v3.5.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= diff --git a/internal/server/evaluation/evaluation.go b/internal/server/evaluation/evaluation.go index 00a266df99..eb8e2d6254 100644 --- a/internal/server/evaluation/evaluation.go +++ b/internal/server/evaluation/evaluation.go @@ -2,6 +2,7 @@ package evaluation import ( "context" + "encoding/json" "fmt" "hash/crc32" "sort" @@ -9,6 +10,8 @@ import ( "strings" "time" + "github.com/Masterminds/semver/v3" + errs "go.flipt.io/flipt/errors" environments "go.flipt.io/flipt/internal/server/environments" "go.flipt.io/flipt/internal/server/metrics" @@ -657,6 +660,8 @@ func (s *Server) matchConstraints(evalCtx map[string]string, constraints []stora match, err = matchesDateTime(c, v) case core.ComparisonType_ENTITY_ID_COMPARISON_TYPE: match, err = matchesString(c, entityId) + case core.ComparisonType_SEMVER_COMPARISON_TYPE: + match, err = matchesSemver(c, v) default: return false, reason, errs.ErrInvalid("unknown constraint type") } @@ -894,6 +899,100 @@ func tryParseDateTime(v string) (time.Time, error) { return time.Time{}, errs.ErrInvalidf("parsing datetime from %q", v) } +func matchesSemver(c storage.EvaluationConstraint, v string) (bool, error) { + switch c.Operator { + case flipt.OpNotPresent: + return len(strings.TrimSpace(v)) == 0, nil + case flipt.OpPresent: + return len(strings.TrimSpace(v)) != 0, nil + } + + // can't parse an empty string + if v == "" { + return false, nil + } + + d, err := semver.NewVersion(strings.TrimSpace(v)) + if err != nil { + return false, errs.ErrInvalidf("parsing semver from %q", v) + } + + switch c.Operator { + case flipt.OpIsOneOf: + if c.SemverList != nil { + for _, target := range c.SemverList { + if d.Equal(target) { + return true, nil + } + } + return false, nil + } + var values []string + if err := json.Unmarshal([]byte(c.Value), &values); err != nil { + return false, errs.ErrInvalidf("invalid value for constraint %q", c.Value) + } + for _, val := range values { + target, err := semver.NewVersion(strings.TrimSpace(val)) + if err != nil { + return false, errs.ErrInvalidf("parsing semver from %q", val) + } + if d.Equal(target) { + return true, nil + } + } + return false, nil + case flipt.OpIsNotOneOf: + if c.SemverList != nil { + for _, target := range c.SemverList { + if d.Equal(target) { + return false, nil + } + } + return true, nil + } + var values []string + if err := json.Unmarshal([]byte(c.Value), &values); err != nil { + return false, errs.ErrInvalidf("invalid value for constraint %q", c.Value) + } + for _, val := range values { + target, err := semver.NewVersion(strings.TrimSpace(val)) + if err != nil { + return false, errs.ErrInvalidf("parsing semver from %q", val) + } + if d.Equal(target) { + return false, nil + } + } + return true, nil + } + + value := c.Semver + if value == nil { + var err error + value, err = semver.NewVersion(strings.TrimSpace(c.Value)) + if err != nil { + return false, errs.ErrInvalidf("parsing semver from %q", c.Value) + } + } + + switch c.Operator { + case flipt.OpEQ: + return d.Equal(value), nil + case flipt.OpNEQ: + return !d.Equal(value), nil + case flipt.OpLT: + return d.LessThan(value), nil + case flipt.OpLTE: + return d.LessThan(value) || d.Equal(value), nil + case flipt.OpGT: + return d.GreaterThan(value), nil + case flipt.OpGTE: + return d.GreaterThan(value) || d.Equal(value), nil + } + + return false, nil +} + func getVariant(key string, variants ...*core.Variant) (*core.Variant, bool) { for _, v := range variants { if v.Key == key { diff --git a/internal/server/evaluation/semver_test.go b/internal/server/evaluation/semver_test.go new file mode 100644 index 0000000000..e73757aef3 --- /dev/null +++ b/internal/server/evaluation/semver_test.go @@ -0,0 +1,577 @@ +package evaluation + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + errs "go.flipt.io/flipt/errors" + "go.flipt.io/flipt/internal/storage" + "go.flipt.io/flipt/rpc/flipt" + "go.flipt.io/flipt/rpc/flipt/core" +) + +func Test_matchesSemver(t *testing.T) { + tests := []struct { + name string + constraint storage.EvaluationConstraint + value string + wantMatch bool + wantErr bool + }{ + { + name: "present", + constraint: storage.EvaluationConstraint{ + Property: "version", + Operator: "present", + }, + value: "1.0.0", + wantMatch: true, + }, + { + name: "negative present", + constraint: storage.EvaluationConstraint{ + Property: "version", + Operator: "present", + }, + value: "", + wantMatch: false, + }, + { + name: "negative present whitespace", + constraint: storage.EvaluationConstraint{ + Property: "version", + Operator: "present", + }, + value: " ", + wantMatch: false, + }, + { + name: "not present", + constraint: storage.EvaluationConstraint{ + Property: "version", + Operator: "notpresent", + }, + value: "", + wantMatch: true, + }, + { + name: "not present whitespace", + constraint: storage.EvaluationConstraint{ + Property: "version", + Operator: "notpresent", + }, + value: " ", + wantMatch: true, + }, + { + name: "negative not present", + constraint: storage.EvaluationConstraint{ + Property: "version", + Operator: "notpresent", + }, + value: "1.0.0", + wantMatch: false, + }, + { + name: "not a semver constraint value", + constraint: storage.EvaluationConstraint{ + Property: "version", + Operator: "eq", + Value: "invalid-semver", + }, + value: "1.0.0", + wantErr: true, + }, + { + name: "not a semver context value", + constraint: storage.EvaluationConstraint{ + Property: "version", + Operator: "eq", + Value: "1.0.0", + }, + value: "invalid-semver", + wantErr: true, + }, + { + name: "empty context value", + constraint: storage.EvaluationConstraint{ + Property: "version", + Operator: "eq", + Value: "1.0.0", + }, + value: "", + wantMatch: false, + }, + { + name: "unknown operator", + constraint: storage.EvaluationConstraint{ + Property: "version", + Operator: "unknown", + Value: "1.0.0", + }, + value: "1.0.0", + wantMatch: false, + }, + { + name: "eq exact", + constraint: storage.EvaluationConstraint{ + Property: "version", + Operator: "eq", + Value: "1.2.3", + }, + value: "1.2.3", + wantMatch: true, + }, + { + name: "eq with v prefix", + constraint: storage.EvaluationConstraint{ + Property: "version", + Operator: "eq", + Value: "v1.2.3", + }, + value: "1.2.3", + wantMatch: true, + }, + { + name: "eq build metadata ignored", + constraint: storage.EvaluationConstraint{ + Property: "version", + Operator: "eq", + Value: "1.2.3+build1", + }, + value: "1.2.3+build2", + wantMatch: true, + }, + { + name: "negative eq", + constraint: storage.EvaluationConstraint{ + Property: "version", + Operator: "eq", + Value: "1.2.3", + }, + value: "1.2.4", + wantMatch: false, + }, + { + name: "neq", + constraint: storage.EvaluationConstraint{ + Property: "version", + Operator: "neq", + Value: "1.2.3", + }, + value: "1.2.4", + wantMatch: true, + }, + { + name: "negative neq", + constraint: storage.EvaluationConstraint{ + Property: "version", + Operator: "neq", + Value: "1.2.3", + }, + value: "1.2.3", + wantMatch: false, + }, + { + name: "lt patch", + constraint: storage.EvaluationConstraint{ + Property: "version", + Operator: "lt", + Value: "1.2.3", + }, + value: "1.2.2", + wantMatch: true, + }, + { + name: "lt minor", + constraint: storage.EvaluationConstraint{ + Property: "version", + Operator: "lt", + Value: "1.3.0", + }, + value: "1.2.9", + wantMatch: true, + }, + { + name: "lt major", + constraint: storage.EvaluationConstraint{ + Property: "version", + Operator: "lt", + Value: "2.0.0", + }, + value: "1.9.9", + wantMatch: true, + }, + { + name: "lt prerelease to release", + constraint: storage.EvaluationConstraint{ + Property: "version", + Operator: "lt", + Value: "1.0.0", + }, + value: "1.0.0-alpha", + wantMatch: true, + }, + { + name: "lt prerelease alpha to beta", + constraint: storage.EvaluationConstraint{ + Property: "version", + Operator: "lt", + Value: "1.0.0-beta", + }, + value: "1.0.0-alpha", + wantMatch: true, + }, + { + name: "lt prerelease numeric identifier", + constraint: storage.EvaluationConstraint{ + Property: "version", + Operator: "lt", + Value: "1.0.0-alpha.2", + }, + value: "1.0.0-alpha.1", + wantMatch: true, + }, + { + name: "negative lt equal", + constraint: storage.EvaluationConstraint{ + Property: "version", + Operator: "lt", + Value: "1.2.3", + }, + value: "1.2.3", + wantMatch: false, + }, + { + name: "negative lt greater", + constraint: storage.EvaluationConstraint{ + Property: "version", + Operator: "lt", + Value: "1.2.3", + }, + value: "1.2.4", + wantMatch: false, + }, + { + name: "lte less", + constraint: storage.EvaluationConstraint{ + Property: "version", + Operator: "lte", + Value: "1.2.3", + }, + value: "1.2.2", + wantMatch: true, + }, + { + name: "lte equal", + constraint: storage.EvaluationConstraint{ + Property: "version", + Operator: "lte", + Value: "1.2.3", + }, + value: "1.2.3", + wantMatch: true, + }, + { + name: "negative lte", + constraint: storage.EvaluationConstraint{ + Property: "version", + Operator: "lte", + Value: "1.2.3", + }, + value: "1.2.4", + wantMatch: false, + }, + { + name: "gt patch", + constraint: storage.EvaluationConstraint{ + Property: "version", + Operator: "gt", + Value: "1.2.3", + }, + value: "1.2.4", + wantMatch: true, + }, + { + name: "gt minor", + constraint: storage.EvaluationConstraint{ + Property: "version", + Operator: "gt", + Value: "1.2.9", + }, + value: "1.3.0", + wantMatch: true, + }, + { + name: "gt major", + constraint: storage.EvaluationConstraint{ + Property: "version", + Operator: "gt", + Value: "1.9.9", + }, + value: "2.0.0", + wantMatch: true, + }, + { + name: "gt release over prerelease", + constraint: storage.EvaluationConstraint{ + Property: "version", + Operator: "gt", + Value: "1.0.0-rc.1", + }, + value: "1.0.0", + wantMatch: true, + }, + { + name: "negative gt equal", + constraint: storage.EvaluationConstraint{ + Property: "version", + Operator: "gt", + Value: "1.2.3", + }, + value: "1.2.3", + wantMatch: false, + }, + { + name: "negative gt less", + constraint: storage.EvaluationConstraint{ + Property: "version", + Operator: "gt", + Value: "1.2.3", + }, + value: "1.2.2", + wantMatch: false, + }, + { + name: "gte greater", + constraint: storage.EvaluationConstraint{ + Property: "version", + Operator: "gte", + Value: "1.2.3", + }, + value: "1.2.4", + wantMatch: true, + }, + { + name: "gte equal", + constraint: storage.EvaluationConstraint{ + Property: "version", + Operator: "gte", + Value: "1.2.3", + }, + value: "1.2.3", + wantMatch: true, + }, + { + name: "negative gte", + constraint: storage.EvaluationConstraint{ + Property: "version", + Operator: "gte", + Value: "1.2.3", + }, + value: "1.2.2", + wantMatch: false, + }, + { + name: "isoneof match", + constraint: storage.EvaluationConstraint{ + Property: "version", + Operator: "isoneof", + Value: `["1.0.0", "1.2.3", "2.0.0"]`, + }, + value: "1.2.3", + wantMatch: true, + }, + { + name: "isoneof negative match", + constraint: storage.EvaluationConstraint{ + Property: "version", + Operator: "isoneof", + Value: `["1.0.0", "2.0.0"]`, + }, + value: "1.2.3", + wantMatch: false, + }, + { + name: "isoneof invalid json", + constraint: storage.EvaluationConstraint{ + Property: "version", + Operator: "isoneof", + Value: `["invalid json`, + }, + value: "1.2.3", + wantErr: true, + }, + { + name: "isoneof invalid semver in array", + constraint: storage.EvaluationConstraint{ + Property: "version", + Operator: "isoneof", + Value: `["1.0.0", "not-a-semver"]`, + }, + value: "1.2.3", + wantErr: true, + }, + { + name: "isnotoneof match", + constraint: storage.EvaluationConstraint{ + Property: "version", + Operator: "isnotoneof", + Value: `["1.0.0", "2.0.0"]`, + }, + value: "1.2.3", + wantMatch: true, + }, + { + name: "isnotoneof negative match", + constraint: storage.EvaluationConstraint{ + Property: "version", + Operator: "isnotoneof", + Value: `["1.0.0", "1.2.3"]`, + }, + value: "1.2.3", + wantMatch: false, + }, + { + name: "isnotoneof invalid json", + constraint: storage.EvaluationConstraint{ + Property: "version", + Operator: "isnotoneof", + Value: `["invalid json`, + }, + value: "1.2.3", + wantErr: true, + }, + { + name: "isnotoneof invalid semver in array", + constraint: storage.EvaluationConstraint{ + Property: "version", + Operator: "isnotoneof", + Value: `["not-a-semver"]`, + }, + value: "1.2.3", + wantErr: true, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + match, err := matchesSemver(tt.constraint, tt.value) + + if tt.wantErr { + require.Error(t, err) + var ierr errs.ErrInvalid + assert.ErrorAs(t, err, &ierr) + return + } + + require.NoError(t, err) + assert.Equal(t, tt.wantMatch, match) + }) + } +} + +func Test_matchesSemverWithPreparedSets(t *testing.T) { + tests := []struct { + name string + constraint storage.EvaluationConstraint + value string + wantMatch bool + wantErr bool + }{ + { + name: "isoneof match", + constraint: storage.EvaluationConstraint{ + Type: core.ComparisonType_SEMVER_COMPARISON_TYPE, + Property: "version", + Operator: flipt.OpIsOneOf, + Value: "[\"1.0.0\", \"2.0.0\"]", + }, + value: "1.0.0", + wantMatch: true, + }, + { + name: "isoneof no match", + constraint: storage.EvaluationConstraint{ + Type: core.ComparisonType_SEMVER_COMPARISON_TYPE, + Property: "version", + Operator: flipt.OpIsOneOf, + Value: "[\"1.0.0\", \"2.0.0\"]", + }, + value: "3.0.0", + wantMatch: false, + }, + { + name: "isnotoneof match", + constraint: storage.EvaluationConstraint{ + Type: core.ComparisonType_SEMVER_COMPARISON_TYPE, + Property: "version", + Operator: flipt.OpIsNotOneOf, + Value: "[\"1.0.0\", \"2.0.0\"]", + }, + value: "3.0.0", + wantMatch: true, + }, + { + name: "isnotoneof no match", + constraint: storage.EvaluationConstraint{ + Type: core.ComparisonType_SEMVER_COMPARISON_TYPE, + Property: "version", + Operator: flipt.OpIsNotOneOf, + Value: "[\"1.0.0\", \"2.0.0\"]", + }, + value: "2.0.0", + wantMatch: false, + }, + { + name: "eq with v prefix prepared", + constraint: storage.EvaluationConstraint{ + Type: core.ComparisonType_SEMVER_COMPARISON_TYPE, + Property: "version", + Operator: flipt.OpEQ, + Value: "v1.2.3", + }, + value: "1.2.3", + wantMatch: true, + }, + { + name: "gte with prerelease prepared", + constraint: storage.EvaluationConstraint{ + Type: core.ComparisonType_SEMVER_COMPARISON_TYPE, + Property: "version", + Operator: flipt.OpGTE, + Value: "1.0.0-alpha", + }, + value: "1.0.0-beta", + wantMatch: true, + }, + { + name: "lt with prerelease prepared", + constraint: storage.EvaluationConstraint{ + Type: core.ComparisonType_SEMVER_COMPARISON_TYPE, + Property: "version", + Operator: flipt.OpLT, + Value: "1.0.0", + }, + value: "1.0.0-rc.1", + wantMatch: true, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + constraint := tt.constraint + require.NoError(t, constraint.PrepareForEvaluation()) + match, err := matchesSemver(constraint, tt.value) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + assert.Equal(t, tt.wantMatch, match) + }) + } +} diff --git a/internal/storage/fs/snapshot.go b/internal/storage/fs/snapshot.go index f2716d3b34..6a04621f0e 100644 --- a/internal/storage/fs/snapshot.go +++ b/internal/storage/fs/snapshot.go @@ -842,6 +842,8 @@ func toEvaluationConstraintComparisonType(c core.ComparisonType) evaluation.Eval return evaluation.EvaluationConstraintComparisonType_BOOLEAN_CONSTRAINT_COMPARISON_TYPE case core.ComparisonType_ENTITY_ID_COMPARISON_TYPE: return evaluation.EvaluationConstraintComparisonType_ENTITY_ID_CONSTRAINT_COMPARISON_TYPE + case core.ComparisonType_SEMVER_COMPARISON_TYPE: + return evaluation.EvaluationConstraintComparisonType_SEMVER_CONSTRAINT_COMPARISON_TYPE } return evaluation.EvaluationConstraintComparisonType_UNKNOWN_CONSTRAINT_COMPARISON_TYPE } diff --git a/internal/storage/storage.go b/internal/storage/storage.go index 7776578b26..1dcc54c118 100644 --- a/internal/storage/storage.go +++ b/internal/storage/storage.go @@ -8,6 +8,7 @@ import ( "strconv" "time" + "github.com/Masterminds/semver/v3" "go.flipt.io/flipt/internal/containers" "go.flipt.io/flipt/rpc/flipt" "go.flipt.io/flipt/rpc/flipt/core" @@ -90,6 +91,8 @@ type EvaluationConstraint struct { NumberSet map[float64]struct{} `json:"-"` // populated for number isoneof/isnotoneof constraints; used by matchesNumberSet. Number *float64 `json:"-"` // populated for scalar number comparisons (eq, neq, lt, lte, gt, gte); used by matchesNumber. Datetime time.Time `json:"-"` // populated for scalar datetime comparisons (eq, neq, lt, lte, gt, gte); used by matchesDatetime. + Semver *semver.Version `json:"-"` // populated for scalar semver comparisons (eq, neq, lt, lte, gt, gte); used by matchesSemver. + SemverList []*semver.Version `json:"-"` // populated for semver isoneof/isnotoneof constraints; used by matchesSemver. } // Pre-parses the Value field to deduplicate JSON deserialization and avoid linear scans. @@ -115,6 +118,19 @@ func (c *EvaluationConstraint) PrepareForEvaluation() error { for _, v := range values { c.NumberSet[v] = struct{}{} } + case core.ComparisonType_SEMVER_COMPARISON_TYPE: + var values []string + if err := json.Unmarshal([]byte(c.Value), &values); err != nil { + return fmt.Errorf("constraint %q: parsing semver list: %w", c.Property, err) + } + c.SemverList = make([]*semver.Version, 0, len(values)) + for _, v := range values { + sv, err := semver.NewVersion(v) + if err != nil { + return fmt.Errorf("constraint %q: parsing semver: %w", c.Property, err) + } + c.SemverList = append(c.SemverList, sv) + } } case flipt.OpEQ, flipt.OpNEQ, flipt.OpLT, flipt.OpLTE, flipt.OpGT, flipt.OpGTE: switch { @@ -130,6 +146,12 @@ func (c *EvaluationConstraint) PrepareForEvaluation() error { return fmt.Errorf("constraint %q: %w", c.Property, err) } c.Datetime = d + case c.Type == core.ComparisonType_SEMVER_COMPARISON_TYPE && c.Value != "": + sv, err := semver.NewVersion(c.Value) + if err != nil { + return fmt.Errorf("constraint %q: parsing semver: %w", c.Property, err) + } + c.Semver = sv } } return nil diff --git a/rpc/flipt/core/core.pb.go b/rpc/flipt/core/core.pb.go index b37e3fcbc5..3b400442b2 100644 --- a/rpc/flipt/core/core.pb.go +++ b/rpc/flipt/core/core.pb.go @@ -218,6 +218,7 @@ const ( ComparisonType_BOOLEAN_COMPARISON_TYPE ComparisonType = 3 ComparisonType_DATETIME_COMPARISON_TYPE ComparisonType = 4 ComparisonType_ENTITY_ID_COMPARISON_TYPE ComparisonType = 5 + ComparisonType_SEMVER_COMPARISON_TYPE ComparisonType = 6 ) // Enum value maps for ComparisonType. @@ -229,6 +230,7 @@ var ( 3: "BOOLEAN_COMPARISON_TYPE", 4: "DATETIME_COMPARISON_TYPE", 5: "ENTITY_ID_COMPARISON_TYPE", + 6: "SEMVER_COMPARISON_TYPE", } ComparisonType_value = map[string]int32{ "UNKNOWN_COMPARISON_TYPE": 0, @@ -237,6 +239,7 @@ var ( "BOOLEAN_COMPARISON_TYPE": 3, "DATETIME_COMPARISON_TYPE": 4, "ENTITY_ID_COMPARISON_TYPE": 5, + "SEMVER_COMPARISON_TYPE": 6, } ) diff --git a/rpc/flipt/core/core.proto b/rpc/flipt/core/core.proto index e357043e33..a448d4442c 100644 --- a/rpc/flipt/core/core.proto +++ b/rpc/flipt/core/core.proto @@ -97,6 +97,7 @@ enum ComparisonType { BOOLEAN_COMPARISON_TYPE = 3; DATETIME_COMPARISON_TYPE = 4; ENTITY_ID_COMPARISON_TYPE = 5; + SEMVER_COMPARISON_TYPE = 6; } message Constraint { diff --git a/rpc/flipt/operators.go b/rpc/flipt/operators.go index a56c6ce483..ee785d54db 100644 --- a/rpc/flipt/operators.go +++ b/rpc/flipt/operators.go @@ -88,4 +88,16 @@ var ( OpContains: {}, OpNotContains: {}, } + SemverOperators = map[string]struct{}{ + OpEQ: {}, + OpNEQ: {}, + OpLT: {}, + OpLTE: {}, + OpGT: {}, + OpGTE: {}, + OpPresent: {}, + OpNotPresent: {}, + OpIsOneOf: {}, + OpIsNotOneOf: {}, + } ) diff --git a/rpc/v2/evaluation/evaluationv2.pb.go b/rpc/v2/evaluation/evaluationv2.pb.go index 885f652ac6..9104cc07ba 100644 --- a/rpc/v2/evaluation/evaluationv2.pb.go +++ b/rpc/v2/evaluation/evaluationv2.pb.go @@ -221,6 +221,7 @@ const ( EvaluationConstraintComparisonType_BOOLEAN_CONSTRAINT_COMPARISON_TYPE EvaluationConstraintComparisonType = 3 EvaluationConstraintComparisonType_DATETIME_CONSTRAINT_COMPARISON_TYPE EvaluationConstraintComparisonType = 4 EvaluationConstraintComparisonType_ENTITY_ID_CONSTRAINT_COMPARISON_TYPE EvaluationConstraintComparisonType = 5 + EvaluationConstraintComparisonType_SEMVER_CONSTRAINT_COMPARISON_TYPE EvaluationConstraintComparisonType = 6 ) // Enum value maps for EvaluationConstraintComparisonType. @@ -232,6 +233,7 @@ var ( 3: "BOOLEAN_CONSTRAINT_COMPARISON_TYPE", 4: "DATETIME_CONSTRAINT_COMPARISON_TYPE", 5: "ENTITY_ID_CONSTRAINT_COMPARISON_TYPE", + 6: "SEMVER_CONSTRAINT_COMPARISON_TYPE", } EvaluationConstraintComparisonType_value = map[string]int32{ "UNKNOWN_CONSTRAINT_COMPARISON_TYPE": 0, @@ -240,6 +242,7 @@ var ( "BOOLEAN_CONSTRAINT_COMPARISON_TYPE": 3, "DATETIME_CONSTRAINT_COMPARISON_TYPE": 4, "ENTITY_ID_CONSTRAINT_COMPARISON_TYPE": 5, + "SEMVER_CONSTRAINT_COMPARISON_TYPE": 6, } ) diff --git a/rpc/v2/evaluation/evaluationv2.proto b/rpc/v2/evaluation/evaluationv2.proto index 97a6bbe3e0..de5be60ff0 100644 --- a/rpc/v2/evaluation/evaluationv2.proto +++ b/rpc/v2/evaluation/evaluationv2.proto @@ -98,6 +98,7 @@ enum EvaluationConstraintComparisonType { BOOLEAN_CONSTRAINT_COMPARISON_TYPE = 3; DATETIME_CONSTRAINT_COMPARISON_TYPE = 4; ENTITY_ID_CONSTRAINT_COMPARISON_TYPE = 5; + SEMVER_CONSTRAINT_COMPARISON_TYPE = 6; } message EvaluationConstraint { diff --git a/rpc/v2/evaluation/openapi.yaml b/rpc/v2/evaluation/openapi.yaml index fabadf0dcc..d9dc6d85aa 100644 --- a/rpc/v2/evaluation/openapi.yaml +++ b/rpc/v2/evaluation/openapi.yaml @@ -102,6 +102,7 @@ components: - BOOLEAN_CONSTRAINT_COMPARISON_TYPE - DATETIME_CONSTRAINT_COMPARISON_TYPE - ENTITY_ID_CONSTRAINT_COMPARISON_TYPE + - SEMVER_CONSTRAINT_COMPARISON_TYPE type: string format: enum property: diff --git a/ui/src/components/segments/ConstraintForm.tsx b/ui/src/components/segments/ConstraintForm.tsx index c02f0de171..aeed06ea4e 100644 --- a/ui/src/components/segments/ConstraintForm.tsx +++ b/ui/src/components/segments/ConstraintForm.tsx @@ -21,6 +21,7 @@ import { ConstraintDateTimeOperators, ConstraintEntityIdOperators, ConstraintNumberOperators, + ConstraintSemverOperators, ConstraintStringOperators, ConstraintType, IConstraint, @@ -64,6 +65,9 @@ const constraintOperators = (c: string) => { case ConstraintType.ENTITY_ID: opts = ConstraintEntityIdOperators; break; + case ConstraintType.SEMVER: + opts = ConstraintSemverOperators; + break; } return Object.entries(opts).map(([k, v]) => ({ value: k, @@ -323,14 +327,16 @@ const ConstraintForm = forwardRef((props: ConstraintFormProps, ref: any) => { const placeholder = { [ConstraintType.STRING]: 'Eg: Any text', [ConstraintType.NUMBER]: 'Eg: 200', - [ConstraintType.ENTITY_ID]: 'Eg: Any text' + [ConstraintType.ENTITY_ID]: 'Eg: Any text', + [ConstraintType.SEMVER]: 'Eg: 1.0.0' }[type as string]; return placeholder ?? ''; } const placeholder = { [ConstraintType.STRING]: 'Eg: Any text', - [ConstraintType.NUMBER]: 'Eg: 200' + [ConstraintType.NUMBER]: 'Eg: 200', + [ConstraintType.SEMVER]: 'Eg: 1.0.0' }[type as string]; return placeholder ?? ''; }; diff --git a/ui/src/types/Constraint.ts b/ui/src/types/Constraint.ts index 5cff7d25fc..ae8d0171c2 100644 --- a/ui/src/types/Constraint.ts +++ b/ui/src/types/Constraint.ts @@ -12,7 +12,8 @@ export enum ConstraintType { NUMBER = 'NUMBER_COMPARISON_TYPE', BOOLEAN = 'BOOLEAN_COMPARISON_TYPE', DATETIME = 'DATETIME_COMPARISON_TYPE', - ENTITY_ID = 'ENTITY_ID_COMPARISON_TYPE' + ENTITY_ID = 'ENTITY_ID_COMPARISON_TYPE', + SEMVER = 'SEMVER_COMPARISON_TYPE' } export function constraintTypeToLabel(c: ConstraintType): string { @@ -27,6 +28,8 @@ export function constraintTypeToLabel(c: ConstraintType): string { return 'Date Time'; case ConstraintType.ENTITY_ID: return 'Entity'; + case ConstraintType.SEMVER: + return 'Semver'; default: return 'Unknown'; } @@ -85,6 +88,19 @@ export const ConstraintDateTimeOperators: Record = { notpresent: 'IS NOT PRESENT' }; +export const ConstraintSemverOperators: Record = { + eq: '==', + neq: '!=', + lt: '<', + lte: '<=', + gt: '>', + gte: '>=', + present: 'IS PRESENT', + notpresent: 'IS NOT PRESENT', + isoneof: 'IS ONE OF', + isnotoneof: 'IS NOT ONE OF' +}; + export const NoValueOperators: string[] = [ 'true', 'false', @@ -99,5 +115,6 @@ export const ConstraintOperators: Record = { ...ConstraintNumberOperators, ...ConstraintBooleanOperators, ...ConstraintDateTimeOperators, - ...ConstraintEntityIdOperators + ...ConstraintEntityIdOperators, + ...ConstraintSemverOperators };