Files
fleet/tools/gitops-auto-complete/schema_test.go
T
Jonathan Katz b99e5564e9 GitOps schema generator and IDE integration (#49717)
### Summary:
Generates a json schema for valid GitOps yaml files, to be used with
[yaml-language-server](https://github.com/redhat-developer/yaml-language-server)
for IDE integration.

This PR includes the actual generated file, so it can be used without
running the tool. All files are in `/tools/gitops-autto-complete`, so
nothing else gets affected.

#### What it adds:
- Complete json schema that defines valid GitOps yaml files and can be
integrated with
[yaml-language-server](https://github.com/redhat-developer/yaml-language-server).
- Auto-completion, error checking, type checking, descriptions.
- Defines all keys for osquery options/flags (based on
`server/fleet/agent_options_generated.go`).
- Additional validation: required keys, strings that must be enclosed in
quotation marks, path support.
- Additional data: descriptions from code comments, notices for fields
that don't reset if null or empty.

#### Limitations:
- Some structs and data are duplicated into the tool and will inevitebly
mismatch over time, because the structs used for gitops are not
sufficient for the schema generation:
- Some fields use an interface/any type that so can't be used for the
schema generation.
- Some important details are not encoded in the type or struct tags for
gitops fields at all.
- Some details (like required fields) are encoded in the Validate()
interface, but the IDE integration cannot run Go code.
- Doesn't work with all yaml file types used for gitops (like a yaml
file that specifies multiple software packages), only the default/fleet
level files. This will require having a subsection of the schema for
each type of file, and some way to detect what it actually is (maybe
specifying the schema in the file itself).
- Requires manual setup to integrate with IDE, it's not an easy to use
extension currently.

#### Dependencies:
- `invopop/jsonschema` reflects Fleet's GitOps structs into the schema.
- `santhosh-tekuri/jsonschema/v6` validates the test fixtures against
that schema.
- `ghodss/yaml` decodes the fixture YAML the way fleetctl does.
 


https://github.com/user-attachments/assets/b6ffacd0-e602-41a2-b46a-7d10300c6ea5



## Testing

- [x] QA'd all new/changed functionality manually
- I have been using and working on this for the past week so it's in a
pretty good state, but some descriptions or keys are probably still
missing.


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **New Features**
* Added GitOps YAML auto-completion powered by a comprehensive JSON
Schema.
* Added validation for GitOps configuration structure, supported fields,
data types, required combinations, and unknown keys.
* Added support for external file references using `path` and `paths` in
supported sections.
* Added clearer guidance for deprecated fields and special field
behaviors.
* **Bug Fixes**
* Improved detection of incorrectly typed values and invalid
configuration shapes.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-07-28 11:50:50 -04:00

334 lines
10 KiB
Go

package main
import (
"bytes"
"encoding/json"
"os"
"os/exec"
"path/filepath"
"reflect"
"strings"
"testing"
"github.com/fleetdm/fleet/v4/pkg/spec"
ghodss "github.com/ghodss/yaml"
"github.com/santhosh-tekuri/jsonschema/v6"
)
const schemaFile = "generated-schema.json"
// compileSchema loads the committed schema and compiles it. The test validates
// against the committed artifact. TestSchemaUpToDate separately guarantees that
// artifact matches what the generator currently produces.
func compileSchema(t *testing.T) *jsonschema.Schema {
t.Helper()
b, err := os.ReadFile(schemaFile)
if err != nil {
t.Fatalf("read %s: %v", schemaFile, err)
}
doc, err := jsonschema.UnmarshalJSON(bytes.NewReader(b))
if err != nil {
t.Fatalf("parse schema: %v", err)
}
c := jsonschema.NewCompiler()
if err := c.AddResource("schema.json", doc); err != nil {
t.Fatalf("add schema resource: %v", err)
}
schema, err := c.Compile("schema.json")
if err != nil {
t.Fatalf("compile schema: %v", err)
}
return schema
}
// loadInstance reads a YAML fixture and decodes it into the JSON-compatible value
// the validator expects (ghodss converts YAML->JSON so numbers/bools are typed).
func loadInstance(t *testing.T, path string) any {
t.Helper()
y, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read %s: %v", path, err)
}
j, err := ghodss.YAMLToJSON(y)
if err != nil {
t.Fatalf("yaml->json %s: %v", path, err)
}
inst, err := jsonschema.UnmarshalJSON(bytes.NewReader(j))
if err != nil {
t.Fatalf("parse %s: %v", path, err)
}
return inst
}
// TestValidFixtures asserts every comprehensive valid gitops file validates
// cleanly against the schema (so all the keys they use stay covered).
func TestValidFixtures(t *testing.T) {
schema := compileSchema(t)
files, err := filepath.Glob("testdata/valid/*.yml")
if err != nil || len(files) == 0 {
t.Fatalf("no valid fixtures found: %v", err)
}
for _, file := range files {
t.Run(filepath.Base(file), func(t *testing.T) {
if err := schema.Validate(loadInstance(t, file)); err != nil {
t.Errorf("expected %s to validate, got:\n%v", file, err)
}
})
}
}
// TestInvalidFixtures asserts the schema still rejects the specific mistakes the
// tool is designed to catch (unknown keys, wrong-typed required keys, an item
// missing its required key).
func TestInvalidFixtures(t *testing.T) {
schema := compileSchema(t)
files, err := filepath.Glob("testdata/invalid/*.yml")
if err != nil || len(files) == 0 {
t.Fatalf("no invalid fixtures found: %v", err)
}
for _, file := range files {
t.Run(filepath.Base(file), func(t *testing.T) {
if err := schema.Validate(loadInstance(t, file)); err == nil {
t.Errorf("expected %s to fail validation, but it passed", file)
}
})
}
}
// TestInvariants pins the post-processing that a refactor could silently break.
func TestInvariants(t *testing.T) {
b, err := os.ReadFile(schemaFile)
if err != nil {
t.Fatalf("read %s: %v", schemaFile, err)
}
var doc map[string]any
if err := json.Unmarshal(b, &doc); err != nil {
t.Fatalf("unmarshal schema: %v", err)
}
defs, _ := doc["$defs"].(map[string]any)
if defs == nil {
t.Fatal("schema has no $defs")
}
def := func(name string) map[string]any {
d, _ := defs[name].(map[string]any)
if d == nil {
t.Fatalf("missing $def %q", name)
}
return d
}
props := func(node map[string]any) map[string]any {
p, _ := node["properties"].(map[string]any)
return p
}
// Declarative notes: the exact set from declarativeExceptions must appear in
// the schema, once each. Counting by note string keeps this independent of the
// walker that placed them.
t.Run("declarative notes", func(t *testing.T) {
want := map[string]int{}
for _, note := range declarativeExceptions {
want[note]++
}
got := map[string]int{}
for _, desc := range collectDescriptions(doc) {
for note := range want {
if containsNote(desc, note) {
got[note]++
}
}
}
for note, count := range want {
if got[note] != count {
t.Errorf("note %q: want %d occurrence(s), got %d", note, count, got[note])
}
}
})
// Required keys: these defs gate on an anyOf of single-key branches.
t.Run("required keys", func(t *testing.T) {
for _, name := range []string{"SoftwarePackageSpec", "TeamSpecAppStoreApp", "MaintainedAppSpec"} {
if _, ok := def(name)["anyOf"].([]any); !ok {
t.Errorf("%s: expected an anyOf of required-key branches", name)
}
}
})
// Typed strict-string keys survive relaxNulls as strict strings.
t.Run("typed strict-string keys", func(t *testing.T) {
for def, keys := range map[string][]string{
"SoftwarePackageSpec": {"url", "hash_sha256"},
"TeamSpecAppStoreApp": {"app_store_id"},
"MaintainedAppSpec": {"slug"},
} {
p := props(defs[def].(map[string]any))
for _, key := range keys {
keyNode, _ := p[key].(map[string]any)
if keyNode["type"] != "string" {
t.Errorf("%s.%s: want type string, got %v", def, key, keyNode["type"])
}
}
}
})
// agent_options.config.options is populated and closed, while config stays open.
t.Run("config.options", func(t *testing.T) {
cfg, _ := props(def("AgentOptions"))["config"].(map[string]any)
if cfg == nil {
t.Fatal("AgentOptions.config missing")
}
opts, _ := props(cfg)["options"].(map[string]any)
if opts == nil {
t.Fatal("AgentOptions.config.options missing")
}
if opts["additionalProperties"] != false {
t.Errorf("options should be closed (additionalProperties:false), got %v", opts["additionalProperties"])
}
optKeys := props(opts)
if len(optKeys) == 0 {
t.Error("options has no properties")
}
// allow_unsafe comes from an embedded per-OS struct, so it guards embed handling.
if _, ok := optKeys["allow_unsafe"]; !ok {
t.Error("options missing embedded per-OS option 'allow_unsafe'")
}
if _, closed := cfg["additionalProperties"]; closed {
t.Error("config should stay open (no additionalProperties)")
}
})
// command_line_flags is populated and closed, including per-OS flags pulled up from
// the embedded structs (users_service_delay isn't in the base flag struct).
t.Run("command_line_flags", func(t *testing.T) {
clf, _ := props(def("AgentOptions"))["command_line_flags"].(map[string]any)
if clf == nil {
t.Fatal("AgentOptions.command_line_flags missing")
}
if clf["additionalProperties"] != false {
t.Errorf("command_line_flags should be closed (additionalProperties:false), got %v", clf["additionalProperties"])
}
flags := props(clf)
if _, ok := flags["verbose"]; !ok {
t.Error("command_line_flags missing base flag 'verbose'")
}
if _, ok := flags["users_service_delay"]; !ok {
t.Error("command_line_flags missing embedded per-OS flag 'users_service_delay'")
}
})
// Path refs: path-only defs carry `path` but not `paths`, and path+paths defs carry both.
t.Run("path refs", func(t *testing.T) {
for _, name := range []string{"ControlsWithTypes", "SoftwarePackageSpec"} {
p := props(def(name))
if _, ok := p["path"]; !ok {
t.Errorf("%s: missing 'path'", name)
}
if _, ok := p["paths"]; ok {
t.Errorf("%s: unexpected 'paths' on a path-only def", name)
}
}
for _, name := range []string{"GitOpsPolicySpec", "LabelSpec"} {
p := props(def(name))
if _, ok := p["path"]; !ok {
t.Errorf("%s: missing 'path'", name)
}
if _, ok := p["paths"]; !ok {
t.Errorf("%s: missing 'paths'", name)
}
}
})
// Rename aliases: old key deprecated, new key present and not deprecated.
t.Run("rename aliases", func(t *testing.T) {
p := props(def("ControlsWithTypes"))
old, _ := p["macos_setup"].(map[string]any)
if old["deprecated"] != true {
t.Errorf("macos_setup should be deprecated, got %v", old["deprecated"])
}
if _, ok := p["setup_experience"]; !ok {
t.Error("setup_experience alias missing")
}
})
}
// TestSchemaUpToDate regenerates the schema and asserts the committed file matches,
// so a refactor that changes the output is caught (and the validation/invariant
// tests above stay meaningful against the committed artifact).
func TestSchemaUpToDate(t *testing.T) {
tmp := filepath.Join(t.TempDir(), "out.json")
cmd := exec.Command("go", "run", ".", tmp)
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("go run . failed: %v\n%s", err, out)
}
got, err := os.ReadFile(tmp)
if err != nil {
t.Fatalf("read regenerated schema: %v", err)
}
want, err := os.ReadFile(schemaFile)
if err != nil {
t.Fatalf("read committed schema: %v", err)
}
if !bytes.Equal(got, want) {
t.Errorf("%s is stale; run `go run . %s` to update", schemaFile, schemaFile)
}
}
func collectDescriptions(node any) []string {
var out []string
switch node := node.(type) {
case map[string]any:
if desc, ok := node["description"].(string); ok {
out = append(out, desc)
}
for _, child := range node {
out = append(out, collectDescriptions(child)...)
}
case []any:
for _, child := range node {
out = append(out, collectDescriptions(child)...)
}
}
return out
}
func containsNote(desc string, note string) bool {
return bytes.Contains([]byte(desc), []byte(note))
}
// TestControlsKeysCoverSpec fails when spec.GitOpsControls gains a controls key that
// the hand-written ControlsWithTypes hasn't mirrored. It's the guard that would have
// caught the missing name_template. The embedded fleet.BaseItem's path/paths are added
// separately by addPathReferences, and the tag-less Defined field is internal, so both
// are excluded from the comparison.
func TestControlsKeysCoverSpec(t *testing.T) {
specKeys := jsonTagSet(reflect.TypeFor[spec.GitOpsControls]())
delete(specKeys, "path")
delete(specKeys, "paths")
toolKeys := jsonTagSet(reflect.TypeFor[ControlsWithTypes]())
for key := range specKeys {
if !toolKeys[key] {
t.Errorf("ControlsWithTypes is missing controls key %q from spec.GitOpsControls; add it", key)
}
}
}
// jsonTagSet returns the json key names of a struct, pulling names up through embedded
// structs and skipping fields with no json tag or "-".
func jsonTagSet(t reflect.Type) map[string]bool {
keys := map[string]bool{}
for field := range t.Fields() {
if field.Anonymous {
for key := range jsonTagSet(field.Type) {
keys[key] = true
}
continue
}
name, _, _ := strings.Cut(field.Tag.Get("json"), ",")
if name != "" && name != "-" {
keys[name] = true
}
}
return keys
}