Files
fleet/tools/gitops-auto-complete/main.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

672 lines
20 KiB
Go

// Command gitops-auto-complete generates a JSON schema from Fleet's GitOps Go
// structs so editors (yamlls) can offer completion/validation for GitOps YAML.
package main
import (
"encoding/json"
"fmt"
"go/ast"
"go/parser"
"go/token"
"maps"
"os"
"path/filepath"
"reflect"
"runtime"
"strings"
"unicode"
"github.com/fleetdm/fleet/v4/pkg/spec"
"github.com/invopop/jsonschema"
)
// generatedOsqueryOptions is the Fleet-generated file defining the osqueryOptions and
// osqueryCommandLineFlags structs, which back config.options and command_line_flags.
const generatedOsqueryOptions = "server/fleet/agent_options_generated.go"
// agentOptionsBase holds the hand-written per-OS structs that both of those structs
// embed, so it's parsed alongside the generated file.
const agentOptionsBase = "server/fleet/agent_options.go"
func main() {
if len(os.Args) > 1 && (os.Args[1] == "-h" || os.Args[1] == "--help") {
fmt.Println(`Usage: gitops-auto-complete [output-file]
Generates a JSON schema from Fleet's GitOps structs for yaml-language-server.
With an output-file, writes the schema there; otherwise prints to stdout.`)
return
}
// Resolve Fleet source paths from this file's own location so the tool works
// from any working directory, not just the module root.
repoRoot := ""
if _, thisFile, _, ok := runtime.Caller(0); ok {
repoRoot = filepath.Join(filepath.Dir(thisFile), "..", "..")
}
renames := map[string]string{}
collectRenames(reflect.TypeFor[GitOpsSpec](), map[reflect.Type]bool{}, renames)
reflector := &jsonschema.Reflector{
RequiredFromJSONSchemaTags: true,
Mapper: typeMapper,
KeyNamer: toSnake,
// Inline the root struct's properties instead of hiding them behind a
// single top-level $ref, so yamlls offers root-level key completion.
ExpandedStruct: true,
}
addFleetGoComments(reflector, repoRoot)
raw, err := json.Marshal(reflector.Reflect(&GitOpsSpec{}))
if err != nil {
fmt.Fprintln(os.Stderr, "marshal schema:", err)
os.Exit(1)
}
var schemaKeys map[string]any
err = json.Unmarshal(raw, &schemaKeys)
if err != nil {
fmt.Fprintln(os.Stderr, "unmarshal schema:", err)
os.Exit(1)
}
// Merges run first so the injected keys get the same treatment as the rest. If a
// schema can't be built, generation continues without it.
osquerySources := []string{
filepath.Join(repoRoot, generatedOsqueryOptions),
filepath.Join(repoRoot, agentOptionsBase),
}
osqueryOptions, err := osqueryStructSchema("osqueryOptions", osquerySources...)
if err != nil {
fmt.Fprintln(os.Stderr, "warning: could not type config.options:", err)
}
mergeOsqueryOptions(schemaKeys, osqueryOptions)
commandLineFlags, err := osqueryStructSchema("osqueryCommandLineFlags", osquerySources...)
if err != nil {
fmt.Fprintln(os.Stderr, "warning: could not type command_line_flags:", err)
}
mergeCommandLineFlags(schemaKeys, commandLineFlags)
mergeMissingMDMKeys(schemaKeys, spec.GitOpsMDM{})
fixYaraRules(schemaKeys)
// Order matters. annotate and addGitOpsKeyNotes read types that relaxNulls
// later strips, so they run first. addPathReferences also runs before relaxNulls
// so the path keys it adds get relaxed too. typeStrictStringKeys runs after
// relaxNulls to restore the string types it drops.
nodes := collectNodes(schemaKeys)
annotate(nodes, renames)
addGitOpsKeyNotes(schemaKeys)
addPathReferences(schemaKeys)
addRequiredKeys(schemaKeys)
// Collect again so relaxNulls reaches the aliases and path keys added above.
nodes = collectNodes(schemaKeys)
relaxNulls(nodes)
typeStrictStringKeys(schemaKeys)
out, err := json.MarshalIndent(schemaKeys, "", " ")
if err != nil {
fmt.Fprintln(os.Stderr, "marshal schema:", err)
os.Exit(1)
}
if len(os.Args) <= 1 {
fmt.Println(string(out))
return
}
path := os.Args[1]
err = os.WriteFile(path, append(out, '\n'), 0o644)
if err != nil {
fmt.Fprintln(os.Stderr, "write file:", err)
os.Exit(1)
}
fmt.Fprintln(os.Stderr, "wrote", path)
}
// --- building the base schema from Go types ---
// addFleetGoComments pulls Fleet's Go doc comments into field descriptions (shown
// on hover). AddGoComments derives package paths from the walk dir relative to cwd,
// so it runs from the repo root and restores cwd afterward.
func addFleetGoComments(reflector *jsonschema.Reflector, repoRoot string) {
workingDir, err := os.Getwd()
if err != nil {
return
}
err = os.Chdir(repoRoot)
if err != nil {
return
}
defer func() { _ = os.Chdir(workingDir) }()
const base = "github.com/fleetdm/fleet/v4"
_ = reflector.AddGoComments(base, "server/fleet")
_ = reflector.AddGoComments(base, "pkg/spec")
}
// osqueryStructSchema parses one of Fleet's generated osquery structs (osqueryOptions
// for config.options, osqueryCommandLineFlags for command_line_flags) into a strict
// object schema, so its keys get completion, types, and unknown-key validation matching
// what Fleet enforces. Both structs embed per-OS structs that live in the hand-written
// agent_options.go, so every source file is parsed and the embeds are pulled up into one
// flat set of keys. The structs are unexported, so we parse the AST rather than reflect.
func osqueryStructSchema(rootStruct string, paths ...string) (map[string]any, error) {
fileSet := token.NewFileSet()
structsByName := map[string]*ast.StructType{}
for _, path := range paths {
parsedFile, err := parser.ParseFile(fileSet, path, nil, 0)
if err != nil {
return nil, err
}
ast.Inspect(parsedFile, func(astNode ast.Node) bool {
typeSpec, ok := astNode.(*ast.TypeSpec)
if !ok {
return true
}
if structType, ok := typeSpec.Type.(*ast.StructType); ok {
structsByName[typeSpec.Name.Name] = structType
}
return true
})
}
root, ok := structsByName[rootStruct]
if !ok {
return nil, fmt.Errorf("%s struct not found", rootStruct)
}
properties := map[string]any{}
var addFields func(structType *ast.StructType)
addFields = func(structType *ast.StructType) {
for _, field := range structType.Fields.List {
fieldType, ok := field.Type.(*ast.Ident)
if !ok {
continue
}
// An anonymous field is an embedded per-OS struct: pull its keys up.
if len(field.Names) == 0 {
if embedded, ok := structsByName[fieldType.Name]; ok {
addFields(embedded)
}
continue
}
if field.Tag == nil {
continue
}
tag := reflect.StructTag(strings.Trim(field.Tag.Value, "`"))
name, _, _ := strings.Cut(tag.Get("json"), ",")
jsonType := goTypeToJSON(fieldType.Name)
if name == "" || name == "-" || jsonType == "" {
continue
}
// [type, null] keeps the key typed while allowing an empty value, and
// relaxNulls leaves the union alone so numeric keys keep their type.
properties[name] = map[string]any{"type": []any{jsonType, "null"}}
}
}
addFields(root)
if len(properties) == 0 {
return nil, fmt.Errorf("%s produced no keys", rootStruct)
}
return map[string]any{"type": "object", "additionalProperties": false, "properties": properties}, nil
}
// reflectProperties reflects a struct and returns its top-level property schemas.
func reflectProperties(v any) map[string]any {
reflector := &jsonschema.Reflector{RequiredFromJSONSchemaTags: true, Mapper: typeMapper, KeyNamer: toSnake, ExpandedStruct: true}
raw, err := json.Marshal(reflector.Reflect(v))
if err != nil {
return nil
}
var reflected map[string]any
err = json.Unmarshal(raw, &reflected)
if err != nil {
return nil
}
properties, _ := reflected["properties"].(map[string]any)
return properties
}
// toSnake converts a Go field name to snake_case. invopop applies KeyNamer to the
// json-tag name when a tag is present, so already-snake tags pass through unchanged.
// Untagged Fleet fields like GitOpsSoftware.Packages get fixed.
func toSnake(name string) string {
runes := []rune(name)
result := make([]rune, 0, len(runes)+4)
for i, char := range runes {
if !unicode.IsUpper(char) {
result = append(result, char)
continue
}
if i > 0 {
previous := runes[i-1]
nextIsLower := i+1 < len(runes) && unicode.IsLower(runes[i+1])
atBoundary := unicode.IsLower(previous) || unicode.IsDigit(previous) || (unicode.IsUpper(previous) && nextIsLower)
if atBoundary {
result = append(result, '_')
}
}
result = append(result, unicode.ToLower(char))
}
return string(result)
}
// collectRenames walks the type tree recording json-tag -> renameto name. Fleet
// aliases many config keys with a `renameto` tag for the new fleets/reports
// terminology, and GitOps YAML uses the renamed key, but invopop only reads json.
func collectRenames(goType reflect.Type, visited map[reflect.Type]bool, renames map[string]string) {
for goType.Kind() == reflect.Pointer || goType.Kind() == reflect.Slice || goType.Kind() == reflect.Array || goType.Kind() == reflect.Map {
goType = goType.Elem()
}
if goType.Kind() != reflect.Struct || visited[goType] {
return
}
visited[goType] = true
for field := range goType.Fields() {
renameTo := field.Tag.Get("renameto")
if renameTo != "" {
jsonName, _, _ := strings.Cut(field.Tag.Get("json"), ",")
renameName, _, _ := strings.Cut(renameTo, ",")
if jsonName != "" && renameName != "" {
renames[jsonName] = renameName
}
}
collectRenames(field.Type, visited, renames)
}
}
// mergeOsqueryOptions types agent_options.config.options with the generated osquery
// option list. config keeps its other keys (schedule, decorators, ...) open.
func mergeOsqueryOptions(schemaKeys map[string]any, osqueryOptions map[string]any) {
// If the options couldn't be built, leave config open rather than pinning its
// options to an empty or null schema.
if len(osqueryOptions) == 0 {
return
}
agentOptions, ok := definitionProperties(schemaKeys, "AgentOptions")
if !ok {
return
}
agentOptions["config"] = map[string]any{
"type": "object",
"properties": map[string]any{"options": osqueryOptions},
}
}
// mergeCommandLineFlags types agent_options.command_line_flags with the generated
// osquery CLI flag list, at the AgentOptions root only since it isn't valid in overrides.
func mergeCommandLineFlags(schemaKeys map[string]any, commandLineFlags map[string]any) {
if len(commandLineFlags) == 0 {
return
}
agentOptions, ok := definitionProperties(schemaKeys, "AgentOptions")
if !ok {
return
}
agentOptions["command_line_flags"] = commandLineFlags
}
// mergeMissingMDMKeys copies gitops-only MDM keys into the "MDM" def, whose base
// fleet.MDM (org_settings.mdm) omits them. spec.GitOpsMDM embeds fleet.MDM and adds
// them (e.g. end_user_license_agreement), so add whichever the def is missing.
func mergeMissingMDMKeys(schemaKeys map[string]any, gitOpsMDM spec.GitOpsMDM) {
extraProperties := reflectProperties(&gitOpsMDM)
if extraProperties == nil {
return
}
properties, ok := definitionProperties(schemaKeys, "MDM")
if !ok {
return
}
for key, value := range extraProperties {
_, exists := properties[key]
if !exists {
properties[key] = value
}
}
}
// --- tree helpers ---
// collectNodes walks the schema iteratively and returns every object node,
// parents always before their children. Collecting once lets the passes below be
// plain loops instead of repeated recursive tree walks.
func collectNodes(schemaKeys any) []map[string]any {
var nodes []map[string]any
stack := []any{schemaKeys}
for len(stack) > 0 {
// Pop the next value off the stack.
current := stack[len(stack)-1]
stack = stack[:len(stack)-1]
switch node := current.(type) {
case map[string]any:
// Add the object to nodes, then push its values to visit next.
nodes = append(nodes, node)
for _, child := range node {
stack = append(stack, child)
}
case []any:
// Walk through arrays without collecting them.
stack = append(stack, node...)
}
}
return nodes
}
// definitionByName returns a named $def object and whether it was found.
func definitionByName(schemaKeys map[string]any, name string) (map[string]any, bool) {
definitions, _ := schemaKeys["$defs"].(map[string]any)
definition, ok := definitions[name].(map[string]any)
return definition, ok
}
// definitionProperties returns the properties of a named $def and whether it was found.
func definitionProperties(schemaKeys map[string]any, name string) (map[string]any, bool) {
definition, ok := definitionByName(schemaKeys, name)
if !ok {
return nil, false
}
properties, ok := definition["properties"].(map[string]any)
return properties, ok
}
// appendDescription puts text below node's existing description, if any. The blank
// line matters, since yamlls renders the two parts as separate paragraphs on hover.
func appendDescription(node map[string]any, text string) {
existing, ok := node["description"].(string)
if ok && existing != "" {
node["description"] = existing + "\n\n" + text
return
}
node["description"] = text
}
// typeLabel returns a short type name for a schema node, for hover text.
func typeLabel(node map[string]any) string {
ref, ok := node["$ref"].(string)
if ok {
return strings.TrimPrefix(ref, "#/$defs/")
}
schemaType, ok := node["type"].(string)
if !ok {
_, isAnyOf := node["anyOf"]
if isAnyOf {
return "boolean or object"
}
return ""
}
if schemaType != "array" {
return schemaType
}
items, ok := node["items"].(map[string]any)
if !ok {
return "array"
}
innerLabel := typeLabel(items)
if innerLabel == "" {
return "array"
}
return "array<" + innerLabel + ">"
}
// resolveReference follows a chain of $ref links through definitions to the concrete
// schema object. Each iteration replaces node with the definition its $ref points at,
// and returns when node has no $ref, the ref is unknown, or it was already seen (a
// cycle), so it visits each definition at most once.
func resolveReference(definitions map[string]any, node map[string]any) map[string]any {
seen := map[string]bool{}
for {
ref, isRef := node["$ref"].(string)
if !isRef {
return node // reached a concrete node
}
name := strings.TrimPrefix(ref, "#/$defs/")
if seen[name] {
return node // cycle: stop where we are
}
seen[name] = true
definition, isObject := definitions[name].(map[string]any)
if !isObject {
return node // dangling ref: nothing to follow
}
node = definition
}
}
// --- post-processing passes ---
// annotate walks the collected nodes once and, per node, does two things: label
// each property with its type (shown on hover), then add an alias for any renamed
// key alongside the deprecated original. Labeling comes first so an alias, a shallow
// copy of the property, inherits the label.
func annotate(nodes []map[string]any, renames map[string]string) {
for _, node := range nodes {
properties, ok := node["properties"].(map[string]any)
if !ok {
continue
}
for _, value := range properties {
property, ok := value.(map[string]any)
if !ok {
continue
}
label := typeLabel(property)
if label == "" {
continue
}
appendDescription(property, "type: `"+label+"`")
}
for jsonName, renameName := range renames {
original, present := properties[jsonName]
if !present {
continue
}
property, isObject := original.(map[string]any)
_, aliasExists := properties[renameName]
switch {
case aliasExists:
// Keep an alias that's already present rather than overwriting it.
case isObject:
properties[renameName] = maps.Clone(property)
default:
properties[renameName] = original
}
if isObject {
property["deprecated"] = true
property["deprecationMessage"] = "'" + jsonName + "' is deprecated, use '" + renameName + "' instead"
}
}
}
}
// addGitOpsKeyNotes appends each declarativeExceptions note to the schema node at
// its dotted key path. It descends the path from the root, following $refs and
// stepping transparently through array items, and attaches the note to the property
// node itself (so shared $defs aren't affected). Missing paths are skipped.
func addGitOpsKeyNotes(schemaKeys map[string]any) {
definitions, _ := schemaKeys["$defs"].(map[string]any)
for path, note := range declarativeExceptions {
node := schemaKeys
found := true
for segment := range strings.SplitSeq(path, ".") {
container := resolveReference(definitions, node)
items, isArray := container["items"].(map[string]any)
if isArray {
container = resolveReference(definitions, items)
}
properties, hasProperties := container["properties"].(map[string]any)
if !hasProperties {
found = false
break
}
next, isObject := properties[segment].(map[string]any)
if !isObject {
found = false
break
}
node = next
}
if found {
appendDescription(node, note)
}
}
}
// fixYaraRules rewrites the reflected YaraRule shape. AppConfig.YaraRules reflects to
// {name, contents}, but gitops org_settings.yara_rules items are {path} file references
// (fleet.YaraRuleSpec), so swap the properties to match what gitops accepts.
func fixYaraRules(schemaKeys map[string]any) {
definition, ok := definitionByName(schemaKeys, "YaraRule")
if !ok {
return
}
definition["properties"] = map[string]any{"path": map[string]any{"type": "string"}}
definition["additionalProperties"] = false
delete(definition, "required")
}
// addPathReferences adds the file-reference keys the Go structs don't model, so a real
// GitOps file using e.g. `- path: ./lib/foo.yml` doesn't light up with "Property path
// is not allowed". `path` is one external file, and `paths` is a single glob string.
func addPathReferences(schemaKeys map[string]any) {
for _, name := range pathReferenceDefinitions {
addStringProperty(schemaKeys, name, "path")
}
for _, name := range pathsReferenceDefinitions {
addStringProperty(schemaKeys, name, "path")
addStringProperty(schemaKeys, name, "paths")
}
}
func addStringProperty(schemaKeys map[string]any, definitionName string, key string) {
properties, ok := definitionProperties(schemaKeys, definitionName)
if !ok {
return
}
if _, exists := properties[key]; !exists {
properties[key] = map[string]any{"type": "string"}
}
}
// addRequiredKeys injects an anyOf of single-key required branches (each with the
// same errorMessage) so an item is valid when any one of the required keys is present.
func addRequiredKeys(schemaKeys map[string]any) {
for _, rule := range requiredKeys {
definition, ok := definitionByName(schemaKeys, rule.definition)
if !ok {
continue
}
anyOf := make([]any, 0, len(rule.validKeyCombinations))
for _, combination := range rule.validKeyCombinations {
required := make([]any, len(combination))
for i, key := range combination {
required[i] = key
}
anyOf = append(anyOf, map[string]any{
"required": required,
"errorMessage": rule.message,
})
}
definition["anyOf"] = anyOf
}
}
// relaxNulls makes empty placeholder keys valid. GitOps files routinely leave keys
// empty, like `minimum_version:` or `scripts:`, which YAML parses as null, so every
// leaf has to accept null. How it does that depends on the type.
func relaxNulls(nodes []map[string]any) {
for _, node := range nodes {
schemaType, ok := node["type"].(string)
if !ok || node["enum"] != nil {
continue
}
switch schemaType {
case "integer", "number":
// Left untyped. Some Fleet ints marshal as string enums, like
// label_membership_type (a uint that marshals as "dynamic"), so a number
// check would reject a value fleetctl accepts. Reflection can't tell those
// apart from real ints, so numeric leaves stay unchecked.
delete(node, "type")
case "string", "boolean", "object", "array":
// Keep the type as [type, null] so a wrong type is still caught while an
// empty null placeholder validates. This makes yamlls offer null in value
// completion, a limitation we accept so a real error like an unquoted
// version: 13.0 shows up in the editor, not at apply time. fleetctl rejects
// that value too, since ghodss decodes it as a number into a Go string.
node["type"] = []any{schemaType, "null"}
}
}
}
// typeStrictStringKeys re-applies a strict string type to the keys in strictStringKeys,
// undoing the relaxNulls pass for them.
func typeStrictStringKeys(schemaKeys map[string]any) {
for definitionName, keys := range strictStringKeys {
properties, ok := definitionProperties(schemaKeys, definitionName)
if !ok {
continue
}
for _, key := range keys {
node, ok := properties[key].(map[string]any)
if !ok {
continue
}
node["type"] = "string"
}
}
}