diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index dda06cb9b3..856e514d82 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -47,6 +47,11 @@ jobs: bash scripts/tests/test-github-bootstrap-isolation.sh bash scripts/tests/test-crossplane-sync-alerter.sh + - name: 🚩 Validate runtime flag definitions + run: | + go test ./scripts/validate-feature-flags + go run ./scripts/validate-feature-flags + - name: 🛡️ Validate preserved policy failure actions run: | go test ./scripts/validate-policy-actions diff --git a/AGENTS.md b/AGENTS.md index d12542378b..62788d9997 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -587,3 +587,10 @@ on new stateful resources. 4. **Platform behaviour → Kubernetes `--feature-gates`** (alpha/beta/GA) — orthogonal, owned by Talos machine config. **Pick the right tool, not always a flag:** a permanent setting is plain config; a version/traffic rollout is Flagger (layer 2), not a runtime flag. **Flag lifecycle:** a *release* flag is short-lived and **removed after rollout** (file the removal when it's born); only *kill-switch* and *permissioning* flags are long-lived. FeatureFlag/FeatureFlagSource CRDs are runtime-installed, so add them to `validation.skipKinds` in `ksail.yaml`+`ksail.prod.yaml` when the first CR lands (same as the Flagger/Tenant CRDs). + +CI checks every authored `FeatureFlag.spec.flagSpec` with the pinned, offline +flagd schema via [`go run ./scripts/validate-feature-flags`](scripts/validate-feature-flags/README.md). Run it before adding +or changing flags. The check reports empty coverage explicitly until a real flag +exists; skipping the runtime-installed CRD's manifest schema does not skip its +flag definitions. The operator requires a string default variant, which must +name a defined variant. diff --git a/go.mod b/go.mod index d0b25cbbba..2699ff6d28 100644 --- a/go.mod +++ b/go.mod @@ -3,9 +3,12 @@ module github.com/devantler-tech/platform go 1.26.6 require ( + github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 github.com/siderolabs/talos/pkg/machinery v1.14.0 + go.yaml.in/yaml/v2 v2.4.2 gopkg.in/yaml.v3 v3.0.1 mvdan.cc/sh/v3 v3.14.1 + sigs.k8s.io/yaml v1.6.0 ) require ( diff --git a/go.sum b/go.sum index 116ec0122b..7137b3a037 100644 --- a/go.sum +++ b/go.sum @@ -39,6 +39,8 @@ github.com/planetscale/vtprotobuf v0.6.1-0.20260702190614-8ae5a48058df h1:x2ymdo github.com/planetscale/vtprotobuf v0.6.1-0.20260702190614-8ae5a48058df/go.mod h1:araspv2uYKozbi5lrKaqpv1/Uei7eQSml8JCw2A3IRg= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 h1:1EYB5IzjZawrrnELUi78f9fPu57HuXjmddZPjrls/28= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.3/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= github.com/sasha-s/go-deadlock v0.3.9 h1:fiaT9rB7g5sr5ddNZvlwheclN9IP86eFW9WgqlEQV+w= github.com/sasha-s/go-deadlock v0.3.9/go.mod h1:KuZj51ZFmx42q/mPaYbRk0P1xcwe697zsJKE03vD4/Y= github.com/siderolabs/crypto v0.6.5 h1:Elq5tpWP2ApZ4Y+Kg+eIDiiWbmriCPI1mjYIMwvsYkw= @@ -65,6 +67,8 @@ go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRk go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA= go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= +go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= +go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= go.yaml.in/yaml/v4 v4.0.0-rc.6 h1:1h7H1ohdUh93/FyE4YaDa1Zh64K6VVbjF4K6WUxMtH4= @@ -95,3 +99,5 @@ mvdan.cc/sh/v3 v3.12.0 h1:ejKUR7ONP5bb+UGHGEG/k9V5+pRVIyD+LsZz7o8KHrI= mvdan.cc/sh/v3 v3.12.0/go.mod h1:Se6Cj17eYSn+sNooLZiEUnNNmNxg0imoYlTu4CyaGyg= mvdan.cc/sh/v3 v3.14.1 h1:bXkhQWNHCs0KZEChF8hYS6FC+T2N9mUZLbQv9blditI= mvdan.cc/sh/v3 v3.14.1/go.mod h1:syYCoFET8w9tvevxiXUtY8/ICrU+l26jHmhJDra3Vwo= +sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= +sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= diff --git a/scripts/validate-feature-flags/README.md b/scripts/validate-feature-flags/README.md new file mode 100644 index 0000000000..ed10b6c39d --- /dev/null +++ b/scripts/validate-feature-flags/README.md @@ -0,0 +1,34 @@ +# FeatureFlag definitions + +The guard uses Kubernetes-compatible YAML 1.1 decoding. Quote variant names such +as `"on"` and `"off"`; an unquoted boolean default is rejected. Duplicate keys +are rejected before validation. Symlinks to YAML, directories or unresolved +targets fail coverage; ordinary non-manifest file symlinks are ignored. + +```bash +go run ./scripts/validate-feature-flags [YAML-ROOT] +go test ./scripts/validate-feature-flags +``` + +CI runs the guard on PRs and merge groups without a path filter. It inspects all +YAML documents and list members under `k8s`, validates `spec.flagSpec`, and +reports how many FeatureFlag resources were examined. No flags is an explicit +empty-coverage result. Unreadable inputs and invalid definitions fail. + +The schemas are unmodified copies from the official +[`open-feature/flagd-schemas` release json/json-schema-v0.2.15](https://github.com/open-feature/flagd-schemas/tree/1daf5ff56b48d582187d59e35d48c6e191c23839/json), +commit `1daf5ff56b48d582187d59e35d48c6e191c23839`: + +| File | SHA-256 | +| --- | --- | +| flags.json | a9b065cc3e140d10a5e139a3f2bbd2f24d4fe8a728ce824a5f2a1231ed60680b | +| targeting.json | 4194aeab5611f80ce8650a10bed5b18483754587bfb2d7ef093e75a0609a2e75 | + +Both are embedded, and the compiler refuses every unregistered schema URI. +Validation makes no network request. A schema update replaces both upstream +files and refreshes these checksums in a reviewed change. + +The flagd schema permits a null code-defined default for general flagd inputs; +the OpenFeature operator's `v1beta1` CR requires a string `defaultVariant`. +The guard also checks that it names a defined variant. It does not attempt to +prove targeting outcomes, SDK integration, or live flag consumption. diff --git a/scripts/validate-feature-flags/main.go b/scripts/validate-feature-flags/main.go new file mode 100644 index 0000000000..6948c77d1a --- /dev/null +++ b/scripts/validate-feature-flags/main.go @@ -0,0 +1,214 @@ +package main + +import ( + "bytes" + "embed" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/santhosh-tekuri/jsonschema/v6" + "go.yaml.in/yaml/v2" + kubernetesyaml "sigs.k8s.io/yaml" +) + +//go:embed schema/*.json +var schemas embed.FS + +const schemaURL = "https://flagd.dev/schema/v0/flags.json" + +type offlineLoader struct{} + +func (offlineLoader) Load(url string) (any, error) { + return nil, fmt.Errorf("unregistered schema %q; network loading is disabled", url) +} + +func compileSchema() (*jsonschema.Schema, error) { + compiler := jsonschema.NewCompiler() + compiler.UseLoader(offlineLoader{}) + for _, name := range []string{"flags.json", "targeting.json"} { + data, err := schemas.ReadFile("schema/" + name) + if err != nil { + return nil, err + } + document, err := jsonschema.UnmarshalJSON(bytes.NewReader(data)) + if err != nil { + return nil, err + } + if err := compiler.AddResource("https://flagd.dev/schema/v0/"+name, document); err != nil { + return nil, err + } + } + return compiler.Compile(schemaURL) +} + +func inspect(document map[string]any, schema *jsonschema.Schema, count *int) error { + kind, _ := document["kind"].(string) + api, _ := document["apiVersion"].(string) + if kind == "List" || kind == "FeatureFlagList" { + if kind == "FeatureFlagList" && api != "core.openfeature.dev/v1beta1" { + return fmt.Errorf("unsupported FeatureFlagList API %q", api) + } + items, ok := document["items"].([]any) + if !ok { + return fmt.Errorf("%s must contain an items list", kind) + } + for _, item := range items { + child, ok := item.(map[string]any) + if !ok { + return errors.New("resource list item must be an object") + } + if kind == "FeatureFlagList" { + if child["kind"] == nil { + child["kind"] = "FeatureFlag" + } + if child["apiVersion"] == nil { + child["apiVersion"] = api + } + if child["kind"] != "FeatureFlag" { + return errors.New("FeatureFlagList contains a different resource kind") + } + } + if err := inspect(child, schema, count); err != nil { + return err + } + } + return nil + } + if kind != "FeatureFlag" { + return nil + } + if api != "core.openfeature.dev/v1beta1" { + return fmt.Errorf("unsupported FeatureFlag API %q", api) + } + spec, _ := document["spec"].(map[string]any) + definition, ok := spec["flagSpec"].(map[string]any) + if !ok { + return errors.New("FeatureFlag spec.flagSpec must be an object") + } + data, err := json.Marshal(definition) + if err != nil { + return fmt.Errorf("flag definition is not JSON-compatible: %w", err) + } + value, err := jsonschema.UnmarshalJSON(bytes.NewReader(data)) + if err != nil { + return err + } + if err := schema.Validate(value); err != nil { + return fmt.Errorf("flagd schema: %w", err) + } + flags, _ := definition["flags"].(map[string]any) + keys := make([]string, 0, len(flags)) + for key := range flags { + keys = append(keys, key) + } + // Stable diagnostics when several flags have invalid defaults. + sort.Strings(keys) + for _, key := range keys { + flag, _ := flags[key].(map[string]any) + variant, ok := flag["defaultVariant"].(string) + if !ok { + return fmt.Errorf("flag %q requires a string defaultVariant in the operator CR", key) + } + variants, _ := flag["variants"].(map[string]any) + if _, exists := variants[variant]; !exists { + return fmt.Errorf("flag %q defaultVariant does not name a variant", key) + } + } + (*count)++ + return nil +} + +func validate(root string, output io.Writer) error { + schema, err := compileSchema() + if err != nil { + return err + } + count := 0 + err = filepath.WalkDir(root, func(path string, entry os.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if entry.Type()&os.ModeSymlink != 0 { + info, statErr := os.Stat(path) + ext := filepath.Ext(path) + if statErr != nil || info.IsDir() || ext == ".yaml" || ext == ".yml" { + return fmt.Errorf("refusing symlink that may hide manifests: %s", path) + } + return nil + } + if entry.IsDir() || (filepath.Ext(path) != ".yaml" && filepath.Ext(path) != ".yml") { + return nil + } + data, err := os.ReadFile(path) + if err != nil { + return err + } + decoder := yaml.NewDecoder(bytes.NewReader(data)) + decoder.SetStrict(true) + for { + var value any + err := decoder.Decode(&value) + if errors.Is(err, io.EOF) { + return nil + } + if err != nil { + return fmt.Errorf("%s: %w", path, err) + } + // Standalone JSON6902 patch arrays and scalar overlay inputs are not CRs. + if _, mapping := value.(map[any]any); !mapping { + continue + } + // Use the Kubernetes YAML 1.1 resolver before JSON conversion. Re-encoding + // a yaml.v3 node would turn plain on/off into quoted strings instead. + documentYAML, err := yaml.Marshal(value) + if err != nil { + return fmt.Errorf("%s: %w", path, err) + } + documentJSON, err := kubernetesyaml.YAMLToJSONStrict(documentYAML) + if err != nil { + return fmt.Errorf("%s: %w", path, err) + } + var document map[string]any + if err := json.Unmarshal(documentJSON, &document); err != nil { + return fmt.Errorf("%s: %w", path, err) + } + if err := inspect(document, schema, &count); err != nil { + return fmt.Errorf("%s: %w", path, err) + } + } + }) + if err != nil { + return err + } + if count == 0 { + _, err = fmt.Fprintln(output, "No FeatureFlag resources found; flag coverage is empty.") + } else { + _, err = fmt.Fprintf(output, "Validated %d FeatureFlag resources against flagd schema 0.2.15.\n", count) + } + return err +} + +func main() { + root := "k8s" + if len(os.Args) > 2 { + fmt.Fprintln(os.Stderr, "usage: validate-feature-flags [ROOT]") + os.Exit(2) + } + if len(os.Args) == 2 { + root = os.Args[1] + } + if strings.TrimSpace(root) == "" { + fmt.Fprintln(os.Stderr, "ROOT must not be empty") + os.Exit(2) + } + if err := validate(root, os.Stdout); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} diff --git a/scripts/validate-feature-flags/main_test.go b/scripts/validate-feature-flags/main_test.go new file mode 100644 index 0000000000..81dc001b9b --- /dev/null +++ b/scripts/validate-feature-flags/main_test.go @@ -0,0 +1,148 @@ +package main + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" +) + +const validFlag = `apiVersion: core.openfeature.dev/v1beta1 +kind: FeatureFlag +metadata: {name: trial} +spec: + flagSpec: + flags: + enabled: + state: ENABLED + defaultVariant: "off" + variants: {"on": true, "off": false} + targeting: {if: [{"==": [{var: role}, admin]}, "on", "off"]} +` + +func fixture(t *testing.T, data string) string { + t.Helper() + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "flag.yaml"), []byte(data), 0600); err != nil { + t.Fatal(err) + } + return dir +} + +func TestActualGuardAcceptsValidAndRejectsMalformedDefinitions(t *testing.T) { + for _, tc := range []struct { + name, data string + valid bool + }{ + {"valid", validFlag, true}, + {"default absent", strings.ReplaceAll(validFlag, " defaultVariant: \"off\"\n", ""), false}, + {"default missing from variants", strings.ReplaceAll(validFlag, "defaultVariant: \"off\"", "defaultVariant: missing"), false}, + {"unquoted YAML 1.1 boolean default", strings.ReplaceAll(validFlag, "defaultVariant: \"off\"", "defaultVariant: off"), false}, + {"mixed variant types", strings.ReplaceAll(validFlag, "\"off\": false", "\"off\": 42"), false}, + {"unknown targeting operator", strings.ReplaceAll(validFlag, "{var: role}", "{wrong: role}"), false}, + {"duplicate key", strings.ReplaceAll(validFlag, "state: ENABLED", "state: ENABLED\n state: DISABLED"), false}, + {"unsupported API", strings.ReplaceAll(validFlag, "v1beta1", "v9"), false}, + {"malformed YAML", "kind: [", false}, + } { + t.Run(tc.name, func(t *testing.T) { + var output bytes.Buffer + err := validate(fixture(t, tc.data), &output) + if (err == nil) != tc.valid { + t.Fatalf("valid=%t error=%v output=%s", tc.valid, err, &output) + } + if tc.valid && !strings.Contains(output.String(), "Validated 1 FeatureFlag") { + t.Fatalf("missing coverage: %s", &output) + } + }) + } +} + +func TestNoFlagsReportsEmptyCoverage(t *testing.T) { + var output bytes.Buffer + if err := validate(fixture(t, "kind: ConfigMap\ndata: {flags: irrelevant}\n"), &output); err != nil { + t.Fatal(err) + } + if !strings.Contains(output.String(), "No FeatureFlag resources found") { + t.Fatal(output.String()) + } +} + +func TestListsAndMultipleDocumentsCannotHideFlags(t *testing.T) { + data := "apiVersion: v1\nkind: List\nitems:\n" + " - " + strings.ReplaceAll(strings.TrimSuffix(validFlag, "\n"), "\n", "\n ") + "\n---\n" + validFlag + var output bytes.Buffer + if err := validate(fixture(t, data), &output); err != nil { + t.Fatal(err) + } + if !strings.Contains(output.String(), "Validated 2 FeatureFlag") { + t.Fatal(output.String()) + } + data = strings.ReplaceAll(data, "\"off\": false", "\"off\": 42") + if err := validate(fixture(t, data), &output); err == nil { + t.Fatal("nested invalid flags passed") + } +} + +func TestUnreadableRootIsUnknown(t *testing.T) { + if err := validate(filepath.Join(t.TempDir(), "missing"), &bytes.Buffer{}); err == nil { + t.Fatal("unreadable root reported clean") + } +} + +func TestTypedListItemsWithoutHeadersAreValidated(t *testing.T) { + item := strings.SplitN(validFlag, "metadata:", 2)[1] + data := "apiVersion: core.openfeature.dev/v1beta1\nkind: FeatureFlagList\nitems:\n - metadata:" + strings.ReplaceAll(item, "\n", "\n ") + var output bytes.Buffer + if err := validate(fixture(t, data), &output); err != nil { + t.Fatal(err) + } + if !strings.Contains(output.String(), "Validated 1 FeatureFlag") { + t.Fatal(output.String()) + } + data = strings.ReplaceAll(data, "\"off\": false", "\"off\": 42") + if err := validate(fixture(t, data), &output); err == nil { + t.Fatal("typed list hid an invalid definition") + } +} + +func TestSymlinkedDirectoriesCannotHideFlagCoverage(t *testing.T) { + dir := t.TempDir() + if err := os.Symlink(fixture(t, validFlag), filepath.Join(dir, "hidden")); err != nil { + t.Fatal(err) + } + if err := validate(dir, &bytes.Buffer{}); err == nil { + t.Fatal("symlink was reported as empty coverage") + } +} + +func TestNonManifestSymlinksDoNotBlockValidation(t *testing.T) { + dir := fixture(t, validFlag) + target := filepath.Join(t.TempDir(), "README.md") + if err := os.WriteFile(target, []byte("documentation"), 0600); err != nil { + t.Fatal(err) + } + if err := os.Symlink(target, filepath.Join(dir, "README.md")); err != nil { + t.Fatal(err) + } + if err := validate(dir, &bytes.Buffer{}); err != nil { + t.Fatalf("non-manifest symlink blocked valid flags: %v", err) + } +} + +func TestManifestAndUnresolvedSymlinksCannotHideCoverage(t *testing.T) { + for _, name := range []string{"flag.yaml", "flag.yml", "unresolved"} { + t.Run(name, func(t *testing.T) { + dir := t.TempDir() + target := filepath.Join(fixture(t, validFlag), "flag.yaml") + if name == "unresolved" { + target = filepath.Join(t.TempDir(), "missing") + } + if err := os.Symlink(target, filepath.Join(dir, name)); err != nil { + t.Fatal(err) + } + if err := validate(dir, &bytes.Buffer{}); err == nil { + t.Fatal("unexamined manifest coverage reported clean") + } + }) + } +} diff --git a/scripts/validate-feature-flags/schema/LICENSE b/scripts/validate-feature-flags/schema/LICENSE new file mode 100644 index 0000000000..96b3dc8fc4 --- /dev/null +++ b/scripts/validate-feature-flags/schema/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright OpenFeature Maintainers + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/scripts/validate-feature-flags/schema/flags.json b/scripts/validate-feature-flags/schema/flags.json new file mode 100644 index 0000000000..cff1aab811 --- /dev/null +++ b/scripts/validate-feature-flags/schema/flags.json @@ -0,0 +1,295 @@ +{ + "$id": "https://flagd.dev/schema/v0/flags.json", + "$schema": "http://json-schema.org/draft-07/schema#", + "$ref": "#/definitions/providerConfig", + "definitions": { + "flagsMap": { + "title": "Flags", + "description": "Top-level flags object. All flags are defined here.", + "type": "object", + "$comment": "flag objects are one of the 4 flag types defined in definitions", + "additionalProperties": false, + "patternProperties": { + "^.{1,}$": { + "$ref": "#/definitions/anyFlag" + } + } + }, + "flagsArray": { + "title": "Flags", + "description": "Top-level flags array. All flags are defined here.", + "type": "array", + "items": { + "allOf": [ + { + "$ref": "#/definitions/anyFlag" + }, + { + "type": "object", + "properties": { + "key": { + "description": "Key of the flag: uniquely identifies this flag within it's flagSet", + "type": "string", + "minLength": 1 + } + }, + "required": [ + "key" + ] + } + ] + } + }, + "baseConfig": { + "title": "flagd Flag Configuration", + "description": "Defines flags for use in flagd providers, including typed variants and rules.", + "type": "object", + "properties": { + "$evaluators": { + "title": "Evaluators", + "description": "Reusable targeting rules that can be referenced with \"$ref\": \"myRule\" in multiple flags.", + "type": "object", + "additionalProperties": false, + "patternProperties": { + "^.{1,}$": { + "$comment": "this relative ref means that targeting.json MUST be in the same dir, or available on the same HTTP path", + "$ref": "./targeting.json" + } + } + }, + "metadata": { + "title": "Flag Set Metadata", + "description": "Metadata about the flag set, with keys of type string, and values of type boolean, string, or number.", + "properties": { + "flagSetId": { + "description": "The unique identifier for the flag set.", + "type": "string" + }, + "version": { + "description": "The version of the flag set.", + "type": "string" + } + }, + "$ref": "#/definitions/metadata" + } + } + }, + "providerConfig": { + "description": "Defines flags for use in providers (not flagd), including typed variants and rules.", + "type": "object", + "allOf": [ + { + "$ref": "#/definitions/baseConfig" + } + ], + "properties": { + "flags": { + "$ref": "#/definitions/flagsMap" + } + }, + "required": [ + "flags" + ] + }, + "flagdConfig": { + "description": "Defines flags for use in the flagd daemon (a superset of what's available in providers), including typed variants and rules. Flags can be defined as an array or an object.", + "type": "object", + "allOf": [ + { + "$ref": "#/definitions/baseConfig" + }, + { + "properties": { + "flags": { + "oneOf": [ + { + "$ref": "#/definitions/flagsMap" + }, + { + "$ref": "#/definitions/flagsArray" + } + ] + } + } + } + ], + "required": [ + "flags" + ] + }, + "baseFlag": { + "$comment": "base flag object; no title/description here, allows for better UX, keep it in the overrides", + "type": "object", + "properties": { + "state": { + "title": "Flag State", + "description": "Indicates whether the flag is functional. Disabled flags are treated as if they don't exist.", + "type": "string", + "enum": [ + "ENABLED", + "DISABLED" + ] + }, + "defaultVariant": { + "title": "Default Variant", + "description": "The variant to serve if no dynamic targeting applies (including if the targeting returns null). Set to null to use code-defined default.", + "type": [ + "string", + "null" + ] + }, + "targeting": { + "$ref": "./targeting.json" + }, + "metadata": { + "title": "Flag Metadata", + "description": "Metadata about an individual feature flag, with keys of type string, and values of type boolean, string, or number.", + "$ref": "#/definitions/metadata" + }, + "variants": { + "type": "object", + "minProperties": 1, + "additionalProperties": false, + "patternProperties": { + "^.{1,}$": {} + } + } + }, + "required": [ + "state", + "variants" + ] + }, + "booleanVariants": { + "type": "object", + "properties": { + "variants": { + "type": "object", + "additionalProperties": false, + "patternProperties": { + "^.{1,}$": { + "type": "boolean" + } + } + } + } + }, + "stringVariants": { + "type": "object", + "properties": { + "variants": { + "type": "object", + "additionalProperties": false, + "patternProperties": { + "^.{1,}$": { + "type": "string" + } + } + } + } + }, + "numberVariants": { + "type": "object", + "properties": { + "variants": { + "type": "object", + "additionalProperties": false, + "patternProperties": { + "^.{1,}$": { + "type": "number" + } + } + } + } + }, + "objectVariants": { + "type": "object", + "properties": { + "variants": { + "type": "object", + "additionalProperties": false, + "patternProperties": { + "^.{1,}$": { + "type": "object" + } + } + } + } + }, + "anyFlag": { + "anyOf": [ + { + "$ref": "#/definitions/booleanFlag" + }, + { + "$ref": "#/definitions/numberFlag" + }, + { + "$ref": "#/definitions/stringFlag" + }, + { + "$ref": "#/definitions/objectFlag" + } + ] + }, + "booleanFlag": { + "$comment": "merge the variants with the base flag to build our typed flags", + "title": "Boolean flag", + "description": "A flag having boolean values.", + "allOf": [ + { + "$ref": "#/definitions/baseFlag" + }, + { + "$ref": "#/definitions/booleanVariants" + } + ] + }, + "stringFlag": { + "title": "String flag", + "description": "A flag having string values.", + "allOf": [ + { + "$ref": "#/definitions/baseFlag" + }, + { + "$ref": "#/definitions/stringVariants" + } + ] + }, + "numberFlag": { + "title": "Numeric flag", + "description": "A flag having numeric values.", + "allOf": [ + { + "$ref": "#/definitions/baseFlag" + }, + { + "$ref": "#/definitions/numberVariants" + } + ] + }, + "objectFlag": { + "title": "Object flag", + "description": "A flag having arbitrary object values.", + "allOf": [ + { + "$ref": "#/definitions/baseFlag" + }, + { + "$ref": "#/definitions/objectVariants" + } + ] + }, + "metadata": { + "type": "object", + "additionalProperties": { + "type": [ + "string", + "number", + "boolean" + ] + } + } + } +} diff --git a/scripts/validate-feature-flags/schema/targeting.json b/scripts/validate-feature-flags/schema/targeting.json new file mode 100644 index 0000000000..f8dd7544fa --- /dev/null +++ b/scripts/validate-feature-flags/schema/targeting.json @@ -0,0 +1,592 @@ +{ + "$id": "https://flagd.dev/schema/v0/targeting.json", + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "flagd Targeting", + "description": "Defines targeting logic for flagd; a extension of JSONLogic, including purpose-built feature-flagging operations. Note that this schema applies to top-level objects; no additional properties are supported, including \"$schema\", which means built-in JSON-schema support is not possible in editors. Please use flags.json (which imports this schema) for a rich editor experience.", + "type": "object", + "anyOf": [ + { + "$comment": "we need this to support empty targeting", + "type": "object", + "additionalProperties": false, + "properties": {} + }, + { + "$ref": "#/definitions/anyRule" + } + ], + "definitions": { + "primitive": { + "oneOf": [ + { + "description": "When returned from rules, a null value \"exits\", the targeting, and the \"defaultValue\" is returned, with the reason indicating the targeting did not match.", + "type": "null" + }, + { + "description": "When returned from rules, booleans are converted to strings (\"true\"/\"false\"), and used to as keys to retrieve the associated value from the \"variants\" object. Be sure that the returned string is present as a key in the variants!", + "type": "boolean" + }, + { + "description": "When returned from rules, the behavior of numbers is not defined.", + "type": "number" + }, + { + "description": "When returned from rules, strings are used to as keys to retrieve the associated value from the \"variants\" object. Be sure that the returned string is present as a key in the variants!.", + "type": "string" + }, + { + "description": "When returned from rules, the behavior of arrays is not defined.", + "type": "array" + } + ] + }, + "varRule": { + "title": "Var Operation", + "description": "Retrieve data from the provided data object.", + "type": "object", + "additionalProperties": false, + "properties": { + "var": { + "anyOf": [ + { + "type": "string", + "description": "flagd automatically injects \"$flagd.timestamp\" (unix epoch) and \"$flagd.flagKey\" (the key of the flag in evaluation) into the context.", + "pattern": "^\\$flagd\\.((timestamp)|(flagKey))$" + }, + { + "not": { + "$comment": "this is a negated (not) match of \"$flagd.{some-key}\", which is faster and more compatible that a negative lookahead regex", + "type": "string", + "description": "flagd automatically injects \"$flagd.timestamp\" (unix epoch) and \"$flagd.flagKey\" (the key of the flag in evaluation) into the context.", + "pattern": "^\\$flagd\\..*$" + } + }, + { + "type": "array", + "$comment": "this is to support the form of var with a default... there seems to be a bug here, where ajv gives a warning (not an error) because maxItems doesn't equal the number of entries in items, though this is valid in this case", + "minItems": 1, + "items": [ + { + "type": "string" + } + ], + "additionalItems": { + "anyOf": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "string" + }, + { + "type": "number" + } + ] + } + } + ] + } + } + }, + "missingRule": { + "title": "Missing Operation", + "description": "Takes an array of data keys to search for (same format as var). Returns an array of any keys that are missing from the data object, or an empty array.", + "type": "object", + "additionalProperties": false, + "properties": { + "missing": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "missingSomeRule": { + "title": "Missing-Some Operation", + "description": "Takes a minimum number of data keys that are required, and an array of keys to search for (same format as var or missing). Returns an empty array if the minimum is met, or an array of the missing keys otherwise.", + "type": "object", + "additionalProperties": false, + "properties": { + "missing_some": { + "minItems": 2, + "maxItems": 2, + "type": "array", + "items": [ + { + "type": "number" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + } + } + }, + "binaryOrTernaryOp": { + "type": "array", + "minItems": 2, + "maxItems": 3, + "items": { + "$ref": "#/definitions/args" + } + }, + "binaryOrTernaryRule": { + "type": "object", + "additionalProperties": false, + "properties": { + "substr": { + "title": "Substring Operation", + "description": "Get a portion of a string. Give a positive start position to return everything beginning at that index. Give a negative start position to work backwards from the end of the string, then return everything. Give a positive length to express how many characters to return.", + "$ref": "#/definitions/binaryOrTernaryOp" + }, + "<": { + "title": "Less-Than/Between Operation. Can be used to test that one value is between two others.", + "$ref": "#/definitions/binaryOrTernaryOp" + }, + "<=": { + "title": "Less-Than-Or-Equal-To/Between Operation. Can be used to test that one value is between two others.", + "$ref": "#/definitions/binaryOrTernaryOp" + } + } + }, + "binaryOp": { + "type": "array", + "minItems": 2, + "maxItems": 2, + "items": { + "$ref": "#/definitions/args" + } + }, + "binaryRule": { + "title": "Binary Operation", + "description": "Any primitive JSONLogic operation with 2 operands.", + "type": "object", + "additionalProperties": false, + "properties": { + "if": { + "title": "If Operator", + "description": "The if statement takes 1 or more arguments: a condition (\"if\"), what to do if its true (\"then\", optional, defaults to returning true), and what to do if its false (\"else\", optional, defaults to returning false). Note that the else condition can be used as an else-if statement by adding additional arguments.", + "$ref": "#/definitions/variadicOp" + }, + "==": { + "title": "Lose Equality Operation", + "description": "Tests equality, with type coercion. Requires two arguments.", + "$ref": "#/definitions/binaryOp" + }, + "===": { + "title": "Strict Equality Operation", + "description": "Tests strict equality. Requires two arguments.", + "$ref": "#/definitions/binaryOp" + }, + "!=": { + "title": "Lose Inequality Operation", + "description": "Tests not-equal, with type coercion.", + "$ref": "#/definitions/binaryOp" + }, + "!==": { + "title": "Strict Inequality Operation", + "description": "Tests strict not-equal.", + "$ref": "#/definitions/binaryOp" + }, + ">": { + "title": "Greater-Than Operation", + "$ref": "#/definitions/binaryOp" + }, + ">=": { + "title": "Greater-Than-Or-Equal-To Operation", + "$ref": "#/definitions/binaryOp" + }, + "%": { + "title": "Modulo Operation", + "description": "Finds the remainder after the first argument is divided by the second argument.", + "$ref": "#/definitions/binaryOp" + }, + "/": { + "title": "Division Operation", + "$ref": "#/definitions/binaryOp" + }, + "map": { + "title": "Map Operation", + "description": "Perform an action on every member of an array. Note, that inside the logic being used to map, var operations are relative to the array element being worked on.", + "$ref": "#/definitions/binaryOp" + }, + "filter": { + "title": "Filter Operation", + "description": "Keep only elements of the array that pass a test. Note, that inside the logic being used to filter, var operations are relative to the array element being worked on.", + "$ref": "#/definitions/binaryOp" + }, + "all": { + "title": "All Operation", + "description": "Perform a test on each member of that array, returning true if all pass. Inside the test code, var operations are relative to the array element being tested.", + "$ref": "#/definitions/binaryOp" + }, + "none": { + "title": "None Operation", + "description": "Perform a test on each member of that array, returning true if none pass. Inside the test code, var operations are relative to the array element being tested.", + "$ref": "#/definitions/binaryOp" + }, + "some": { + "title": "Some Operation", + "description": "Perform a test on each member of that array, returning true if some pass. Inside the test code, var operations are relative to the array element being tested.", + "$ref": "#/definitions/binaryOp" + }, + "in": { + "title": "In Operation", + "description": "If the second argument is an array, tests that the first argument is a member of the array.", + "$ref": "#/definitions/binaryOp" + } + } + }, + "reduceRule": { + "type": "object", + "additionalProperties": false, + "properties": { + "reduce": { + "title": "Reduce Operation", + "description": "Combine all the elements in an array into a single value, like adding up a list of numbers. Note, that inside the logic being used to reduce, var operations only have access to an object with a \"current\" and a \"accumulator\".", + "type": "array", + "minItems": 3, + "maxItems": 3, + "items": { + "$ref": "#/definitions/args" + } + } + } + }, + "associativeOp": { + "type": "array", + "minItems": 2, + "items": { + "$ref": "#/definitions/args" + } + }, + "associativeRule": { + "title": "Mathematically Associative Operation", + "description": "Operation applicable to 2 or more parameters.", + "type": "object", + "additionalProperties": false, + "properties": { + "*": { + "title": "Multiplication Operation", + "description": "Multiplication; associative, will accept and unlimited amount of arguments.", + "$ref": "#/definitions/associativeOp" + } + } + }, + "unaryOp": { + "anyOf": [ + { + "type": "array", + "minItems": 1, + "maxItems": 1, + "items": { + "$ref": "#/definitions/args" + } + }, + { + "$ref": "#/definitions/args" + } + ] + }, + "unaryRule": { + "title": "Unary Operation", + "description": "Any primitive JSONLogic operation with 1 operands.", + "type": "object", + "additionalProperties": false, + "properties": { + "!": { + "title": "Negation Operation", + "description": "Logical negation (“not”). Takes just one argument.", + "$ref": "#/definitions/unaryOp" + }, + "!!": { + "title": "Double Negation Operation", + "description": "Double negation, or 'cast to a boolean'. Takes a single argument.", + "$ref": "#/definitions/unaryOp" + } + } + }, + "variadicOp": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/definitions/args" + } + }, + "variadicRule": { + "$comment": "note < and <= can be used with up to 3 ops (between)", + "type": "object", + "additionalProperties": false, + "properties": { + "or": { + "title": "Or Operation", + "description": "Simple boolean test, with 1 or more arguments. At a more sophisticated level, \"or\" returns the first truthy argument, or the last argument.", + "$ref": "#/definitions/variadicOp" + }, + "and": { + "title": "", + "description": "Simple boolean test, with 1 or more arguments. At a more sophisticated level, \"and\" returns the first falsy argument, or the last argument.", + "$ref": "#/definitions/variadicOp" + }, + "+": { + "title": "Addition Operation", + "description": "Addition; associative, will accept and unlimited amount of arguments.", + "$ref": "#/definitions/variadicOp" + }, + "-": { + "title": "Subtraction Operation", + "$ref": "#/definitions/variadicOp" + }, + "max": { + "title": "Maximum Operation", + "description": "Return the maximum from a list of values.", + "$ref": "#/definitions/variadicOp" + }, + "min": { + "title": "Minimum Operation", + "description": "Return the minimum from a list of values.", + "$ref": "#/definitions/variadicOp" + }, + "merge": { + "title": "Merge Operation", + "description": "Takes one or more arrays, and merges them into one array. If arguments aren't arrays, they get cast to arrays.", + "$ref": "#/definitions/variadicOp" + }, + "cat": { + "title": "Concatenate Operation", + "description": "Concatenate all the supplied arguments. Note that this is not a join or implode operation, there is no “glue” string.", + "$ref": "#/definitions/variadicOp" + } + } + }, + "stringCompareArg": { + "oneOf": [ + { + "type": "string" + }, + { + "$ref": "#/definitions/anyRule" + } + ] + }, + "stringCompareArgs": { + "type": "array", + "minItems": 2, + "maxItems": 2, + "items": { + "$ref": "#/definitions/stringCompareArg" + } + }, + "stringCompareRule": { + "type": "object", + "additionalProperties": false, + "properties": { + "starts_with": { + "title": "Starts-With Operation", + "description": "The string attribute starts with the specified string value.", + "$ref": "#/definitions/stringCompareArgs" + }, + "ends_with": { + "title": "Ends-With Operation", + "description": "The string attribute ends with the specified string value.", + "$ref": "#/definitions/stringCompareArgs" + } + } + }, + "semVerString": { + "title": "Semantic Version String", + "description": "A string representing a valid semantic version expression as per https://semver.org/.", + "type": "string", + "pattern": "^(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)(?:-((?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\\.(?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\\+([0-9a-zA-Z-]+(?:\\.[0-9a-zA-Z-]+)*))?$" + }, + "ruleSemVer": { + "type": "object", + "additionalProperties": false, + "properties": { + "sem_ver": { + "title": "Semantic Version Operation", + "description": "Attribute matches a semantic version condition. Accepts \"npm-style\" range specifiers: \"=\", \"!=\", \">\", \"<\", \">=\", \"<=\", \"~\" (match minor version), \"^\" (match major version).", + "type": "array", + "minItems": 3, + "maxItems": 3, + "items": [ + { + "oneOf": [ + { + "$ref": "#/definitions/semVerString" + }, + { + "$ref": "#/definitions/varRule" + } + ] + }, + { + "description": "Range specifiers: \"=\", \"!=\", \">\", \"<\", \">=\", \"<=\", \"~\" (match minor version), \"^\" (match major version).", + "enum": [ + "=", + "!=", + ">", + "<", + ">=", + "<=", + "~", + "^" + ] + }, + { + "oneOf": [ + { + "$ref": "#/definitions/semVerString" + }, + { + "$ref": "#/definitions/varRule" + } + ] + } + ] + } + } + }, + "fractionalWeightArg": { + "description": "Distribution for all possible variants, with their associated weighting.", + "type": "array", + "minItems": 1, + "maxItems": 2, + "items": [ + { + "description": "If this bucket is randomly selected, this JSONLogic will be evaluated, and the result will be used as the variant key to return from the variants map.", + "$ref": "#/definitions/args" + }, + { + "description": "Weighted distribution for this variant key. Must be a non-negative integer. Can be a JSONLogic expression that evaluates to a number (e.g. for time-based progressive rollouts); computed negative weights are clamped to 0 at evaluation time. The total weight sum across all variants must not exceed 2,147,483,647.", + "oneOf": [ + { + "type": "integer", + "minimum": 0 + }, + { + "$ref": "#/definitions/anyRule" + } + ] + } + ] + }, + "fractionalOp": { + "type": "array", + "minItems": 1, + "$comment": "there seems to be a bug here, where ajv gives a warning (not an error) because maxItems doesn't equal the number of entries in items, though this is valid in this case", + "items": [ + { + "description": "Bucketing value used in pseudorandom assignment; should be a string that is unique and stable for each subject of flag evaluation. Defaults to a concatenation of the flagKey and targetingKey.", + "$ref": "#/definitions/anyRule" + }, + { + "$ref": "#/definitions/fractionalWeightArg" + }, + { + "$ref": "#/definitions/fractionalWeightArg" + } + ], + "additionalItems": { + "$ref": "#/definitions/fractionalWeightArg" + } + }, + "fractionalShorthandOp": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/definitions/fractionalWeightArg" + } + }, + "fractionalRule": { + "type": "object", + "additionalProperties": false, + "properties": { + "fractional": { + "title": "Fractional Operation", + "description": "Deterministic, pseudorandom fractional distribution.", + "oneOf": [ + { + "$ref": "#/definitions/fractionalOp" + }, + { + "$ref": "#/definitions/fractionalShorthandOp" + } + ] + } + } + }, + "reference": { + "additionalProperties": false, + "type": "object", + "$comment": "patternProperties here is a bit of a hack to prevent this definition from being dereferenced early.", + "patternProperties": { + "^\\$ref$": { + "title": "Reference", + "description": "A reference to another entity, used for $evaluators (shared rules).", + "type": "string" + } + } + }, + "args": { + "oneOf": [ + { + "$ref": "#/definitions/reference" + }, + { + "$ref": "#/definitions/anyRule" + }, + { + "$ref": "#/definitions/primitive" + } + ] + }, + "anyRule": { + "anyOf": [ + { + "$ref": "#/definitions/varRule" + }, + { + "$ref": "#/definitions/missingRule" + }, + { + "$ref": "#/definitions/missingSomeRule" + }, + { + "$ref": "#/definitions/binaryRule" + }, + { + "$ref": "#/definitions/binaryOrTernaryRule" + }, + { + "$ref": "#/definitions/associativeRule" + }, + { + "$ref": "#/definitions/unaryRule" + }, + { + "$ref": "#/definitions/variadicRule" + }, + { + "$ref": "#/definitions/reduceRule" + }, + { + "$ref": "#/definitions/stringCompareRule" + }, + { + "$ref": "#/definitions/ruleSemVer" + }, + { + "$ref": "#/definitions/fractionalRule" + } + ] + } + } +}