package smartconfig import ( "fmt" "io" "os" "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) } // Interpolate variables recursively interpolated, err := c.interpolate(string(data), 0) if err != nil { return nil, fmt.Errorf("failed to interpolate config: %w", err) } // Parse as YAML if err := yaml.Unmarshal([]byte(interpolated), &c.data); err != nil { return nil, fmt.Errorf("failed to parse YAML: %w", err) } // 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 } // LoadFromFile loads configuration from a YAML file at the specified path. // DEPRECATED: Use NewFromConfigPath instead for cleaner API. func (c *Config) LoadFromFile(path string) error { file, err := os.Open(path) if err != nil { return fmt.Errorf("failed to open config file: %w", err) } defer func() { _ = file.Close() }() return c.LoadFromReader(file) } // LoadFromReader loads configuration from an io.Reader containing YAML data. // DEPRECATED: Use NewFromReader instead for cleaner API. func (c *Config) LoadFromReader(reader io.Reader) error { data, err := io.ReadAll(reader) if err != nil { return fmt.Errorf("failed to read config: %w", err) } // Interpolate variables recursively interpolated, err := c.interpolate(string(data), 0) if err != nil { return fmt.Errorf("failed to interpolate config: %w", err) } // Parse as YAML if err := yaml.Unmarshal([]byte(interpolated), &c.data); err != nil { return fmt.Errorf("failed to parse YAML: %w", err) } // Handle environment variable injection if err := c.injectEnvironment(); err != nil { return fmt.Errorf("failed to inject environment variables: %w", err) } return nil } 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 dot notation. // 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) { return c.data[key], true } // GetString retrieves a string value from the configuration. // Returns an error if the key doesn't exist. Numeric values are converted to strings. func (c *Config) GetString(key string) (string, error) { value, ok := c.data[key] if !ok { return "", fmt.Errorf("key %s not found", key) } // Try direct string conversion first if strValue, ok := value.(string); ok { return strValue, nil } // Convert other types to string return fmt.Sprintf("%v", value), nil } // 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 }