205 lines
4.9 KiB
Go
205 lines
4.9 KiB
Go
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
|
|
}
|