475 lines
12 KiB
Go
475 lines
12 KiB
Go
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
|
|
}
|