passes tests, has cli filter now.
* still has not been *really* tested yet
This commit is contained in:
parent
971068ea03
commit
8a38afba5e
184
README.md
184
README.md
@ -76,6 +76,190 @@ env:
|
|||||||
* FILE - read from a file
|
* FILE - read from a file
|
||||||
* JSON - read from a JSON file (supports json5)
|
* JSON - read from a JSON file (supports json5)
|
||||||
* YAML - read from a YAML file
|
* YAML - read from a YAML file
|
||||||
|
* ETCD - etcd key-value store
|
||||||
|
|
||||||
|
# API Documentation
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go get git.eeqj.de/sneak/smartconfig
|
||||||
|
```
|
||||||
|
|
||||||
|
## Basic Usage
|
||||||
|
|
||||||
|
```go
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
|
||||||
|
"git.eeqj.de/sneak/smartconfig"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
// Load configuration from /etc/myapp/config.yml
|
||||||
|
config, err := smartconfig.NewFromAppName("myapp")
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Or load from a specific path
|
||||||
|
config, err = smartconfig.NewFromConfigPath("/path/to/config.yaml")
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Access configuration values
|
||||||
|
dbHost, _ := config.GetString("db_host")
|
||||||
|
fmt.Printf("Database host: %s\n", dbHost)
|
||||||
|
|
||||||
|
// Get entire configuration as a map
|
||||||
|
data := config.Data()
|
||||||
|
fmt.Printf("Full config: %+v\n", data)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Loading Configuration
|
||||||
|
|
||||||
|
### From App Name (looks in /etc/appname/config.yml)
|
||||||
|
|
||||||
|
```go
|
||||||
|
config, err := smartconfig.NewFromAppName("myapp")
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### From a File Path
|
||||||
|
|
||||||
|
```go
|
||||||
|
config, err := smartconfig.NewFromConfigPath("/path/to/config.yaml")
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### From an io.Reader
|
||||||
|
|
||||||
|
```go
|
||||||
|
reader := strings.NewReader(yamlContent)
|
||||||
|
config, err := smartconfig.NewFromReader(reader)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Accessing Configuration Values
|
||||||
|
|
||||||
|
### Get Any Value
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Returns (value, exists)
|
||||||
|
value, exists := config.Get("database.host")
|
||||||
|
if exists {
|
||||||
|
fmt.Printf("Database host: %v\n", value)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Get String Value
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Returns (string, error)
|
||||||
|
host, err := config.GetString("database.host")
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Error: %v", err)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Get Entire Configuration
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Returns the full configuration as a map
|
||||||
|
data := config.Data()
|
||||||
|
```
|
||||||
|
|
||||||
|
## Custom Resolvers
|
||||||
|
|
||||||
|
You can add custom resolvers to extend the interpolation capabilities:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Define a custom resolver
|
||||||
|
type CustomResolver struct{}
|
||||||
|
|
||||||
|
func (r *CustomResolver) Resolve(value string) (string, error) {
|
||||||
|
// Your custom resolution logic here
|
||||||
|
return "resolved-value", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register the resolver
|
||||||
|
config := smartconfig.New()
|
||||||
|
config.RegisterResolver("CUSTOM", &CustomResolver{})
|
||||||
|
|
||||||
|
// Use in config: ${CUSTOM:some-value}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Resolver Formats
|
||||||
|
|
||||||
|
### Local Resolvers
|
||||||
|
|
||||||
|
* `${ENV:VARIABLE_NAME}` - Environment variable
|
||||||
|
* `${EXEC:command}` - Execute shell command
|
||||||
|
* `${FILE:/path/to/file}` - Read file contents
|
||||||
|
* `${JSON:/path/to/file.json:json.path}` - Read JSON value
|
||||||
|
* `${YAML:/path/to/file.yaml:yaml.path}` - Read YAML value
|
||||||
|
|
||||||
|
### Cloud Resolvers
|
||||||
|
|
||||||
|
* `${AWSSM:secret-name}` - AWS Secrets Manager
|
||||||
|
* `${GCPSM:projects/PROJECT/secrets/NAME}` - GCP Secret Manager
|
||||||
|
* `${VAULT:path:key}` - HashiCorp Vault (e.g., `${VAULT:secret/data/myapp:password}`)
|
||||||
|
* `${CONSUL:key/path}` - Consul KV store
|
||||||
|
* `${AZURESM:https://vault.azure.net:secret}` - Azure Key Vault
|
||||||
|
* `${K8SS:namespace/secret:key}` - Kubernetes Secrets
|
||||||
|
* `${ETCD:/key/path}` - etcd (requires ETCD_ENDPOINTS env var)
|
||||||
|
|
||||||
|
## Nested Interpolation
|
||||||
|
|
||||||
|
Smartconfig supports nested interpolations up to 3 levels deep:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
env_suffix: "prod"
|
||||||
|
database:
|
||||||
|
host: "${ENV:DB_HOST_${ENV:ENV_SUFFIX}}" # Resolves DB_HOST_prod
|
||||||
|
```
|
||||||
|
|
||||||
|
## Environment Variable Injection
|
||||||
|
|
||||||
|
Values under the `env` key are automatically set as environment variables:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
env:
|
||||||
|
API_KEY: "${VAULT:secret/api:key}"
|
||||||
|
DB_PASSWORD: "${AWSSM:db-password}"
|
||||||
|
|
||||||
|
# After loading, these are available as environment variables
|
||||||
|
```
|
||||||
|
|
||||||
|
## CLI Tool
|
||||||
|
|
||||||
|
A command-line tool is included that reads YAML from stdin, interpolates all variables, and writes the result to stdout:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build the CLI tool
|
||||||
|
go build ./cmd/smartconfig
|
||||||
|
|
||||||
|
# Use it to interpolate config files
|
||||||
|
cat config.yaml | ./smartconfig > interpolated.yaml
|
||||||
|
|
||||||
|
# Or use it in a pipeline
|
||||||
|
export DB_PASSWORD="secret123"
|
||||||
|
echo 'password: ${ENV:DB_PASSWORD}' | ./smartconfig
|
||||||
|
# Output: password: secret123
|
||||||
|
```
|
||||||
|
|
||||||
|
Note: If an environment variable or other resource is not found, the tool will exit with an error.
|
||||||
|
|
||||||
# License
|
# License
|
||||||
|
|
||||||
|
31
cli_missing_env_test.go
Normal file
31
cli_missing_env_test.go
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
package smartconfig
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCLIMissingEnvInterpolation(t *testing.T) {
|
||||||
|
// Don't set TEST_APP_NAME - it should cause an error
|
||||||
|
// Set TEST_PORT to verify partial interpolation works
|
||||||
|
_ = os.Setenv("TEST_PORT", "3000")
|
||||||
|
defer func() {
|
||||||
|
_ = os.Unsetenv("TEST_PORT")
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Test YAML content with missing env var
|
||||||
|
yamlContent := `name: ${ENV:TEST_APP_NAME}
|
||||||
|
port: ${ENV:TEST_PORT}
|
||||||
|
host: localhost`
|
||||||
|
|
||||||
|
// Create config from reader - should fail due to missing TEST_APP_NAME
|
||||||
|
reader := strings.NewReader(yamlContent)
|
||||||
|
_, err := NewFromReader(reader)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("Expected error when environment variable is missing, but got none")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "TEST_APP_NAME") {
|
||||||
|
t.Errorf("Expected error to mention missing TEST_APP_NAME, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
46
cli_test.go
Normal file
46
cli_test.go
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
package smartconfig
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCLIInterpolation(t *testing.T) {
|
||||||
|
// Set up environment variables
|
||||||
|
_ = os.Setenv("TEST_APP_NAME", "myapp")
|
||||||
|
_ = os.Setenv("TEST_PORT", "8080")
|
||||||
|
defer func() {
|
||||||
|
_ = os.Unsetenv("TEST_APP_NAME")
|
||||||
|
_ = os.Unsetenv("TEST_PORT")
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Test YAML content
|
||||||
|
yamlContent := `name: ${ENV:TEST_APP_NAME}
|
||||||
|
port: ${ENV:TEST_PORT}
|
||||||
|
host: localhost`
|
||||||
|
|
||||||
|
// Create config from reader
|
||||||
|
reader := strings.NewReader(yamlContent)
|
||||||
|
config, err := NewFromReader(reader)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if interpolation worked
|
||||||
|
name, err := config.GetString("name")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to get name: %v", err)
|
||||||
|
}
|
||||||
|
if name != "myapp" {
|
||||||
|
t.Errorf("Expected name 'myapp', got '%s'", name)
|
||||||
|
}
|
||||||
|
|
||||||
|
port, err := config.GetString("port")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to get port: %v", err)
|
||||||
|
}
|
||||||
|
if port != "8080" {
|
||||||
|
t.Errorf("Expected port '8080', got '%s'", port)
|
||||||
|
}
|
||||||
|
}
|
32
cmd/smartconfig/main.go
Normal file
32
cmd/smartconfig/main.go
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"git.eeqj.de/sneak/smartconfig"
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
// Read from stdin
|
||||||
|
config, err := smartconfig.NewFromReader(os.Stdin)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Error reading config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the interpolated data
|
||||||
|
data := config.Data()
|
||||||
|
|
||||||
|
// Marshal back to YAML
|
||||||
|
output, err := yaml.Marshal(data)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Error marshaling to YAML: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write to stdout
|
||||||
|
_, err = os.Stdout.Write(output)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Error writing output: %v", err)
|
||||||
|
}
|
||||||
|
}
|
@ -1,6 +1,9 @@
|
|||||||
package smartconfig
|
package smartconfig
|
||||||
|
|
||||||
import "strings"
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
// findInterpolations finds all ${...} patterns in the string, handling nested cases
|
// findInterpolations finds all ${...} patterns in the string, handling nested cases
|
||||||
func findInterpolations(s string) []struct{ start, end int } {
|
func findInterpolations(s string) []struct{ start, end int } {
|
||||||
@ -71,7 +74,7 @@ func (c *Config) interpolate(content string, depth int) (string, error) {
|
|||||||
// Recursively interpolate the value first
|
// Recursively interpolate the value first
|
||||||
interpolatedValue, err := c.interpolate(value, depth+1)
|
interpolatedValue, err := c.interpolate(value, depth+1)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
continue
|
return "", err
|
||||||
}
|
}
|
||||||
value = interpolatedValue
|
value = interpolatedValue
|
||||||
}
|
}
|
||||||
@ -79,12 +82,12 @@ func (c *Config) interpolate(content string, depth int) (string, error) {
|
|||||||
// Resolve the value
|
// Resolve the value
|
||||||
resolver, ok := c.resolvers[resolverName]
|
resolver, ok := c.resolvers[resolverName]
|
||||||
if !ok {
|
if !ok {
|
||||||
continue
|
return "", fmt.Errorf("unknown resolver: %s", resolverName)
|
||||||
}
|
}
|
||||||
|
|
||||||
resolved, err := resolver.Resolve(value)
|
resolved, err := resolver.Resolve(value)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
continue
|
return "", fmt.Errorf("failed to resolve %s:%s: %w", resolverName, value, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Replace the match
|
// Replace the match
|
||||||
|
474
main.go
474
main.go
@ -1,474 +0,0 @@
|
|||||||
package smartconfig
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"os"
|
|
||||||
"os/exec"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"cloud.google.com/go/secretmanager/apiv1"
|
|
||||||
secretmanagerpb "cloud.google.com/go/secretmanager/apiv1/secretmanagerpb"
|
|
||||||
"github.com/Azure/azure-sdk-for-go/sdk/azidentity"
|
|
||||||
"github.com/Azure/azure-sdk-for-go/sdk/keyvault/azsecrets"
|
|
||||||
"github.com/aws/aws-sdk-go-v2/config"
|
|
||||||
"github.com/aws/aws-sdk-go-v2/service/secretsmanager"
|
|
||||||
"github.com/hashicorp/consul/api"
|
|
||||||
vaultapi "github.com/hashicorp/vault/api"
|
|
||||||
clientv3 "go.etcd.io/etcd/client/v3"
|
|
||||||
"gopkg.in/yaml.v3"
|
|
||||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
|
||||||
"k8s.io/client-go/kubernetes"
|
|
||||||
"k8s.io/client-go/rest"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
maxRecursionDepth = 3
|
|
||||||
interpolationPattern = `\$\{([^:]+?):(.*?)\}`
|
|
||||||
)
|
|
||||||
|
|
||||||
type Config struct {
|
|
||||||
data map[string]interface{}
|
|
||||||
resolvers map[string]Resolver
|
|
||||||
}
|
|
||||||
|
|
||||||
type Resolver interface {
|
|
||||||
Resolve(value string) (string, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
type EnvResolver struct{}
|
|
||||||
|
|
||||||
func (r *EnvResolver) Resolve(value string) (string, error) {
|
|
||||||
result := os.Getenv(value)
|
|
||||||
if result == "" {
|
|
||||||
return "", fmt.Errorf("environment variable %s not found", value)
|
|
||||||
}
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type ExecResolver struct{}
|
|
||||||
|
|
||||||
func (r *ExecResolver) Resolve(value string) (string, error) {
|
|
||||||
cmd := exec.Command("sh", "-c", value)
|
|
||||||
output, err := cmd.Output()
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("exec command failed: %w", err)
|
|
||||||
}
|
|
||||||
return strings.TrimSpace(string(output)), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type FileResolver struct{}
|
|
||||||
|
|
||||||
func (r *FileResolver) Resolve(value string) (string, error) {
|
|
||||||
data, err := os.ReadFile(value)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("failed to read file %s: %w", value, err)
|
|
||||||
}
|
|
||||||
return strings.TrimSpace(string(data)), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type JSONResolver struct{}
|
|
||||||
|
|
||||||
func (r *JSONResolver) Resolve(value string) (string, error) {
|
|
||||||
parts := strings.SplitN(value, ":", 2)
|
|
||||||
if len(parts) != 2 {
|
|
||||||
return "", fmt.Errorf("invalid JSON resolver format, expected FILE:PATH")
|
|
||||||
}
|
|
||||||
|
|
||||||
filePath := parts[0]
|
|
||||||
jsonPath := parts[1]
|
|
||||||
|
|
||||||
data, err := os.ReadFile(filePath)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("failed to read JSON file %s: %w", filePath, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var jsonData interface{}
|
|
||||||
if err := json.Unmarshal(data, &jsonData); err != nil {
|
|
||||||
return "", fmt.Errorf("failed to parse JSON: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Simple JSON path evaluation (would need a proper library for complex paths)
|
|
||||||
if jsonPath == "." {
|
|
||||||
return fmt.Sprintf("%v", jsonData), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// This is a simplified implementation
|
|
||||||
// In production, use a proper JSON path library
|
|
||||||
return fmt.Sprintf("%v", jsonData), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type YAMLResolver struct{}
|
|
||||||
|
|
||||||
func (r *YAMLResolver) Resolve(value string) (string, error) {
|
|
||||||
parts := strings.SplitN(value, ":", 2)
|
|
||||||
if len(parts) != 2 {
|
|
||||||
return "", fmt.Errorf("invalid YAML resolver format, expected FILE:PATH")
|
|
||||||
}
|
|
||||||
|
|
||||||
filePath := parts[0]
|
|
||||||
yamlPath := parts[1]
|
|
||||||
|
|
||||||
data, err := os.ReadFile(filePath)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("failed to read YAML file %s: %w", filePath, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var yamlData interface{}
|
|
||||||
if err := yaml.Unmarshal(data, &yamlData); err != nil {
|
|
||||||
return "", fmt.Errorf("failed to parse YAML: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Simple YAML path evaluation (would need a proper library for complex paths)
|
|
||||||
if yamlPath == "." {
|
|
||||||
return fmt.Sprintf("%v", yamlData), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// This is a simplified implementation
|
|
||||||
// In production, use a proper YAML path library
|
|
||||||
return fmt.Sprintf("%v", yamlData), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type AWSSecretManagerResolver struct{}
|
|
||||||
|
|
||||||
func (r *AWSSecretManagerResolver) Resolve(value string) (string, error) {
|
|
||||||
ctx := context.Background()
|
|
||||||
cfg, err := config.LoadDefaultConfig(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("failed to load AWS config: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
svc := secretsmanager.NewFromConfig(cfg)
|
|
||||||
input := &secretsmanager.GetSecretValueInput{
|
|
||||||
SecretId: &value,
|
|
||||||
}
|
|
||||||
|
|
||||||
result, err := svc.GetSecretValue(ctx, input)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("failed to get secret %s: %w", value, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if result.SecretString != nil {
|
|
||||||
return *result.SecretString, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return "", fmt.Errorf("secret %s has no string value", value)
|
|
||||||
}
|
|
||||||
|
|
||||||
type GCPSecretManagerResolver struct{}
|
|
||||||
|
|
||||||
func (r *GCPSecretManagerResolver) Resolve(value string) (string, error) {
|
|
||||||
ctx := context.Background()
|
|
||||||
client, err := secretmanager.NewClient(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("failed to create GCP Secret Manager client: %w", err)
|
|
||||||
}
|
|
||||||
defer func() {
|
|
||||||
_ = client.Close()
|
|
||||||
}()
|
|
||||||
|
|
||||||
// If value doesn't contain a version, append /versions/latest
|
|
||||||
if !strings.Contains(value, "/versions/") {
|
|
||||||
value = value + "/versions/latest"
|
|
||||||
}
|
|
||||||
|
|
||||||
req := &secretmanagerpb.AccessSecretVersionRequest{
|
|
||||||
Name: value,
|
|
||||||
}
|
|
||||||
|
|
||||||
result, err := client.AccessSecretVersion(ctx, req)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("failed to access secret %s: %w", value, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return string(result.Payload.Data), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type VaultResolver struct{}
|
|
||||||
|
|
||||||
func (r *VaultResolver) Resolve(value string) (string, error) {
|
|
||||||
config := vaultapi.DefaultConfig()
|
|
||||||
client, err := vaultapi.NewClient(config)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("failed to create Vault client: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Expect format: "path:key" e.g., "secret/data/myapp:password"
|
|
||||||
parts := strings.SplitN(value, ":", 2)
|
|
||||||
if len(parts) != 2 {
|
|
||||||
return "", fmt.Errorf("invalid Vault path format, expected PATH:KEY")
|
|
||||||
}
|
|
||||||
|
|
||||||
path := parts[0]
|
|
||||||
key := parts[1]
|
|
||||||
|
|
||||||
secret, err := client.Logical().Read(path)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("failed to read secret from Vault: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if secret == nil || secret.Data == nil {
|
|
||||||
return "", fmt.Errorf("no secret found at path %s", path)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle KV v2 format
|
|
||||||
data, ok := secret.Data["data"].(map[string]interface{})
|
|
||||||
if ok {
|
|
||||||
if val, ok := data[key].(string); ok {
|
|
||||||
return val, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle KV v1 format
|
|
||||||
if val, ok := secret.Data[key].(string); ok {
|
|
||||||
return val, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return "", fmt.Errorf("key %s not found in secret", key)
|
|
||||||
}
|
|
||||||
|
|
||||||
type ConsulResolver struct{}
|
|
||||||
|
|
||||||
func (r *ConsulResolver) Resolve(value string) (string, error) {
|
|
||||||
config := api.DefaultConfig()
|
|
||||||
client, err := api.NewClient(config)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("failed to create Consul client: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
kv := client.KV()
|
|
||||||
pair, _, err := kv.Get(value, nil)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("failed to get key %s from Consul: %w", value, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if pair == nil {
|
|
||||||
return "", fmt.Errorf("key %s not found in Consul", value)
|
|
||||||
}
|
|
||||||
|
|
||||||
return string(pair.Value), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type AzureKeyVaultResolver struct{}
|
|
||||||
|
|
||||||
func (r *AzureKeyVaultResolver) Resolve(value string) (string, error) {
|
|
||||||
// Expect format: "https://myvault.vault.azure.net:secretname"
|
|
||||||
parts := strings.SplitN(value, ":", 2)
|
|
||||||
if len(parts) != 2 {
|
|
||||||
return "", fmt.Errorf("invalid Azure Key Vault format, expected VAULT_URL:SECRET_NAME")
|
|
||||||
}
|
|
||||||
|
|
||||||
vaultURL := parts[0]
|
|
||||||
secretName := parts[1]
|
|
||||||
|
|
||||||
cred, err := azidentity.NewDefaultAzureCredential(nil)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("failed to create Azure credential: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
client, err := azsecrets.NewClient(vaultURL, cred, nil)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("failed to create Azure Key Vault client: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
resp, err := client.GetSecret(ctx, secretName, "", nil)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("failed to get secret %s: %w", secretName, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return *resp.Value, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type K8SSecretResolver struct{}
|
|
||||||
|
|
||||||
func (r *K8SSecretResolver) Resolve(value string) (string, error) {
|
|
||||||
// Expect format: "namespace/secretname:key"
|
|
||||||
parts := strings.SplitN(value, ":", 2)
|
|
||||||
if len(parts) != 2 {
|
|
||||||
return "", fmt.Errorf("invalid K8S secret format, expected NAMESPACE/SECRET:KEY")
|
|
||||||
}
|
|
||||||
|
|
||||||
secretPath := parts[0]
|
|
||||||
key := parts[1]
|
|
||||||
|
|
||||||
pathParts := strings.SplitN(secretPath, "/", 2)
|
|
||||||
if len(pathParts) != 2 {
|
|
||||||
return "", fmt.Errorf("invalid K8S secret path format, expected NAMESPACE/SECRET")
|
|
||||||
}
|
|
||||||
|
|
||||||
namespace := pathParts[0]
|
|
||||||
secretName := pathParts[1]
|
|
||||||
|
|
||||||
config, err := rest.InClusterConfig()
|
|
||||||
if err != nil {
|
|
||||||
// Fall back to kubeconfig
|
|
||||||
return "", fmt.Errorf("failed to get K8S config: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
clientset, err := kubernetes.NewForConfig(config)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("failed to create K8S client: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
secret, err := clientset.CoreV1().Secrets(namespace).Get(ctx, secretName, metav1.GetOptions{})
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("failed to get secret %s/%s: %w", namespace, secretName, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
data, ok := secret.Data[key]
|
|
||||||
if !ok {
|
|
||||||
return "", fmt.Errorf("key %s not found in secret %s/%s", key, namespace, secretName)
|
|
||||||
}
|
|
||||||
|
|
||||||
return string(data), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type EtcdResolver struct{}
|
|
||||||
|
|
||||||
func (r *EtcdResolver) Resolve(value string) (string, error) {
|
|
||||||
// Default to localhost:2379 if ETCD_ENDPOINTS is not set
|
|
||||||
endpoints := strings.Split(os.Getenv("ETCD_ENDPOINTS"), ",")
|
|
||||||
if len(endpoints) == 1 && endpoints[0] == "" {
|
|
||||||
endpoints = []string{"localhost:2379"}
|
|
||||||
}
|
|
||||||
|
|
||||||
cli, err := clientv3.New(clientv3.Config{
|
|
||||||
Endpoints: endpoints,
|
|
||||||
DialTimeout: 5 * time.Second,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("failed to create etcd client: %w", err)
|
|
||||||
}
|
|
||||||
defer func() {
|
|
||||||
_ = cli.Close()
|
|
||||||
}()
|
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
resp, err := cli.Get(ctx, value)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("failed to get key %s from etcd: %w", value, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(resp.Kvs) == 0 {
|
|
||||||
return "", fmt.Errorf("key %s not found in etcd", value)
|
|
||||||
}
|
|
||||||
|
|
||||||
return string(resp.Kvs[0].Value), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func New() *Config {
|
|
||||||
c := &Config{
|
|
||||||
resolvers: make(map[string]Resolver),
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Config) RegisterResolver(name string, resolver Resolver) {
|
|
||||||
c.resolvers[name] = resolver
|
|
||||||
}
|
|
||||||
|
|
||||||
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) 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)
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Config) Get(key string) (interface{}, bool) {
|
|
||||||
return c.data[key], true
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Config) GetString(key string) (string, error) {
|
|
||||||
value, ok := c.data[key]
|
|
||||||
if !ok {
|
|
||||||
return "", fmt.Errorf("key %s not found", key)
|
|
||||||
}
|
|
||||||
|
|
||||||
strValue, ok := value.(string)
|
|
||||||
if !ok {
|
|
||||||
return "", fmt.Errorf("key %s is not a string", key)
|
|
||||||
}
|
|
||||||
|
|
||||||
return strValue, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Config) Data() map[string]interface{} {
|
|
||||||
return c.data
|
|
||||||
}
|
|
38
resolver_awssm.go
Normal file
38
resolver_awssm.go
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
package smartconfig
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/aws/aws-sdk-go-v2/config"
|
||||||
|
"github.com/aws/aws-sdk-go-v2/service/secretsmanager"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AWSSecretManagerResolver retrieves secrets from AWS Secrets Manager.
|
||||||
|
// Usage: ${AWSSM:secret-name}
|
||||||
|
type AWSSecretManagerResolver struct{}
|
||||||
|
|
||||||
|
// Resolve retrieves the secret value from AWS Secrets Manager.
|
||||||
|
func (r *AWSSecretManagerResolver) Resolve(value string) (string, error) {
|
||||||
|
ctx := context.Background()
|
||||||
|
cfg, err := config.LoadDefaultConfig(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to load AWS config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
svc := secretsmanager.NewFromConfig(cfg)
|
||||||
|
input := &secretsmanager.GetSecretValueInput{
|
||||||
|
SecretId: &value,
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := svc.GetSecretValue(ctx, input)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to get secret %s: %w", value, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.SecretString != nil {
|
||||||
|
return *result.SecretString, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", fmt.Errorf("secret %s has no string value", value)
|
||||||
|
}
|
44
resolver_azure.go
Normal file
44
resolver_azure.go
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
package smartconfig
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/Azure/azure-sdk-for-go/sdk/azidentity"
|
||||||
|
"github.com/Azure/azure-sdk-for-go/sdk/keyvault/azsecrets"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AzureKeyVaultResolver retrieves secrets from Azure Key Vault.
|
||||||
|
// Usage: ${AZURESM:https://myvault.vault.azure.net:secretname}
|
||||||
|
type AzureKeyVaultResolver struct{}
|
||||||
|
|
||||||
|
// Resolve retrieves the secret value from Azure Key Vault.
|
||||||
|
func (r *AzureKeyVaultResolver) Resolve(value string) (string, error) {
|
||||||
|
// Expect format: "https://myvault.vault.azure.net:secretname"
|
||||||
|
parts := strings.SplitN(value, ":", 2)
|
||||||
|
if len(parts) != 2 {
|
||||||
|
return "", fmt.Errorf("invalid Azure Key Vault format, expected VAULT_URL:SECRET_NAME")
|
||||||
|
}
|
||||||
|
|
||||||
|
vaultURL := parts[0]
|
||||||
|
secretName := parts[1]
|
||||||
|
|
||||||
|
cred, err := azidentity.NewDefaultAzureCredential(nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to create Azure credential: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
client, err := azsecrets.NewClient(vaultURL, cred, nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to create Azure Key Vault client: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
resp, err := client.GetSecret(ctx, secretName, "", nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to get secret %s: %w", secretName, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return *resp.Value, nil
|
||||||
|
}
|
32
resolver_consul.go
Normal file
32
resolver_consul.go
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
package smartconfig
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/hashicorp/consul/api"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ConsulResolver retrieves values from Consul KV store.
|
||||||
|
// Usage: ${CONSUL:myapp/config/database}
|
||||||
|
type ConsulResolver struct{}
|
||||||
|
|
||||||
|
// Resolve retrieves the value from Consul.
|
||||||
|
func (r *ConsulResolver) Resolve(value string) (string, error) {
|
||||||
|
config := api.DefaultConfig()
|
||||||
|
client, err := api.NewClient(config)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to create Consul client: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
kv := client.KV()
|
||||||
|
pair, _, err := kv.Get(value, nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to get key %s from Consul: %w", value, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if pair == nil {
|
||||||
|
return "", fmt.Errorf("key %s not found in Consul", value)
|
||||||
|
}
|
||||||
|
|
||||||
|
return string(pair.Value), nil
|
||||||
|
}
|
19
resolver_env.go
Normal file
19
resolver_env.go
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
package smartconfig
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
// EnvResolver resolves environment variables.
|
||||||
|
// Usage: ${ENV:VARIABLE_NAME}
|
||||||
|
type EnvResolver struct{}
|
||||||
|
|
||||||
|
// Resolve returns the value of the environment variable.
|
||||||
|
func (r *EnvResolver) Resolve(value string) (string, error) {
|
||||||
|
result := os.Getenv(value)
|
||||||
|
if result == "" {
|
||||||
|
return "", fmt.Errorf("environment variable %s not found", value)
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
50
resolver_etcd.go
Normal file
50
resolver_etcd.go
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
package smartconfig
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
clientv3 "go.etcd.io/etcd/client/v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
// EtcdResolver retrieves values from etcd.
|
||||||
|
// Usage: ${ETCD:/myapp/config/key}
|
||||||
|
// Requires ETCD_ENDPOINTS environment variable or defaults to localhost:2379
|
||||||
|
type EtcdResolver struct{}
|
||||||
|
|
||||||
|
// Resolve retrieves the value from etcd.
|
||||||
|
func (r *EtcdResolver) Resolve(value string) (string, error) {
|
||||||
|
// Default to localhost:2379 if ETCD_ENDPOINTS is not set
|
||||||
|
endpoints := strings.Split(os.Getenv("ETCD_ENDPOINTS"), ",")
|
||||||
|
if len(endpoints) == 1 && endpoints[0] == "" {
|
||||||
|
endpoints = []string{"localhost:2379"}
|
||||||
|
}
|
||||||
|
|
||||||
|
cli, err := clientv3.New(clientv3.Config{
|
||||||
|
Endpoints: endpoints,
|
||||||
|
DialTimeout: 5 * time.Second,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to create etcd client: %w", err)
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
_ = cli.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
resp, err := cli.Get(ctx, value)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to get key %s from etcd: %w", value, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(resp.Kvs) == 0 {
|
||||||
|
return "", fmt.Errorf("key %s not found in etcd", value)
|
||||||
|
}
|
||||||
|
|
||||||
|
return string(resp.Kvs[0].Value), nil
|
||||||
|
}
|
21
resolver_exec.go
Normal file
21
resolver_exec.go
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
package smartconfig
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ExecResolver executes shell commands and returns their output.
|
||||||
|
// Usage: ${EXEC:command}
|
||||||
|
type ExecResolver struct{}
|
||||||
|
|
||||||
|
// Resolve executes the command and returns its output.
|
||||||
|
func (r *ExecResolver) Resolve(value string) (string, error) {
|
||||||
|
cmd := exec.Command("sh", "-c", value)
|
||||||
|
output, err := cmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("exec command failed: %w", err)
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(string(output)), nil
|
||||||
|
}
|
20
resolver_file.go
Normal file
20
resolver_file.go
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
package smartconfig
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FileResolver reads file contents.
|
||||||
|
// Usage: ${FILE:/path/to/file}
|
||||||
|
type FileResolver struct{}
|
||||||
|
|
||||||
|
// Resolve reads the file and returns its contents.
|
||||||
|
func (r *FileResolver) Resolve(value string) (string, error) {
|
||||||
|
data, err := os.ReadFile(value)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to read file %s: %w", value, err)
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(string(data)), nil
|
||||||
|
}
|
42
resolver_gcpsm.go
Normal file
42
resolver_gcpsm.go
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
package smartconfig
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
secretmanager "cloud.google.com/go/secretmanager/apiv1"
|
||||||
|
secretmanagerpb "cloud.google.com/go/secretmanager/apiv1/secretmanagerpb"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GCPSecretManagerResolver retrieves secrets from Google Cloud Secret Manager.
|
||||||
|
// Usage: ${GCPSM:projects/PROJECT_ID/secrets/SECRET_NAME}
|
||||||
|
type GCPSecretManagerResolver struct{}
|
||||||
|
|
||||||
|
// Resolve retrieves the secret value from GCP Secret Manager.
|
||||||
|
func (r *GCPSecretManagerResolver) Resolve(value string) (string, error) {
|
||||||
|
ctx := context.Background()
|
||||||
|
client, err := secretmanager.NewClient(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to create GCP Secret Manager client: %w", err)
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
_ = client.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
|
// If value doesn't contain a version, append /versions/latest
|
||||||
|
if !strings.Contains(value, "/versions/") {
|
||||||
|
value = value + "/versions/latest"
|
||||||
|
}
|
||||||
|
|
||||||
|
req := &secretmanagerpb.AccessSecretVersionRequest{
|
||||||
|
Name: value,
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := client.AccessSecretVersion(ctx, req)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to access secret %s: %w", value, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return string(result.Payload.Data), nil
|
||||||
|
}
|
42
resolver_json.go
Normal file
42
resolver_json.go
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
package smartconfig
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// JSONResolver reads values from JSON files.
|
||||||
|
// Usage: ${JSON:/path/to/file.json:json.path}
|
||||||
|
type JSONResolver struct{}
|
||||||
|
|
||||||
|
// Resolve reads a JSON file and extracts the value at the specified path.
|
||||||
|
func (r *JSONResolver) Resolve(value string) (string, error) {
|
||||||
|
parts := strings.SplitN(value, ":", 2)
|
||||||
|
if len(parts) != 2 {
|
||||||
|
return "", fmt.Errorf("invalid JSON resolver format, expected FILE:PATH")
|
||||||
|
}
|
||||||
|
|
||||||
|
filePath := parts[0]
|
||||||
|
jsonPath := parts[1]
|
||||||
|
|
||||||
|
data, err := os.ReadFile(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to read JSON file %s: %w", filePath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var jsonData interface{}
|
||||||
|
if err := json.Unmarshal(data, &jsonData); err != nil {
|
||||||
|
return "", fmt.Errorf("failed to parse JSON: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simple JSON path evaluation (would need a proper library for complex paths)
|
||||||
|
if jsonPath == "." {
|
||||||
|
return fmt.Sprintf("%v", jsonData), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// This is a simplified implementation
|
||||||
|
// In production, use a proper JSON path library
|
||||||
|
return fmt.Sprintf("%v", jsonData), nil
|
||||||
|
}
|
59
resolver_k8s.go
Normal file
59
resolver_k8s.go
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
package smartconfig
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||||
|
"k8s.io/client-go/kubernetes"
|
||||||
|
"k8s.io/client-go/rest"
|
||||||
|
)
|
||||||
|
|
||||||
|
// K8SSecretResolver retrieves secrets from Kubernetes.
|
||||||
|
// Usage: ${K8SS:namespace/secretname:key}
|
||||||
|
type K8SSecretResolver struct{}
|
||||||
|
|
||||||
|
// Resolve retrieves the secret value from Kubernetes.
|
||||||
|
func (r *K8SSecretResolver) Resolve(value string) (string, error) {
|
||||||
|
// Expect format: "namespace/secretname:key"
|
||||||
|
parts := strings.SplitN(value, ":", 2)
|
||||||
|
if len(parts) != 2 {
|
||||||
|
return "", fmt.Errorf("invalid K8S secret format, expected NAMESPACE/SECRET:KEY")
|
||||||
|
}
|
||||||
|
|
||||||
|
secretPath := parts[0]
|
||||||
|
key := parts[1]
|
||||||
|
|
||||||
|
pathParts := strings.SplitN(secretPath, "/", 2)
|
||||||
|
if len(pathParts) != 2 {
|
||||||
|
return "", fmt.Errorf("invalid K8S secret path format, expected NAMESPACE/SECRET")
|
||||||
|
}
|
||||||
|
|
||||||
|
namespace := pathParts[0]
|
||||||
|
secretName := pathParts[1]
|
||||||
|
|
||||||
|
config, err := rest.InClusterConfig()
|
||||||
|
if err != nil {
|
||||||
|
// Fall back to kubeconfig
|
||||||
|
return "", fmt.Errorf("failed to get K8S config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
clientset, err := kubernetes.NewForConfig(config)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to create K8S client: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
secret, err := clientset.CoreV1().Secrets(namespace).Get(ctx, secretName, metav1.GetOptions{})
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to get secret %s/%s: %w", namespace, secretName, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, ok := secret.Data[key]
|
||||||
|
if !ok {
|
||||||
|
return "", fmt.Errorf("key %s not found in secret %s/%s", key, namespace, secretName)
|
||||||
|
}
|
||||||
|
|
||||||
|
return string(data), nil
|
||||||
|
}
|
54
resolver_vault.go
Normal file
54
resolver_vault.go
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
package smartconfig
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
vaultapi "github.com/hashicorp/vault/api"
|
||||||
|
)
|
||||||
|
|
||||||
|
// VaultResolver retrieves secrets from HashiCorp Vault.
|
||||||
|
// Usage: ${VAULT:secret/data/myapp:password}
|
||||||
|
type VaultResolver struct{}
|
||||||
|
|
||||||
|
// Resolve retrieves the secret value from Vault.
|
||||||
|
func (r *VaultResolver) Resolve(value string) (string, error) {
|
||||||
|
config := vaultapi.DefaultConfig()
|
||||||
|
client, err := vaultapi.NewClient(config)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to create Vault client: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expect format: "path:key" e.g., "secret/data/myapp:password"
|
||||||
|
parts := strings.SplitN(value, ":", 2)
|
||||||
|
if len(parts) != 2 {
|
||||||
|
return "", fmt.Errorf("invalid Vault path format, expected PATH:KEY")
|
||||||
|
}
|
||||||
|
|
||||||
|
path := parts[0]
|
||||||
|
key := parts[1]
|
||||||
|
|
||||||
|
secret, err := client.Logical().Read(path)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to read secret from Vault: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if secret == nil || secret.Data == nil {
|
||||||
|
return "", fmt.Errorf("no secret found at path %s", path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle KV v2 format
|
||||||
|
data, ok := secret.Data["data"].(map[string]interface{})
|
||||||
|
if ok {
|
||||||
|
if val, ok := data[key].(string); ok {
|
||||||
|
return val, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle KV v1 format
|
||||||
|
if val, ok := secret.Data[key].(string); ok {
|
||||||
|
return val, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", fmt.Errorf("key %s not found in secret", key)
|
||||||
|
}
|
43
resolver_yaml.go
Normal file
43
resolver_yaml.go
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
package smartconfig
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
// YAMLResolver reads values from YAML files.
|
||||||
|
// Usage: ${YAML:/path/to/file.yaml:yaml.path}
|
||||||
|
type YAMLResolver struct{}
|
||||||
|
|
||||||
|
// Resolve reads a YAML file and extracts the value at the specified path.
|
||||||
|
func (r *YAMLResolver) Resolve(value string) (string, error) {
|
||||||
|
parts := strings.SplitN(value, ":", 2)
|
||||||
|
if len(parts) != 2 {
|
||||||
|
return "", fmt.Errorf("invalid YAML resolver format, expected FILE:PATH")
|
||||||
|
}
|
||||||
|
|
||||||
|
filePath := parts[0]
|
||||||
|
yamlPath := parts[1]
|
||||||
|
|
||||||
|
data, err := os.ReadFile(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to read YAML file %s: %w", filePath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var yamlData interface{}
|
||||||
|
if err := yaml.Unmarshal(data, &yamlData); err != nil {
|
||||||
|
return "", fmt.Errorf("failed to parse YAML: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simple YAML path evaluation (would need a proper library for complex paths)
|
||||||
|
if yamlPath == "." {
|
||||||
|
return fmt.Sprintf("%v", yamlData), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// This is a simplified implementation
|
||||||
|
// In production, use a proper YAML path library
|
||||||
|
return fmt.Sprintf("%v", yamlData), nil
|
||||||
|
}
|
214
smartconfig.go
Normal file
214
smartconfig.go
Normal file
@ -0,0 +1,214 @@
|
|||||||
|
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
|
||||||
|
}
|
@ -285,18 +285,27 @@ func TestConfigInterpolation(t *testing.T) {
|
|||||||
func TestRecursionLimit(t *testing.T) {
|
func TestRecursionLimit(t *testing.T) {
|
||||||
config := New()
|
config := New()
|
||||||
|
|
||||||
// Create a deeply nested interpolation
|
// Create a deeply nested interpolation that hits the recursion limit
|
||||||
|
// This has 4 levels, but our max is 3
|
||||||
content := "value: ${ENV:LEVEL1_${ENV:LEVEL2_${ENV:LEVEL3_${ENV:LEVEL4}}}}"
|
content := "value: ${ENV:LEVEL1_${ENV:LEVEL2_${ENV:LEVEL3_${ENV:LEVEL4}}}}"
|
||||||
|
|
||||||
|
// Set up environment variables for a complete chain
|
||||||
_ = os.Setenv("LEVEL4", "END")
|
_ = os.Setenv("LEVEL4", "END")
|
||||||
_ = os.Setenv("LEVEL3_END", "3")
|
_ = os.Setenv("LEVEL3_END", "3")
|
||||||
_ = os.Setenv("LEVEL2_3", "2")
|
_ = os.Setenv("LEVEL2_3", "2")
|
||||||
_ = os.Setenv("LEVEL1_2", "final")
|
_ = os.Setenv("LEVEL1_2", "final")
|
||||||
|
// Also set the partial resolution that would be needed at depth 3
|
||||||
|
_ = os.Setenv("LEVEL3_${ENV:LEVEL4}", "depth3value")
|
||||||
|
_ = os.Setenv("LEVEL2_depth3value", "depth2value")
|
||||||
|
_ = os.Setenv("LEVEL1_depth2value", "final_with_limit")
|
||||||
defer func() {
|
defer func() {
|
||||||
_ = os.Unsetenv("LEVEL4")
|
_ = os.Unsetenv("LEVEL4")
|
||||||
_ = os.Unsetenv("LEVEL3_END")
|
_ = os.Unsetenv("LEVEL3_END")
|
||||||
_ = os.Unsetenv("LEVEL2_3")
|
_ = os.Unsetenv("LEVEL2_3")
|
||||||
_ = os.Unsetenv("LEVEL1_2")
|
_ = os.Unsetenv("LEVEL1_2")
|
||||||
|
_ = os.Unsetenv("LEVEL3_${ENV:LEVEL4}")
|
||||||
|
_ = os.Unsetenv("LEVEL2_depth3value")
|
||||||
|
_ = os.Unsetenv("LEVEL1_depth2value")
|
||||||
}()
|
}()
|
||||||
|
|
||||||
err := config.LoadFromReader(strings.NewReader(content))
|
err := config.LoadFromReader(strings.NewReader(content))
|
||||||
@ -304,11 +313,13 @@ func TestRecursionLimit(t *testing.T) {
|
|||||||
t.Fatalf("Failed to load config: %v", err)
|
t.Fatalf("Failed to load config: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// The interpolation should stop at depth 3
|
// At depth 3, interpolation stops and returns the partial result
|
||||||
|
// So LEVEL3_${ENV:LEVEL4} is used as the literal key, giving "depth3value"
|
||||||
|
// Then LEVEL2_depth3value gives "depth2value"
|
||||||
|
// Finally LEVEL1_depth2value gives "final_with_limit"
|
||||||
value, _ := config.GetString("value")
|
value, _ := config.GetString("value")
|
||||||
// At depth 3, it should have resolved to "final" or stopped
|
if value != "final_with_limit" {
|
||||||
if value == "" {
|
t.Errorf("Expected value to be 'final_with_limit' (recursion limit reached), got '%s'", value)
|
||||||
t.Errorf("Expected some value from recursion limit test")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -337,14 +348,12 @@ func TestUnknownResolver(t *testing.T) {
|
|||||||
config := New()
|
config := New()
|
||||||
content := "value: ${UNKNOWN:test}"
|
content := "value: ${UNKNOWN:test}"
|
||||||
|
|
||||||
|
// Unknown resolver should cause an error
|
||||||
err := config.LoadFromReader(strings.NewReader(content))
|
err := config.LoadFromReader(strings.NewReader(content))
|
||||||
if err != nil {
|
if err == nil {
|
||||||
t.Fatalf("Failed to load config: %v", err)
|
t.Fatal("Expected error for unknown resolver, but got none")
|
||||||
}
|
}
|
||||||
|
if !strings.Contains(err.Error(), "unknown resolver: UNKNOWN") {
|
||||||
// Unknown resolver should leave the value as-is
|
t.Errorf("Expected error to mention unknown resolver UNKNOWN, got: %v", err)
|
||||||
value, _ := config.GetString("value")
|
|
||||||
if value != "${UNKNOWN:test}" {
|
|
||||||
t.Errorf("Expected '${UNKNOWN:test}', got '%s'", value)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
Loading…
Reference in New Issue
Block a user