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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
3 changes: 3 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down
6 changes: 6 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand All @@ -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=
Expand Down Expand Up @@ -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=
34 changes: 34 additions & 0 deletions scripts/validate-feature-flags/README.md
Original file line number Diff line number Diff line change
@@ -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.
214 changes: 214 additions & 0 deletions scripts/validate-feature-flags/main.go
Original file line number Diff line number Diff line change
@@ -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
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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)
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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)
}
}
Loading
Loading