### 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 -->
273 lines
7.2 KiB
Go
273 lines
7.2 KiB
Go
package main
|
|
|
|
import (
|
|
"bufio"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// TestYAMLLS validates the fixtures against a real yaml-language-server, the actual
|
|
// editor target, so the generated schema is checked with the same engine users see.
|
|
// It's gated behind YAMLLS_TEST=1 because it needs the yamlls binary (a node
|
|
// program). Set YAMLLS_BIN to point at the binary if it isn't on PATH.
|
|
//
|
|
// The schema is attached per-file with a modeline. Only Error-severity (schema)
|
|
// diagnostics are counted, so a deprecation hint on a valid file doesn't fail it.
|
|
func TestYAMLLS(t *testing.T) {
|
|
if os.Getenv("YAMLLS_TEST") == "" {
|
|
t.Skip("set YAMLLS_TEST=1 to validate fixtures against a real yaml-language-server")
|
|
}
|
|
bin := os.Getenv("YAMLLS_BIN")
|
|
if bin == "" {
|
|
path, err := exec.LookPath("yaml-language-server")
|
|
if err != nil {
|
|
t.Fatal("YAMLLS_TEST is set but yaml-language-server is not on PATH; add it to PATH or set YAMLLS_BIN")
|
|
}
|
|
bin = path
|
|
}
|
|
schemaPath, err := filepath.Abs(schemaFile)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
yamlls := startYAMLLS(t, bin)
|
|
defer yamlls.close()
|
|
|
|
run := func(dir string, wantErrors bool) {
|
|
files, _ := filepath.Glob(filepath.Join("testdata", dir, "*.yml"))
|
|
for _, file := range files {
|
|
t.Run(dir+"/"+filepath.Base(file), func(t *testing.T) {
|
|
content, err := os.ReadFile(file)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
doc := "# yaml-language-server: $schema=" + schemaPath + "\n" + string(content)
|
|
errs := yamlls.diagnose(t, doc, wantErrors)
|
|
switch {
|
|
case wantErrors && errs == 0:
|
|
t.Errorf("%s: expected yamlls schema errors, got none", file)
|
|
case !wantErrors && errs > 0:
|
|
t.Errorf("%s: expected no yamlls schema errors, got %d", file, errs)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
run("valid", false)
|
|
run("invalid", true)
|
|
}
|
|
|
|
type yamllsClient struct {
|
|
cmd *exec.Cmd
|
|
in io.WriteCloser
|
|
mu sync.Mutex // serializes writes (main + auto-responses from readLoop)
|
|
msgs chan map[string]any
|
|
id int
|
|
uri int
|
|
}
|
|
|
|
func startYAMLLS(t *testing.T, bin string) *yamllsClient {
|
|
t.Helper()
|
|
cmd := exec.Command(bin, "--stdio")
|
|
cmd.Stderr = os.Stderr
|
|
in, err := cmd.StdinPipe()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
out, err := cmd.StdoutPipe()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := cmd.Start(); err != nil {
|
|
t.Fatalf("start yaml-language-server: %v", err)
|
|
}
|
|
c := &yamllsClient{cmd: cmd, in: in, msgs: make(chan map[string]any, 64)}
|
|
go c.readLoop(out)
|
|
|
|
c.request(t, "initialize", map[string]any{
|
|
"processId": nil,
|
|
"rootUri": nil,
|
|
"capabilities": map[string]any{
|
|
"textDocument": map[string]any{"publishDiagnostics": map[string]any{}},
|
|
},
|
|
})
|
|
c.notify(t, "initialized", map[string]any{})
|
|
return c
|
|
}
|
|
|
|
func (c *yamllsClient) close() {
|
|
_ = c.in.Close()
|
|
_ = c.cmd.Process.Kill()
|
|
_ = c.cmd.Wait()
|
|
}
|
|
|
|
func (c *yamllsClient) write(t *testing.T, m map[string]any) {
|
|
t.Helper()
|
|
b, _ := json.Marshal(m)
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
if _, err := fmt.Fprintf(c.in, "Content-Length: %d\r\n\r\n%s", len(b), b); err != nil {
|
|
t.Fatalf("write lsp message: %v", err)
|
|
}
|
|
}
|
|
|
|
func (c *yamllsClient) notify(t *testing.T, method string, params any) {
|
|
c.write(t, map[string]any{"jsonrpc": "2.0", "method": method, "params": params})
|
|
}
|
|
|
|
func (c *yamllsClient) request(t *testing.T, method string, params any) {
|
|
t.Helper()
|
|
c.id++
|
|
id := c.id
|
|
c.write(t, map[string]any{"jsonrpc": "2.0", "id": id, "method": method, "params": params})
|
|
timeout := time.After(15 * time.Second)
|
|
for {
|
|
select {
|
|
case m, ok := <-c.msgs:
|
|
if !ok {
|
|
t.Fatalf("yamlls closed while waiting for %s", method)
|
|
}
|
|
// A response has an id and no method.
|
|
if _, hasMethod := m["method"]; !hasMethod && idOf(m) == id {
|
|
return
|
|
}
|
|
case <-timeout:
|
|
t.Fatalf("timeout waiting for %s response", method)
|
|
}
|
|
}
|
|
}
|
|
|
|
// diagnose opens a fresh document and returns how many Error-severity diagnostics
|
|
// yamlls publishes for it. wantErrors reflects the caller's expectation: yamlls often
|
|
// publishes an empty set on didOpen before the schema loads, so when errors are
|
|
// expected an empty set is treated as premature and diagnose keeps waiting for the
|
|
// real one rather than finalizing early (which would let an invalid fixture pass).
|
|
func (c *yamllsClient) diagnose(t *testing.T, doc string, wantErrors bool) int {
|
|
t.Helper()
|
|
c.uri++
|
|
uri := fmt.Sprintf("file:///tmp/gitops-schema-test-%d.yaml", c.uri)
|
|
c.notify(t, "textDocument/didOpen", map[string]any{
|
|
"textDocument": map[string]any{"uri": uri, "languageId": "yaml", "version": 1, "text": doc},
|
|
})
|
|
|
|
var latest []any
|
|
published := false
|
|
hard := time.After(15 * time.Second)
|
|
for {
|
|
var quiet <-chan time.Time
|
|
// Start the quiet countdown only once the result is trustworthy: a valid
|
|
// file's empty set is authoritative immediately, but when errors are expected
|
|
// only a set that actually contains errors is.
|
|
if published && (!wantErrors || countErrors(latest) > 0) {
|
|
quiet = time.After(1 * time.Second)
|
|
}
|
|
select {
|
|
case m, ok := <-c.msgs:
|
|
if !ok {
|
|
t.Fatal("yamlls closed while waiting for diagnostics")
|
|
}
|
|
if m["method"] == "textDocument/publishDiagnostics" {
|
|
if p, _ := m["params"].(map[string]any); p != nil && p["uri"] == uri {
|
|
latest, _ = p["diagnostics"].([]any)
|
|
published = true
|
|
}
|
|
}
|
|
case <-quiet:
|
|
return countErrors(latest)
|
|
case <-hard:
|
|
if !published {
|
|
t.Fatal("timed out waiting for yamlls diagnostics")
|
|
}
|
|
return countErrors(latest)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (c *yamllsClient) readLoop(r io.Reader) {
|
|
br := bufio.NewReader(r)
|
|
for {
|
|
length := 0
|
|
for {
|
|
line, err := br.ReadString('\n')
|
|
if err != nil {
|
|
close(c.msgs)
|
|
return
|
|
}
|
|
line = strings.TrimRight(line, "\r\n")
|
|
if line == "" {
|
|
break
|
|
}
|
|
if strings.HasPrefix(strings.ToLower(line), "content-length:") {
|
|
length, _ = strconv.Atoi(strings.TrimSpace(line[len("content-length:"):]))
|
|
}
|
|
}
|
|
if length == 0 {
|
|
continue
|
|
}
|
|
body := make([]byte, length)
|
|
if _, err := io.ReadFull(br, body); err != nil {
|
|
close(c.msgs)
|
|
return
|
|
}
|
|
var m map[string]any
|
|
if json.Unmarshal(body, &m) != nil {
|
|
continue
|
|
}
|
|
// Auto-respond to server->client requests (method + id) so yamlls doesn't
|
|
// block. Forward responses and notifications to the channel.
|
|
if _, hasMethod := m["method"]; hasMethod {
|
|
if _, hasID := m["id"]; hasID {
|
|
c.respond(m)
|
|
continue
|
|
}
|
|
}
|
|
c.msgs <- m
|
|
}
|
|
}
|
|
|
|
// respond returns a null (or empty-array for workspace/configuration) result to a
|
|
// server-initiated request.
|
|
func (c *yamllsClient) respond(req map[string]any) {
|
|
var result any
|
|
if req["method"] == "workspace/configuration" {
|
|
items := 0
|
|
if p, _ := req["params"].(map[string]any); p != nil {
|
|
if arr, _ := p["items"].([]any); arr != nil {
|
|
items = len(arr)
|
|
}
|
|
}
|
|
result = make([]any, items)
|
|
}
|
|
b, _ := json.Marshal(map[string]any{"jsonrpc": "2.0", "id": req["id"], "result": result})
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
_, _ = fmt.Fprintf(c.in, "Content-Length: %d\r\n\r\n%s", len(b), b)
|
|
}
|
|
|
|
func idOf(m map[string]any) int {
|
|
if f, ok := m["id"].(float64); ok {
|
|
return int(f)
|
|
}
|
|
return -1
|
|
}
|
|
|
|
func countErrors(diags []any) int {
|
|
count := 0
|
|
for _, diag := range diags {
|
|
if fields, ok := diag.(map[string]any); ok {
|
|
if sev, ok := fields["severity"].(float64); ok && sev == 1 {
|
|
count++
|
|
}
|
|
}
|
|
}
|
|
return count
|
|
}
|