518 lines
15 KiB
Go
518 lines
15 KiB
Go
package settings
|
|
|
|
import (
|
|
"bytes"
|
|
"fmt"
|
|
"go/ast"
|
|
"go/parser"
|
|
"go/token"
|
|
"os"
|
|
"path/filepath"
|
|
"reflect"
|
|
"strings"
|
|
|
|
"gopkg.in/yaml.v3"
|
|
)
|
|
|
|
// CommentsMap[typeName][fieldName] = combined doc+inline comment text
|
|
type CommentsMap map[string]map[string]string
|
|
|
|
// SecretFieldsMap[typeName][fieldName] = true if field should be redacted
|
|
type SecretFieldsMap map[string]map[string]bool
|
|
|
|
// DeprecatedFieldsMap[typeName][fieldName] = true if field is deprecated
|
|
type DeprecatedFieldsMap map[string]map[string]bool
|
|
|
|
// getStringStyle determines whether a string should be quoted in YAML
|
|
func getStringStyle(value string) yaml.Style {
|
|
// Always quote all strings for consistency
|
|
return yaml.DoubleQuotedStyle
|
|
}
|
|
|
|
// CollectComments parses all Go source in the directory of srcPath and returns CommentsMap.
|
|
func CollectComments(srcPath string) (CommentsMap, error) {
|
|
comments, _, _, err := CollectCommentsAndSecrets(srcPath)
|
|
return comments, err
|
|
}
|
|
|
|
// CollectCommentsAndSecrets parses all Go source and returns comments, secrets, and deprecated field mappings
|
|
func CollectCommentsAndSecrets(srcPath string) (CommentsMap, SecretFieldsMap, DeprecatedFieldsMap, error) {
|
|
// parse entire package so we capture comments on all types
|
|
dir := srcPath
|
|
if filepath.IsAbs(srcPath) {
|
|
// If it's an absolute path to a file, get the directory
|
|
dir = filepath.Dir(srcPath)
|
|
}
|
|
|
|
fset := token.NewFileSet()
|
|
pkgs, err := parser.ParseDir(fset, dir, nil, parser.ParseComments)
|
|
if err != nil {
|
|
return nil, nil, nil, err
|
|
}
|
|
comments := make(CommentsMap)
|
|
secrets := make(SecretFieldsMap)
|
|
deprecated := make(DeprecatedFieldsMap)
|
|
for _, pkg := range pkgs {
|
|
for _, file := range pkg.Files {
|
|
for _, decl := range file.Decls {
|
|
gen, ok := decl.(*ast.GenDecl)
|
|
if !ok || gen.Tok != token.TYPE {
|
|
continue
|
|
}
|
|
for _, spec := range gen.Specs {
|
|
ts, ok := spec.(*ast.TypeSpec)
|
|
if !ok {
|
|
continue
|
|
}
|
|
st, ok := ts.Type.(*ast.StructType)
|
|
if !ok {
|
|
continue
|
|
}
|
|
typeName := ts.Name.Name
|
|
commentMap := make(map[string]string)
|
|
secretMap := make(map[string]bool)
|
|
deprecatedMap := make(map[string]bool)
|
|
comments[typeName] = commentMap
|
|
secrets[typeName] = secretMap
|
|
deprecated[typeName] = deprecatedMap
|
|
|
|
for _, field := range st.Fields.List {
|
|
if len(field.Names) == 0 {
|
|
continue
|
|
}
|
|
name := field.Names[0].Name
|
|
var parts []string
|
|
var fullComment string
|
|
|
|
if field.Doc != nil {
|
|
docText := strings.TrimSpace(field.Doc.Text())
|
|
parts = append(parts, docText)
|
|
fullComment += docText + " "
|
|
}
|
|
if field.Comment != nil {
|
|
commentText := strings.TrimSpace(field.Comment.Text())
|
|
parts = append(parts, commentText)
|
|
fullComment += commentText
|
|
}
|
|
|
|
if len(parts) > 0 {
|
|
commentMap[name] = strings.Join(parts, " : ")
|
|
}
|
|
|
|
// Check if field should be treated as secret
|
|
if strings.Contains(strings.ToLower(fullComment), "secret:") {
|
|
secretMap[name] = true
|
|
}
|
|
|
|
// Check if field should be treated as deprecated
|
|
if strings.Contains(strings.ToLower(fullComment), "deprecated:") {
|
|
deprecatedMap[name] = true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return comments, secrets, deprecated, nil
|
|
}
|
|
|
|
// BuildNode constructs a yaml.Node for any Go value, injecting comments on struct fields.
|
|
func BuildNode(v reflect.Value, comm CommentsMap) (*yaml.Node, error) {
|
|
return buildNodeWithDefaults(v, comm, reflect.Value{}, SecretFieldsMap{}, DeprecatedFieldsMap{})
|
|
}
|
|
|
|
// buildNodeWithDefaults constructs a yaml.Node for any Go value, skipping fields that match defaults, redacting secrets, and filtering deprecated fields
|
|
func buildNodeWithDefaults(v reflect.Value, comm CommentsMap, defaults reflect.Value, secrets SecretFieldsMap, deprecated DeprecatedFieldsMap) (*yaml.Node, error) {
|
|
// Dereference pointers
|
|
if v.Kind() == reflect.Ptr {
|
|
if v.IsNil() {
|
|
return &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!null", Value: "null"}, nil
|
|
}
|
|
var defaultsElem reflect.Value
|
|
if defaults.IsValid() && !defaults.IsNil() {
|
|
defaultsElem = defaults.Elem()
|
|
}
|
|
return buildNodeWithDefaults(v.Elem(), comm, defaultsElem, secrets, deprecated)
|
|
}
|
|
|
|
switch v.Kind() {
|
|
case reflect.String:
|
|
value := v.String()
|
|
return &yaml.Node{
|
|
Kind: yaml.ScalarNode,
|
|
Tag: "!!str",
|
|
Value: value,
|
|
Style: getStringStyle(value),
|
|
}, nil
|
|
case reflect.Struct:
|
|
rt := v.Type()
|
|
typeName := rt.Name()
|
|
mapNode := &yaml.Node{Kind: yaml.MappingNode}
|
|
for i := 0; i < rt.NumField(); i++ {
|
|
sf := rt.Field(i)
|
|
|
|
// skip unexported or omitted fields
|
|
if sf.PkgPath != "" {
|
|
continue
|
|
}
|
|
if yamlTag := sf.Tag.Get("yaml"); yamlTag == "-" {
|
|
continue
|
|
}
|
|
if jsonTag := sf.Tag.Get("json"); jsonTag == "-" {
|
|
continue
|
|
}
|
|
|
|
// Skip deprecated fields if filtering is enabled
|
|
if len(deprecated) > 0 && deprecated[typeName][sf.Name] {
|
|
continue
|
|
}
|
|
|
|
currentField := v.Field(i)
|
|
|
|
// If we have defaults, compare and skip if values match
|
|
if defaults.IsValid() && i < defaults.NumField() {
|
|
defaultField := defaults.Field(i)
|
|
currentValue := currentField.Interface()
|
|
defaultValue := defaultField.Interface()
|
|
|
|
isEqual := reflect.DeepEqual(currentValue, defaultValue)
|
|
|
|
if isEqual {
|
|
continue // Skip this field as it matches the default
|
|
}
|
|
}
|
|
|
|
// determine key: yaml tag > json tag > field name
|
|
yamlTag := sf.Tag.Get("yaml")
|
|
jsonTag := sf.Tag.Get("json")
|
|
var keyName string
|
|
if yamlTag != "" && yamlTag != "-" {
|
|
keyName = strings.Split(yamlTag, ",")[0]
|
|
} else if jsonTag != "" && jsonTag != "-" {
|
|
keyName = strings.Split(jsonTag, ",")[0]
|
|
} else {
|
|
keyName = sf.Name
|
|
}
|
|
|
|
keyNode := &yaml.Node{Kind: yaml.ScalarNode, Value: keyName}
|
|
|
|
// attach validate and comments inline (only when comments are enabled)
|
|
var parts []string
|
|
if cm := comm[typeName][sf.Name]; cm != "" {
|
|
parts = append(parts, cm)
|
|
}
|
|
// Only add validation tags if comments map is not empty (meaning comments are enabled)
|
|
if len(comm) > 0 {
|
|
if vt := sf.Tag.Get("validate"); vt != "" {
|
|
parts = append(parts, fmt.Sprintf(" validate:%s", vt))
|
|
}
|
|
}
|
|
|
|
if len(parts) > 0 {
|
|
keyNode.LineComment = strings.Join(parts, " ")
|
|
}
|
|
|
|
// Check if this field should be redacted as a secret
|
|
var valNode *yaml.Node
|
|
var err error
|
|
if secrets[typeName][sf.Name] {
|
|
valNode = &yaml.Node{
|
|
Kind: yaml.ScalarNode,
|
|
Tag: "!!str",
|
|
Value: "**hidden**",
|
|
Style: yaml.DoubleQuotedStyle, // Keep secrets quoted for clarity
|
|
}
|
|
} else {
|
|
// Pass through the corresponding default field for recursive comparison
|
|
var defaultField reflect.Value
|
|
if defaults.IsValid() && i < defaults.NumField() {
|
|
defaultField = defaults.Field(i)
|
|
}
|
|
|
|
valNode, err = buildNodeWithDefaults(currentField, comm, defaultField, secrets, deprecated)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
mapNode.Content = append(mapNode.Content, keyNode, valNode)
|
|
}
|
|
return mapNode, nil
|
|
|
|
case reflect.Slice, reflect.Array:
|
|
seq := &yaml.Node{Kind: yaml.SequenceNode}
|
|
// for non-struct slices, render inline [] via flow style
|
|
if v.Type().Elem().Kind() != reflect.Struct {
|
|
seq.Style = yaml.FlowStyle
|
|
}
|
|
// placeholder for empty slice of structs
|
|
if v.Len() == 0 && v.Type().Elem().Kind() == reflect.Struct {
|
|
zero := reflect.Zero(v.Type().Elem())
|
|
var defaultElem reflect.Value
|
|
if defaults.IsValid() && defaults.Len() > 0 {
|
|
defaultElem = defaults.Index(0)
|
|
}
|
|
n, err := buildNodeWithDefaults(zero, comm, defaultElem, secrets, deprecated)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
seq.Content = append(seq.Content, n)
|
|
return seq, nil
|
|
}
|
|
for i := 0; i < v.Len(); i++ {
|
|
var defaultElem reflect.Value
|
|
if defaults.IsValid() && i < defaults.Len() {
|
|
defaultElem = defaults.Index(i)
|
|
}
|
|
n, err := buildNodeWithDefaults(v.Index(i), comm, defaultElem, secrets, deprecated)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
seq.Content = append(seq.Content, n)
|
|
}
|
|
return seq, nil
|
|
|
|
case reflect.Map:
|
|
mapNode := &yaml.Node{Kind: yaml.MappingNode}
|
|
|
|
for _, key := range v.MapKeys() {
|
|
// Handle key
|
|
keyStr := fmt.Sprintf("%v", key.Interface())
|
|
keyNode := &yaml.Node{
|
|
Kind: yaml.ScalarNode,
|
|
Value: keyStr,
|
|
}
|
|
|
|
// Handle value recursively
|
|
mapVal := v.MapIndex(key)
|
|
var defaultVal reflect.Value
|
|
if defaults.IsValid() {
|
|
defaultVal = defaults.MapIndex(key)
|
|
}
|
|
|
|
valNode, err := buildNodeWithDefaults(mapVal, comm, defaultVal, secrets, deprecated)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
mapNode.Content = append(mapNode.Content, keyNode, valNode)
|
|
}
|
|
return mapNode, nil
|
|
|
|
default:
|
|
n := &yaml.Node{}
|
|
if err := n.Encode(v.Interface()); err != nil {
|
|
return nil, err
|
|
}
|
|
return n, nil
|
|
}
|
|
}
|
|
|
|
func GenerateYaml() {
|
|
_ = loadConfigWithDefaults("", true)
|
|
Config.Server.Sources = []Source{
|
|
{
|
|
Path: ".",
|
|
},
|
|
}
|
|
|
|
setupLogging()
|
|
setupAuth(true)
|
|
setupSources(true)
|
|
setupUrls()
|
|
setupFrontend(true)
|
|
output := "generated.yaml" // "output YAML file"
|
|
|
|
// Generate YAML with comments enabled, full config, and deprecated fields filtered
|
|
// Force the source path to be correct for static generation
|
|
yamlContent, err := GenerateConfigYamlWithSource(&Config, true, true, true, "common/settings")
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "error generating YAML: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
if err := os.WriteFile(output, []byte(yamlContent), 0644); err != nil {
|
|
fmt.Fprintf(os.Stderr, "error writing YAML: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
fmt.Printf("Generated YAML with comments (deprecated fields filtered): %s\n", output)
|
|
}
|
|
|
|
// GenerateConfigYaml generates YAML from a given config with options for comments and filtering
|
|
func GenerateConfigYaml(config *Settings, showComments bool, showFull bool, filterDeprecated bool) (string, error) {
|
|
// Try different source paths to handle both runtime and test scenarios
|
|
sourcePaths := []string{
|
|
"common/settings", // When running from backend directory
|
|
".", // When running tests from settings directory
|
|
"../settings", // Alternative test path
|
|
}
|
|
|
|
for _, sourcePath := range sourcePaths {
|
|
yamlOutput, err := GenerateConfigYamlWithSource(config, showComments, showFull, filterDeprecated, sourcePath)
|
|
if err == nil {
|
|
return yamlOutput, nil
|
|
}
|
|
// If it's not a file not found error, return immediately
|
|
if !strings.Contains(err.Error(), "no such file or directory") && !strings.Contains(err.Error(), "cannot find") {
|
|
return "", err
|
|
}
|
|
}
|
|
|
|
// If all paths failed, try with empty maps to at least generate basic YAML
|
|
return GenerateConfigYamlWithEmptyMaps(config, showFull)
|
|
}
|
|
|
|
// GenerateConfigYamlWithEmptyMaps generates YAML without comment parsing when source files are unavailable
|
|
func GenerateConfigYamlWithEmptyMaps(config *Settings, showFull bool) (string, error) {
|
|
// Create empty maps
|
|
comm := make(CommentsMap)
|
|
secrets := make(SecretFieldsMap)
|
|
deprecated := make(DeprecatedFieldsMap)
|
|
|
|
var node *yaml.Node
|
|
var err error
|
|
|
|
if showFull {
|
|
// Show the full current config
|
|
node, err = buildNodeWithDefaults(reflect.ValueOf(config), comm, reflect.Value{}, secrets, deprecated)
|
|
} else {
|
|
// Show only non-default values by comparing with defaults during node building
|
|
defaultConfig := setDefaults(true)
|
|
defaultConfig.Server.Sources = []Source{{Path: "."}}
|
|
|
|
node, err = buildNodeWithDefaults(reflect.ValueOf(config), comm, reflect.ValueOf(&defaultConfig), secrets, deprecated)
|
|
}
|
|
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
// Convert to YAML
|
|
doc := &yaml.Node{Kind: yaml.DocumentNode}
|
|
doc.Content = []*yaml.Node{node}
|
|
|
|
var rawBuf bytes.Buffer
|
|
enc := yaml.NewEncoder(&rawBuf)
|
|
enc.SetIndent(2)
|
|
if err := enc.Encode(doc); err != nil {
|
|
return "", err
|
|
}
|
|
|
|
return AlignComments(rawBuf.String()), nil
|
|
}
|
|
|
|
// GenerateConfigYamlWithSource generates YAML from a given config with options for comments and filtering, using a custom source path
|
|
func GenerateConfigYamlWithSource(config *Settings, showComments bool, showFull bool, filterDeprecated bool, sourcePath string) (string, error) {
|
|
var comm CommentsMap
|
|
var secrets SecretFieldsMap
|
|
var deprecated DeprecatedFieldsMap
|
|
var err error
|
|
|
|
if showComments {
|
|
// Collect comments, secrets, and deprecated fields from the settings source files
|
|
comm, secrets, deprecated, err = CollectCommentsAndSecrets(sourcePath)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
} else {
|
|
// Still need to collect secrets and deprecated fields even if not showing comments
|
|
_, secrets, deprecated, err = CollectCommentsAndSecrets(sourcePath)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
// Create empty comments map
|
|
comm = make(CommentsMap)
|
|
}
|
|
|
|
// If not filtering deprecated fields, clear the deprecated map
|
|
if !filterDeprecated {
|
|
deprecated = make(DeprecatedFieldsMap)
|
|
}
|
|
|
|
var node *yaml.Node
|
|
|
|
if showFull {
|
|
// Show the full current config
|
|
node, err = buildNodeWithDefaults(reflect.ValueOf(config), comm, reflect.Value{}, secrets, deprecated)
|
|
} else {
|
|
// Show only non-default values by comparing with defaults during node building
|
|
// Create a clean default config (no file loading, just pure defaults)
|
|
defaultConfig := setDefaults(true)
|
|
// Apply same setup as a fresh instance would have
|
|
defaultConfig.Server.Sources = []Source{{Path: "."}}
|
|
|
|
node, err = buildNodeWithDefaults(reflect.ValueOf(config), comm, reflect.ValueOf(&defaultConfig), secrets, deprecated)
|
|
}
|
|
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
// Create document
|
|
doc := &yaml.Node{Kind: yaml.DocumentNode}
|
|
doc.Content = []*yaml.Node{node}
|
|
|
|
// Encode to YAML
|
|
var rawBuf bytes.Buffer
|
|
enc := yaml.NewEncoder(&rawBuf)
|
|
enc.SetIndent(2)
|
|
if err := enc.Encode(doc); err != nil {
|
|
return "", err
|
|
}
|
|
|
|
// Apply comment alignment if comments are enabled
|
|
yamlOutput := rawBuf.String()
|
|
if showComments {
|
|
yamlOutput = AlignComments(yamlOutput)
|
|
}
|
|
|
|
return yamlOutput, nil
|
|
}
|
|
|
|
// formatLine applies padding to a single line so its comment starts at a target column.
|
|
func formatLine(line string) string {
|
|
const targetCol = 42
|
|
const defaultPadding = 1
|
|
|
|
// Find the first # character that is NOT inside double or single quotes.
|
|
inSingle, inDouble := false, false
|
|
for i := 0; i < len(line); i++ {
|
|
c := line[i]
|
|
switch c {
|
|
case '\'':
|
|
if !inDouble {
|
|
inSingle = !inSingle
|
|
}
|
|
case '"':
|
|
if !inSingle {
|
|
inDouble = !inDouble
|
|
}
|
|
case '#':
|
|
if !inSingle && !inDouble {
|
|
// found a comment
|
|
contentPart := line[:i]
|
|
commentPart := line[i:]
|
|
trimmedContent := strings.TrimRight(contentPart, " ")
|
|
if len(trimmedContent) >= targetCol {
|
|
return trimmedContent + strings.Repeat(" ", defaultPadding) + commentPart
|
|
}
|
|
paddingNeeded := targetCol - len(trimmedContent)
|
|
return trimmedContent + strings.Repeat(" ", paddingNeeded) + commentPart
|
|
}
|
|
}
|
|
}
|
|
// No comment found
|
|
return line
|
|
}
|
|
|
|
// AlignComments formats each line of the YAML string independently.
|
|
func AlignComments(input string) string {
|
|
lines := strings.Split(input, "\n")
|
|
outputLines := make([]string, len(lines))
|
|
|
|
for i, line := range lines {
|
|
outputLines[i] = formatLine(line)
|
|
}
|
|
|
|
return strings.Join(outputLines, "\n")
|
|
}
|