215 lines
6.1 KiB
Go
215 lines
6.1 KiB
Go
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
|
|
}
|