### 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 -->
334 lines
10 KiB
Go
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
|
|
}
|