package smartconfig import ( "encoding/json" "fmt" "io" "math" "os" "regexp" "strconv" "strings" "github.com/dustin/go-humanize" "github.com/tidwall/gjson" "gopkg.in/yaml.v3" ) const ( maxRecursionDepth = 3 interpolationPattern = `\$\{([^:]+?):(.*?)\}` ) // Config represents the loaded configuration with support for interpolated values. // It provides methods to load configuration from files or readers, access values, // and register custom resolvers for extending interpolation capabilities. type Config struct { data map[string]interface{} resolvers map[string]Resolver } // Resolver defines the interface for custom variable resolution. // Implementations should resolve the given value and return the result. // For example, an environment resolver would return the value of the // environment variable specified in the value parameter. type Resolver interface { Resolve(value string) (string, error) } // NewFromAppName loads configuration from /etc/appname/config.yml. // It creates a new Config instance, loads and interpolates the configuration file. func NewFromAppName(appname string) (*Config, error) { configPath := fmt.Sprintf("/etc/%s/config.yml", appname) return NewFromConfigPath(configPath) } // NewFromConfigPath loads configuration from the specified file path. // It creates a new Config instance, loads and interpolates the configuration file. func NewFromConfigPath(path string) (*Config, error) { file, err := os.Open(path) if err != nil { return nil, fmt.Errorf("failed to open config file: %w", err) } defer func() { _ = file.Close() }() return NewFromReader(file) } // NewFromReader loads configuration from an io.Reader. // It creates a new Config instance, loads and interpolates the configuration. func NewFromReader(reader io.Reader) (*Config, error) { c := newWithDefaults() data, err := io.ReadAll(reader) if err != nil { return nil, fmt.Errorf("failed to read config: %w", err) } // Parse YAML first if err := yaml.Unmarshal(data, &c.data); err != nil { return nil, fmt.Errorf("failed to parse YAML: %w", err) } // Walk and interpolate the parsed structure interpolated, err := c.walkAndInterpolate(c.data) if err != nil { return nil, fmt.Errorf("failed to interpolate config: %w", err) } c.data = interpolated.(map[string]interface{}) // Handle environment variable injection if err := c.injectEnvironment(); err != nil { return nil, fmt.Errorf("failed to inject environment variables: %w", err) } return c, nil } // New creates an empty Config instance with default resolvers registered. // For loading configuration, prefer using NewFromAppName, NewFromConfigPath, or NewFromReader. func New() *Config { return newWithDefaults() } func newWithDefaults() *Config { c := &Config{ resolvers: make(map[string]Resolver), data: make(map[string]interface{}), } // Register default resolvers c.RegisterResolver("ENV", &EnvResolver{}) c.RegisterResolver("EXEC", &ExecResolver{}) c.RegisterResolver("FILE", &FileResolver{}) c.RegisterResolver("JSON", &JSONResolver{}) c.RegisterResolver("YAML", &YAMLResolver{}) c.RegisterResolver("AWSSM", &AWSSecretManagerResolver{}) c.RegisterResolver("GCPSM", &GCPSecretManagerResolver{}) c.RegisterResolver("VAULT", &VaultResolver{}) c.RegisterResolver("CONSUL", &ConsulResolver{}) c.RegisterResolver("AZURESM", &AzureKeyVaultResolver{}) c.RegisterResolver("K8SS", &K8SSecretResolver{}) c.RegisterResolver("ETCD", &EtcdResolver{}) return c } // RegisterResolver registers a custom resolver with the given name. // The resolver will be available for use in interpolations with the syntax ${name:value}. func (c *Config) RegisterResolver(name string, resolver Resolver) { c.resolvers[name] = resolver } func (c *Config) injectEnvironment() error { envData, ok := c.data["env"] if !ok { return nil } envMap, ok := envData.(map[string]interface{}) if !ok { return fmt.Errorf("env section must be a map") } for key, value := range envMap { strValue, ok := value.(string) if !ok { strValue = fmt.Sprintf("%v", value) } if err := os.Setenv(key, strValue); err != nil { return fmt.Errorf("failed to set environment variable %s: %w", key, err) } } return nil } // Get retrieves a value from the configuration using gjson path syntax. // For example: // - "database.host" retrieves config.database.host // - "servers.0.name" retrieves the name of the first server in a list // // Returns the value and true if found, nil and false if not found. func (c *Config) Get(key string) (interface{}, bool) { // Try direct key lookup first for backward compatibility if value, exists := c.data[key]; exists { return value, true } // If not found, try gjson path value, err := c.getValueByPath(key) if err != nil { return nil, false } return value, true } // getValueByPath retrieves a value using gjson path syntax. // It converts the internal data to JSON and uses gjson to extract the value. func (c *Config) getValueByPath(path string) (interface{}, error) { // Clean the data before JSON marshaling to handle special float values cleanedData := c.cleanDataForJSON(c.data) // Convert data to JSON jsonBytes, err := json.Marshal(cleanedData) if err != nil { return nil, fmt.Errorf("failed to marshal config to JSON: %w", err) } // Use gjson to get the value result := gjson.GetBytes(jsonBytes, path) if !result.Exists() { return nil, fmt.Errorf("key %s not found", path) } // Check if the value is null if result.Type == gjson.Null { return nil, fmt.Errorf("key %s has null value", path) } // Return the underlying value return result.Value(), nil } // cleanDataForJSON recursively cleans data to handle special float values // that cannot be marshaled to JSON (Inf, -Inf, NaN) func (c *Config) cleanDataForJSON(data interface{}) interface{} { switch v := data.(type) { case map[string]interface{}: cleaned := make(map[string]interface{}) for key, value := range v { cleaned[key] = c.cleanDataForJSON(value) } return cleaned case []interface{}: cleaned := make([]interface{}, len(v)) for i, value := range v { cleaned[i] = c.cleanDataForJSON(value) } return cleaned case float64: // Handle special float values if math.IsInf(v, 1) { return "Infinity" } else if math.IsInf(v, -1) { return "-Infinity" } else if math.IsNaN(v) { return "NaN" } return v default: return v } } // GetString retrieves a string value from the configuration using gjson path syntax. // Returns an error if the key doesn't exist. Numeric values are converted to strings. func (c *Config) GetString(key string) (string, error) { value, err := c.getValueByPath(key) if err != nil { return "", err } // Try direct string conversion first if strValue, ok := value.(string); ok { return strValue, nil } // Don't allow complex types (maps, slices) to be converted to string switch value.(type) { case map[string]interface{}, []interface{}: return "", fmt.Errorf("cannot convert complex type to string for key %s", key) } // Convert other types to string return fmt.Sprintf("%v", value), nil } // GetInt retrieves an integer value from the configuration using gjson path syntax. // Returns an error if the key doesn't exist or if the value cannot be converted to an integer. func (c *Config) GetInt(key string) (int, error) { value, err := c.getValueByPath(key) if err != nil { return 0, err } // Don't allow complex types switch value.(type) { case map[string]interface{}, []interface{}: return 0, fmt.Errorf("cannot convert complex type to integer for key %s", key) } // Try direct int conversion first if intValue, ok := value.(int); ok { return intValue, nil } // Try float64 (common in JSON/YAML parsing) if floatValue, ok := value.(float64); ok { return int(floatValue), nil } // Try string conversion if strValue, ok := value.(string); ok { intValue, err := strconv.Atoi(strValue) if err != nil { return 0, fmt.Errorf("cannot convert value %q to integer: %w", strValue, err) } return intValue, nil } return 0, fmt.Errorf("cannot convert value of type %T to integer", value) } // GetUint retrieves an unsigned integer value from the configuration using gjson path syntax. // Returns an error if the key doesn't exist or if the value cannot be converted to a uint. func (c *Config) GetUint(key string) (uint, error) { value, err := c.getValueByPath(key) if err != nil { return 0, err } // Try direct uint conversion first if uintValue, ok := value.(uint); ok { return uintValue, nil } // Try int conversion if intValue, ok := value.(int); ok { if intValue < 0 { return 0, fmt.Errorf("cannot convert negative value %d to uint", intValue) } return uint(intValue), nil } // Try float64 (common in JSON/YAML parsing) if floatValue, ok := value.(float64); ok { if floatValue < 0 { return 0, fmt.Errorf("cannot convert negative value %f to uint", floatValue) } return uint(floatValue), nil } // Try string conversion if strValue, ok := value.(string); ok { uintValue, err := strconv.ParseUint(strValue, 10, 0) if err != nil { return 0, fmt.Errorf("cannot convert value %q to uint: %w", strValue, err) } return uint(uintValue), nil } return 0, fmt.Errorf("cannot convert value of type %T to uint", value) } // GetFloat retrieves a float64 value from the configuration using gjson path syntax. // Returns an error if the key doesn't exist or if the value cannot be converted to a float64. func (c *Config) GetFloat(key string) (float64, error) { value, err := c.getValueByPath(key) if err != nil { return 0, err } // Try direct float64 conversion first if floatValue, ok := value.(float64); ok { return floatValue, nil } // Try float32 conversion if floatValue, ok := value.(float32); ok { return float64(floatValue), nil } // Try int conversion if intValue, ok := value.(int); ok { return float64(intValue), nil } // Try string conversion if strValue, ok := value.(string); ok { floatValue, err := strconv.ParseFloat(strValue, 64) if err != nil { return 0, fmt.Errorf("cannot convert value %q to float: %w", strValue, err) } return floatValue, nil } return 0, fmt.Errorf("cannot convert value of type %T to float", value) } // GetBool retrieves a boolean value from the configuration using gjson path syntax. // Returns an error if the key doesn't exist or if the value cannot be converted to a boolean. func (c *Config) GetBool(key string) (bool, error) { value, err := c.getValueByPath(key) if err != nil { return false, err } // Try direct bool conversion first if boolValue, ok := value.(bool); ok { return boolValue, nil } // Try string conversion if strValue, ok := value.(string); ok { boolValue, err := strconv.ParseBool(strValue) if err != nil { return false, fmt.Errorf("cannot convert value %q to boolean: %w", strValue, err) } return boolValue, nil } // Try numeric conversions (0 = false, non-zero = true) if intValue, ok := value.(int); ok { return intValue != 0, nil } if floatValue, ok := value.(float64); ok { return floatValue != 0, nil } return false, fmt.Errorf("cannot convert value of type %T to boolean", value) } // GetBytes retrieves a byte size value from the configuration using gjson path syntax. // Supports human-readable formats like "10G", "20KiB", "25TB", etc. // Returns the size in bytes as uint64. func (c *Config) GetBytes(key string) (uint64, error) { value, err := c.getValueByPath(key) if err != nil { return 0, err } // Try direct numeric conversions first if uintValue, ok := value.(uint64); ok { return uintValue, nil } if intValue, ok := value.(int); ok { if intValue < 0 { return 0, fmt.Errorf("cannot convert negative value %d to bytes", intValue) } return uint64(intValue), nil } if floatValue, ok := value.(float64); ok { if floatValue < 0 { return 0, fmt.Errorf("cannot convert negative value %f to bytes", floatValue) } return uint64(floatValue), nil } // Try string conversion with humanize parsing if strValue, ok := value.(string); ok { // Try parsing as a plain number first if bytesValue, err := strconv.ParseUint(strValue, 10, 64); err == nil { return bytesValue, nil } // Try parsing with humanize bytesValue, err := humanize.ParseBytes(strValue) if err != nil { return 0, fmt.Errorf("cannot parse value %q as bytes: %w", strValue, err) } return bytesValue, nil } return 0, fmt.Errorf("cannot convert value of type %T to bytes", value) } // Data returns the entire configuration as a map. // This is useful for unmarshaling into custom structures. func (c *Config) Data() map[string]interface{} { return c.data } // walkAndInterpolate recursively walks through the parsed YAML structure // and interpolates any string values containing ${...} patterns. func (c *Config) walkAndInterpolate(data interface{}) (interface{}, error) { switch v := data.(type) { case string: // Check if this string contains interpolation patterns if strings.Contains(v, "${") && strings.Contains(v, "}") { return c.interpolateString(v) } return v, nil case map[string]interface{}: // Recursively process map values result := make(map[string]interface{}) for key, value := range v { interpolated, err := c.walkAndInterpolate(value) if err != nil { return nil, err } result[key] = interpolated } return result, nil case []interface{}: // Recursively process array elements result := make([]interface{}, len(v)) for i, value := range v { interpolated, err := c.walkAndInterpolate(value) if err != nil { return nil, err } result[i] = interpolated } return result, nil default: // Return other types as-is (numbers, booleans, etc.) return v, nil } } // interpolateString handles interpolation of a single string value. // It returns the appropriate type based on the resolved value. func (c *Config) interpolateString(s string) (interface{}, error) { // If the entire string is a single interpolation, we can return typed values if match := regexp.MustCompile(`^\$\{([^:]+):(.+)\}$`).FindStringSubmatch(s); match != nil { resolverName := match[1] value := match[2] // Handle nested interpolations in the value if strings.Contains(value, "${") { processedValue, err := c.interpolateValue(value, 0) if err != nil { return nil, err } value = processedValue } // Resolve the value resolver, ok := c.resolvers[resolverName] if !ok { return nil, fmt.Errorf("unknown resolver: %s", resolverName) } resolved, err := resolver.Resolve(value) if err != nil { return nil, fmt.Errorf("failed to resolve %s:%s: %w", resolverName, value, err) } // Try to convert to appropriate type return c.convertToType(resolved), nil } // Otherwise, it's a string with embedded interpolations // We need to interpolate these as strings return c.interpolateMixedContent(s, 0) } // interpolateValue handles nested interpolations for resolver values func (c *Config) interpolateValue(s string, depth int) (string, error) { if depth >= maxRecursionDepth { return s, nil } result := s changed := true for changed && depth < maxRecursionDepth { changed = false positions := findInterpolations(result) // Process from end to beginning for i := len(positions) - 1; i >= 0; i-- { pos := positions[i] fullMatch := result[pos.start:pos.end] inner := fullMatch[2 : len(fullMatch)-1] colonIdx := strings.Index(inner, ":") if colonIdx == -1 { continue } resolverName := inner[:colonIdx] value := inner[colonIdx+1:] // Recursively handle nested interpolations if strings.Contains(value, "${") { interpolatedValue, err := c.interpolateValue(value, depth+1) if err != nil { return "", err } value = interpolatedValue } // Resolve the value resolver, ok := c.resolvers[resolverName] if !ok { return "", fmt.Errorf("unknown resolver: %s", resolverName) } resolved, err := resolver.Resolve(value) if err != nil { return "", fmt.Errorf("failed to resolve %s:%s: %w", resolverName, value, err) } // Replace without quotes result = result[:pos.start] + resolved + result[pos.end:] changed = true } if changed { depth++ } } return result, nil } // interpolateMixedContent handles strings with embedded interpolations func (c *Config) interpolateMixedContent(s string, depth int) (string, error) { if depth >= maxRecursionDepth { return s, nil } result := s positions := findInterpolations(result) // Process from end to beginning for i := len(positions) - 1; i >= 0; i-- { pos := positions[i] fullMatch := result[pos.start:pos.end] inner := fullMatch[2 : len(fullMatch)-1] colonIdx := strings.Index(inner, ":") if colonIdx == -1 { continue } resolverName := inner[:colonIdx] value := inner[colonIdx+1:] // Handle nested interpolations if strings.Contains(value, "${") { interpolatedValue, err := c.interpolateValue(value, depth+1) if err != nil { return "", err } value = interpolatedValue } // Resolve the value resolver, ok := c.resolvers[resolverName] if !ok { return "", fmt.Errorf("unknown resolver: %s", resolverName) } resolved, err := resolver.Resolve(value) if err != nil { return "", fmt.Errorf("failed to resolve %s:%s: %w", resolverName, value, err) } // Replace without quotes for mixed content result = result[:pos.start] + resolved + result[pos.end:] } return result, nil } // convertToType attempts to convert a string to its appropriate type func (c *Config) convertToType(s string) interface{} { // First try to parse as YAML - this handles complex structures // returned by JSON/YAML resolvers var yamlData interface{} if err := yaml.Unmarshal([]byte(s), &yamlData); err == nil { // If it's a complex type (map or slice), return it directly switch yamlData.(type) { case map[string]interface{}, map[interface{}]interface{}, []interface{}: return yamlData } // For simple types parsed from YAML, continue with normal conversion } // Try boolean if s == "true" { return true } if s == "false" { return false } // Try integer if i, err := strconv.Atoi(s); err == nil { // Verify the conversion is lossless if strconv.Itoa(i) == s { return i } } // Try float if f, err := strconv.ParseFloat(s, 64); err == nil { // Check if it's actually an integer if float64(int(f)) == f { // Verify integer conversion is lossless if strconv.Itoa(int(f)) == s { return int(f) } } // For floats, use FormatFloat to check if conversion is lossless // Using -1 precision means use the smallest number of digits necessary if strconv.FormatFloat(f, 'f', -1, 64) == s { return f } } // Default to string return s }