package config import ( "context" "fmt" "log" "os" "path/filepath" "regexp" "strings" secretmanager "cloud.google.com/go/secretmanager/apiv1" "cloud.google.com/go/secretmanager/apiv1/secretmanagerpb" "github.com/aws/aws-sdk-go/aws" "github.com/aws/aws-sdk-go/aws/session" "github.com/aws/aws-sdk-go/service/secretsmanager" "github.com/spf13/afero" ) // Resolver handles resolution of configuration values with special prefixes. type Resolver struct { gcpProject string awsRegion string gsmClient *secretmanager.Client asmClient *secretsmanager.SecretsManager awsSession *session.Session specialValue *regexp.Regexp fs afero.Fs } // NewResolver creates a new value resolver. func NewResolver(gcpProject, awsRegion string, fs afero.Fs) *Resolver { return &Resolver{ gcpProject: gcpProject, awsRegion: awsRegion, specialValue: regexp.MustCompile(`^\$([A-Z]+):(.+)$`), fs: fs, } } // Resolve resolves a configuration value that may contain special prefixes. func (r *Resolver) Resolve(value interface{}) interface{} { switch v := value.(type) { case string: return r.resolveString(v) case map[string]interface{}: // Recursively resolve map values result := make(map[string]interface{}) for k, val := range v { result[k] = r.Resolve(val) } return result case []interface{}: // Recursively resolve slice items result := make([]interface{}, len(v)) for i, val := range v { result[i] = r.Resolve(val) } return result default: // Return non-string values as-is return value } } // resolveString resolves a string value that may contain a special prefix. func (r *Resolver) resolveString(value string) interface{} { matches := r.specialValue.FindStringSubmatch(value) if matches == nil { return value } resolverType := matches[1] resolverValue := matches[2] switch resolverType { case "ENV": return r.resolveEnv(resolverValue) case "GSM": return r.resolveGSM(resolverValue) case "ASM": return r.resolveASM(resolverValue) case "FILE": return r.resolveFile(resolverValue) default: log.Printf("Unknown resolver type: %s", resolverType) return value } } // resolveEnv resolves an environment variable. func (r *Resolver) resolveEnv(envVar string) interface{} { value := os.Getenv(envVar) if value == "" { return nil } return value } // resolveGSM resolves a Google Secret Manager secret. func (r *Resolver) resolveGSM(secretName string) interface{} { if r.gcpProject == "" { log.Printf("GCP project not configured for GSM resolution") return nil } // Initialize GSM client if needed if r.gsmClient == nil { ctx := context.Background() client, err := secretmanager.NewClient(ctx) if err != nil { log.Printf("Failed to create GSM client: %v", err) return nil } r.gsmClient = client } // Build the resource name name := fmt.Sprintf("projects/%s/secrets/%s/versions/latest", r.gcpProject, secretName) // Access the secret ctx := context.Background() req := &secretmanagerpb.AccessSecretVersionRequest{ Name: name, } result, err := r.gsmClient.AccessSecretVersion(ctx, req) if err != nil { log.Printf("Failed to access GSM secret %s: %v", secretName, err) return nil } return string(result.Payload.Data) } // resolveASM resolves an AWS Secrets Manager secret. func (r *Resolver) resolveASM(secretName string) interface{} { // Initialize AWS session if needed if r.awsSession == nil { sess, err := session.NewSession(&aws.Config{ Region: aws.String(r.awsRegion), }) if err != nil { log.Printf("Failed to create AWS session: %v", err) return nil } r.awsSession = sess } // Initialize ASM client if needed if r.asmClient == nil { r.asmClient = secretsmanager.New(r.awsSession) } // Get the secret value input := &secretsmanager.GetSecretValueInput{ SecretId: aws.String(secretName), } result, err := r.asmClient.GetSecretValue(input) if err != nil { log.Printf("Failed to access ASM secret %s: %v", secretName, err) return nil } // Return the secret string if result.SecretString != nil { return *result.SecretString } // If it's binary data, we can't handle it as a string config value log.Printf("ASM secret %s contains binary data, which is not supported", secretName) return nil } // resolveFile resolves a file's contents. func (r *Resolver) resolveFile(filePath string) interface{} { // Expand user home directory if present if strings.HasPrefix(filePath, "~/") { home, err := os.UserHomeDir() if err != nil { log.Printf("Failed to get user home directory: %v", err) return nil } filePath = filepath.Join(home, filePath[2:]) } data, err := afero.ReadFile(r.fs, filePath) if err != nil { log.Printf("Failed to read file %s: %v", filePath, err) return nil } // Strip whitespace/newlines from file contents return strings.TrimSpace(string(data)) } // Close closes any open clients. func (r *Resolver) Close() error { if r.gsmClient != nil { return r.gsmClient.Close() } return nil }