### 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 -->
672 lines
20 KiB
Go
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"
|
|
}
|
|
}
|
|
}
|