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 }