github.com/grafana/synthetic-monitoring-agent@v0.64.2/internal/prober/interpolation/interpolation.go (about)

     1  package interpolation
     2  
     3  import (
     4  	"context"
     5  	"fmt"
     6  	"regexp"
     7  	"strings"
     8  
     9  	"github.com/grafana/synthetic-monitoring-agent/internal/model"
    10  	"github.com/rs/zerolog"
    11  )
    12  
    13  // VariableRegex matches ${variable_name} patterns
    14  var VariableRegex = regexp.MustCompile(`\$\{([a-zA-Z_][a-zA-Z0-9_-]*)\}`)
    15  
    16  // SecretRegex matches ${secrets.secret_name} patterns
    17  var SecretRegex = regexp.MustCompile(`\$\{secrets\.([^}]*)\}`)
    18  
    19  // VariableProvider defines the interface for resolving variables
    20  type VariableProvider interface {
    21  	GetVariable(name string) (string, error)
    22  }
    23  
    24  // SecretProvider defines the interface for resolving secrets
    25  type SecretProvider interface {
    26  	GetSecretValue(ctx context.Context, tenantID model.GlobalID, secretKey string) (string, error)
    27  }
    28  
    29  // Resolver handles string interpolation for both variables and secrets
    30  type Resolver struct {
    31  	variableProvider VariableProvider
    32  	secretProvider   SecretProvider
    33  	tenantID         model.GlobalID
    34  	logger           zerolog.Logger
    35  	secretEnabled    bool
    36  	variableEnabled  bool
    37  }
    38  
    39  // NewResolver creates a new interpolation resolver
    40  func NewResolver(variableProvider VariableProvider, secretProvider SecretProvider, tenantID model.GlobalID, logger zerolog.Logger, secretEnabled bool) *Resolver {
    41  	return &Resolver{
    42  		variableProvider: variableProvider,
    43  		secretProvider:   secretProvider,
    44  		tenantID:         tenantID,
    45  		logger:           logger,
    46  		secretEnabled:    secretEnabled,
    47  		variableEnabled:  variableProvider != nil,
    48  	}
    49  }
    50  
    51  // Resolve performs string interpolation, replacing both variables and secrets in a single pass
    52  func (r *Resolver) Resolve(ctx context.Context, value string) (string, error) {
    53  	if value == "" {
    54  		return "", nil
    55  	}
    56  
    57  	// If secrets are disabled, just process variables in the entire string
    58  	if !r.secretEnabled {
    59  		return r.processVariables(value), nil
    60  	}
    61  
    62  	// Step 1: Find all secret matches with their positions
    63  	type secretMatch struct {
    64  		start, end  int
    65  		name        string
    66  		placeholder string
    67  	}
    68  
    69  	var secretMatches []secretMatch
    70  
    71  	matches := SecretRegex.FindAllStringSubmatchIndex(value, -1)
    72  	for _, match := range matches {
    73  		if len(match) < 4 {
    74  			continue
    75  		}
    76  
    77  		secretName := value[match[2]:match[3]]
    78  		placeholder := value[match[0]:match[1]]
    79  
    80  		// Validate secret name follows Kubernetes DNS subdomain naming convention
    81  		if !isValidSecretName(secretName) {
    82  			return "", fmt.Errorf("invalid secret name '%s': must follow Kubernetes DNS subdomain naming convention", secretName)
    83  		}
    84  
    85  		secretMatches = append(secretMatches, secretMatch{
    86  			start:       match[0],
    87  			end:         match[1],
    88  			name:        secretName,
    89  			placeholder: placeholder,
    90  		})
    91  	}
    92  
    93  	// Step 2: Split string into parts and process each part
    94  	var result strings.Builder
    95  
    96  	lastPos := 0
    97  
    98  	for _, secretMatch := range secretMatches {
    99  		// Process the part before this secret (non-secret part)
   100  		nonSecretPart := value[lastPos:secretMatch.start]
   101  		if nonSecretPart != "" {
   102  			processedPart := r.processVariables(nonSecretPart)
   103  			result.WriteString(processedPart)
   104  		}
   105  
   106  		// Process the secret part
   107  		r.logger.Debug().Str("secretName", secretMatch.name).Int64("tenantId", int64(r.tenantID)).Msg("resolving secret from GSM")
   108  
   109  		secretValue, err := r.secretProvider.GetSecretValue(ctx, r.tenantID, secretMatch.name)
   110  		if err != nil {
   111  			return "", fmt.Errorf("failed to get secret '%s' from GSM: %w", secretMatch.name, err)
   112  		}
   113  
   114  		result.WriteString(secretValue)
   115  
   116  		lastPos = secretMatch.end
   117  	}
   118  
   119  	// Process the remaining part after the last secret (non-secret part)
   120  	remainingPart := value[lastPos:]
   121  	if remainingPart != "" {
   122  		processedPart := r.processVariables(remainingPart)
   123  		result.WriteString(processedPart)
   124  	}
   125  
   126  	return result.String(), nil
   127  }
   128  
   129  // processVariables resolves ${variable_name} patterns in a string
   130  func (r *Resolver) processVariables(value string) string {
   131  	if !r.variableEnabled {
   132  		return value
   133  	}
   134  
   135  	result := value
   136  
   137  	variableMatches := VariableRegex.FindAllStringSubmatch(result, -1)
   138  	for _, match := range variableMatches {
   139  		if len(match) < 2 {
   140  			continue
   141  		}
   142  
   143  		varName := match[1]
   144  		placeholder := match[0] // ${variable_name}
   145  
   146  		varValue, err := r.variableProvider.GetVariable(varName)
   147  		if err != nil {
   148  			// If variable is not found, leave the placeholder as-is
   149  			// This allows for backward compatibility and flexible configuration
   150  			continue
   151  		}
   152  
   153  		// Replace the placeholder with the actual variable value
   154  		result = strings.ReplaceAll(result, placeholder, varValue)
   155  	}
   156  
   157  	return result
   158  }
   159  
   160  // isValidSecretName validates that a secret name follows Kubernetes DNS subdomain naming convention.
   161  // See: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#dns-subdomain-names
   162  func isValidSecretName(name string) bool {
   163  	if len(name) == 0 || len(name) > 253 {
   164  		return false
   165  	}
   166  
   167  	// Must consist of lowercase alphanumeric characters, '-' or '.', and must start and end with an alphanumeric character
   168  	if !regexp.MustCompile(`^[a-z0-9]([a-z0-9\-\.]*[a-z0-9])?$`).MatchString(name) {
   169  		return false
   170  	}
   171  
   172  	return true
   173  }
   174  
   175  // ToJavaScript converts a string with variable interpolation to JavaScript code
   176  // This is used by multihttp to generate JavaScript that references variables
   177  func ToJavaScript(value string) string {
   178  	if len(value) == 0 {
   179  		return `''`
   180  	}
   181  
   182  	var s strings.Builder
   183  
   184  	buf := []byte(value)
   185  	locs := VariableRegex.FindAllSubmatchIndex(buf, -1)
   186  
   187  	p := 0
   188  
   189  	for _, loc := range locs {
   190  		if len(loc) < 4 { // put the bounds checker at ease
   191  			panic("unexpected result while building JavaScript")
   192  		}
   193  
   194  		if s.Len() > 0 {
   195  			s.WriteRune('+')
   196  		}
   197  
   198  		if pre := buf[p:loc[0]]; len(pre) > 0 {
   199  			s.WriteRune('\'')
   200  			escapeJavaScript(&s, pre)
   201  			s.WriteRune('\'')
   202  			s.WriteRune('+')
   203  		}
   204  
   205  		s.WriteString(`vars['`)
   206  		// Because of the capture in the regular expression, the result
   207  		// has two indices that represent the matched substring, and
   208  		// two more indices that represent the capture group.
   209  		s.Write(buf[loc[2]:loc[3]])
   210  		s.WriteString(`']`)
   211  
   212  		p = loc[1]
   213  	}
   214  
   215  	if len(buf[p:]) > 0 {
   216  		if s.Len() > 0 {
   217  			s.WriteRune('+')
   218  		}
   219  
   220  		s.WriteRune('\'')
   221  		escapeJavaScript(&s, buf[p:])
   222  		s.WriteRune('\'')
   223  	}
   224  
   225  	return s.String()
   226  }
   227  
   228  // escapeJavaScript escapes a byte slice for use in JavaScript strings
   229  func escapeJavaScript(s *strings.Builder, buf []byte) {
   230  	for _, b := range buf {
   231  		switch b {
   232  		case '\'':
   233  			s.WriteString(`\'`)
   234  		case '"':
   235  			s.WriteString(`\"`)
   236  		case '\\':
   237  			s.WriteString(`\\`)
   238  		case '\n':
   239  			s.WriteString(`\n`)
   240  		case '\r':
   241  			s.WriteString(`\r`)
   242  		case '\t':
   243  			s.WriteString(`\t`)
   244  		default:
   245  			if b < 32 || b > 126 {
   246  				// Escape non-printable characters
   247  				fmt.Fprintf(s, `\x%02x`, b)
   248  			} else {
   249  				s.WriteByte(b)
   250  			}
   251  		}
   252  	}
   253  }