github.com/grafana/synthetic-monitoring-agent@v0.64.2/internal/prober/multihttp/script.go (about) 1 package multihttp 2 3 import ( 4 "bytes" 5 "embed" 6 "encoding/base64" 7 "fmt" 8 "regexp" 9 "strings" 10 "text/template" 11 12 sm "github.com/grafana/synthetic-monitoring-agent/pkg/pb/synthetic_monitoring" 13 ) 14 15 // embed script template 16 // 17 //go:embed script.tmpl 18 var templateFS embed.FS 19 20 var userVariables = regexp.MustCompile(`\$\{([a-zA-Z_][a-zA-Z0-9_]*)\}`) 21 22 func performVariableExpansion(in string) string { 23 if len(in) == 0 { 24 return `''` 25 } 26 27 var s strings.Builder 28 29 buf := []byte(in) 30 locs := userVariables.FindAllSubmatchIndex(buf, -1) 31 32 p := 0 33 34 for _, loc := range locs { 35 if len(loc) < 4 { // put the bounds checker at ease 36 panic("unexpected result while building URL") 37 } 38 39 if s.Len() > 0 { 40 s.WriteRune('+') 41 } 42 43 if pre := buf[p:loc[0]]; len(pre) > 0 { 44 s.WriteRune('\'') 45 template.JSEscape(&s, pre) 46 s.WriteRune('\'') 47 s.WriteRune('+') 48 } 49 50 s.WriteString(`vars['`) 51 // Because of the capture in the regular expression, the result 52 // has two indices that represent the matched substring, and 53 // two more indices that represent the capture group. 54 s.Write(buf[loc[2]:loc[3]]) 55 s.WriteString(`']`) 56 57 p = loc[1] 58 } 59 60 if len(buf[p:]) > 0 { 61 if s.Len() > 0 { 62 s.WriteRune('+') 63 } 64 65 s.WriteRune('\'') 66 template.JSEscape(&s, buf[p:]) 67 s.WriteRune('\'') 68 } 69 70 return s.String() 71 } 72 73 // Query params must be appended to a URL that has already been created. 74 // urlVarName is the variable name to reference when appending params. 75 func buildQueryParams(urlVarName string, req *sm.MultiHttpEntryRequest) []string { 76 var buf strings.Builder 77 78 out := make([]string, 0, len(req.QueryFields)) 79 for _, field := range req.QueryFields { 80 buf.Reset() 81 buf.WriteString(urlVarName) 82 buf.WriteString(".searchParams.append(") 83 buf.WriteString(performVariableExpansion(field.Name)) 84 buf.WriteString(", ") 85 buf.WriteString(performVariableExpansion(field.Value)) 86 buf.WriteString(")") 87 out = append(out, buf.String()) 88 } 89 90 return out 91 } 92 93 func buildBody(body *sm.HttpRequestBody) string { 94 switch { 95 case body == nil: 96 return "null" 97 98 case len(body.Payload) == 0: 99 return `""` 100 101 default: 102 var buf strings.Builder 103 104 buf.WriteString(`encoding.b64decode("`) 105 buf.WriteString(base64.RawStdEncoding.EncodeToString(body.Payload)) 106 buf.WriteString(`", 'rawstd', "s")`) 107 108 return buf.String() 109 } 110 } 111 112 func interpolateBodyVariables(bodyVarName string, body *sm.HttpRequestBody) []string { 113 switch { 114 case body == nil || len(body.Payload) == 0: 115 return nil 116 117 default: 118 var buf strings.Builder 119 120 matches := userVariables.FindAllString(string(body.Payload), -1) 121 parsedMatches := make(map[string]struct{}) 122 out := make([]string, 0, len(matches)) 123 124 // For every instance of ${variable} in the body, 125 // this block returns {bodyVarName} = {bodyVarName}.replaceAll('${variable}', vars['variable']) 126 for _, m := range matches { 127 if _, found := parsedMatches[m]; found { 128 continue 129 } 130 131 buf.Reset() 132 buf.WriteString(bodyVarName) 133 buf.WriteString("=") 134 buf.WriteString(bodyVarName) 135 buf.WriteString(".replaceAll('") 136 buf.WriteString(m) 137 buf.WriteString("', vars['") 138 // writing the variable name from between ${ and } 139 for i := 2; i < len(m)-1; i++ { 140 buf.WriteByte(m[i]) 141 } 142 143 buf.WriteString("'])") 144 out = append(out, buf.String()) 145 146 parsedMatches[m] = struct{}{} 147 } 148 149 return out 150 } 151 } 152 153 func buildHeaders(headers []*sm.HttpHeader, body *sm.HttpRequestBody) string { 154 var buf strings.Builder 155 156 if len(headers) == 0 && body == nil { 157 return "" 158 } 159 160 buf.WriteRune('{') 161 162 comma := "" 163 164 if body != nil { 165 if len(body.ContentType) > 0 { 166 buf.WriteString(`'Content-Type':"`) 167 buf.WriteString(template.JSEscapeString(body.ContentType)) 168 buf.WriteRune('"') 169 170 comma = "," 171 } 172 173 if len(body.ContentEncoding) > 0 { 174 buf.WriteString(comma) 175 176 buf.WriteString(`'Content-Encoding':"`) 177 buf.WriteString(template.JSEscapeString(body.ContentEncoding)) 178 buf.WriteRune('"') 179 180 comma = "," 181 } 182 } 183 184 for _, header := range headers { 185 buf.WriteString(comma) 186 187 buf.WriteRune('"') 188 buf.WriteString(template.JSEscapeString(header.Name)) 189 buf.WriteString(`":`) 190 buf.WriteString(performVariableExpansion(header.Value)) 191 192 comma = "," 193 } 194 195 buf.WriteRune('}') 196 197 return buf.String() 198 } 199 200 type assertionCondition sm.MultiHttpEntryAssertionConditionVariant 201 202 func (c assertionCondition) Name(w *strings.Builder, subject, value string) { 203 w.WriteString(template.JSEscapeString(subject)) 204 w.WriteRune(' ') 205 206 switch sm.MultiHttpEntryAssertionConditionVariant(c) { 207 case sm.MultiHttpEntryAssertionConditionVariant_NOT_CONTAINS: 208 w.WriteString(`does not contain`) 209 210 case sm.MultiHttpEntryAssertionConditionVariant_CONTAINS, sm.MultiHttpEntryAssertionConditionVariant_DEFAULT_CONDITION: 211 w.WriteString(`contains`) 212 213 case sm.MultiHttpEntryAssertionConditionVariant_EQUALS: 214 w.WriteString(`equals`) 215 216 case sm.MultiHttpEntryAssertionConditionVariant_STARTS_WITH: 217 w.WriteString(`starts with`) 218 219 case sm.MultiHttpEntryAssertionConditionVariant_ENDS_WITH: 220 w.WriteString(`ends with`) 221 } 222 223 w.WriteString(` \"`) 224 w.WriteString(template.JSEscapeString(value)) 225 w.WriteString(`\"`) 226 } 227 228 func (c assertionCondition) Render(w *strings.Builder, subject, value string) { 229 switch sm.MultiHttpEntryAssertionConditionVariant(c) { 230 case sm.MultiHttpEntryAssertionConditionVariant_NOT_CONTAINS: 231 w.WriteRune('!') 232 fallthrough 233 234 case sm.MultiHttpEntryAssertionConditionVariant_CONTAINS, sm.MultiHttpEntryAssertionConditionVariant_DEFAULT_CONDITION: 235 w.WriteString(subject) 236 w.WriteString(`.includes("`) 237 w.WriteString(template.JSEscapeString(value)) 238 w.WriteString(`")`) 239 240 case sm.MultiHttpEntryAssertionConditionVariant_EQUALS: 241 w.WriteString(subject) 242 w.WriteString(` === "`) 243 w.WriteString(template.JSEscapeString(value)) 244 w.WriteString(`"`) 245 246 case sm.MultiHttpEntryAssertionConditionVariant_STARTS_WITH: 247 w.WriteString(subject) 248 w.WriteString(`.startsWith("`) 249 w.WriteString(template.JSEscapeString(value)) 250 w.WriteString(`")`) 251 252 case sm.MultiHttpEntryAssertionConditionVariant_ENDS_WITH: 253 w.WriteString(subject) 254 w.WriteString(`.endsWith("`) 255 w.WriteString(template.JSEscapeString(value)) 256 w.WriteString(`")`) 257 } 258 } 259 260 // buildChecks takes a single assertion and produces the corresponding JavaScript code. 261 // 262 // This function is a mess because a single assertion represents multiple types 263 // of checks. 264 func buildChecks(urlVarName, method string, assertion *sm.MultiHttpEntryAssertion) string { 265 var ( 266 b strings.Builder 267 assertionDescriptor strings.Builder 268 ) 269 270 b.WriteString(`currentCheck = check(response, { "`) 271 272 switch assertion.Type { 273 case sm.MultiHttpEntryAssertionType_TEXT: 274 cond := assertionCondition(assertion.Condition) 275 276 switch assertion.Subject { 277 case sm.MultiHttpEntryAssertionSubjectVariant_RESPONSE_BODY, sm.MultiHttpEntryAssertionSubjectVariant_DEFAULT_SUBJECT: 278 cond.Name(&b, "body", assertion.Value) 279 b.WriteString(`": response => `) 280 cond.Render(&b, "response.body", assertion.Value) 281 cond.Render(&assertionDescriptor, "response.body", assertion.Value) 282 283 case sm.MultiHttpEntryAssertionSubjectVariant_RESPONSE_HEADERS: 284 cond.Name(&b, "header", assertion.Value) 285 b.WriteString(`": response => { `) 286 287 if len(assertion.Expression) == 0 { 288 // No expression provided, match the entire value against all headers. 289 b.WriteString(`const values = Object.entries(response.headers).map(header => header[0].toLowerCase() + ': ' + header[1]); `) 290 b.WriteString(`return !!values.find(value => `) 291 cond.Render(&b, "value", assertion.Value) 292 cond.Render(&assertionDescriptor, "value", assertion.Value) 293 b.WriteString(`);`) 294 } else { 295 // Expression provided, search for a matching header. 296 b.WriteString(`return assertHeader(response.headers, "`) 297 b.WriteString(template.JSEscapeString(assertion.Expression)) 298 b.WriteString(`", `) 299 b.WriteString(`v => `) 300 cond.Render(&b, "value", assertion.Value) 301 cond.Render(&assertionDescriptor, "value", assertion.Value) 302 b.WriteString(`);`) 303 } 304 305 b.WriteString(` }`) 306 307 case sm.MultiHttpEntryAssertionSubjectVariant_HTTP_STATUS_CODE: 308 cond.Name(&b, "status code", assertion.Value) 309 b.WriteString(`": response => `) 310 cond.Render(&b, `response.status.toString()`, assertion.Value) 311 cond.Render(&assertionDescriptor, `response.status.toString()`, assertion.Value) 312 } 313 314 case sm.MultiHttpEntryAssertionType_JSON_PATH_VALUE: 315 cond := assertionCondition(assertion.Condition) 316 cond.Name(&b, assertion.Expression, assertion.Value) 317 b.WriteString(`": response => jsonpath.query(response.json(), "`) 318 b.WriteString(template.JSEscapeString(assertion.Expression)) 319 b.WriteString(`").some(values => `) 320 cond.Render(&b, `values`, assertion.Value) 321 cond.Render(&assertionDescriptor, `values`, assertion.Value) 322 b.WriteString(`)`) 323 324 case sm.MultiHttpEntryAssertionType_JSON_PATH_ASSERTION: 325 b.WriteString(template.JSEscapeString(assertion.Expression)) 326 b.WriteString(` exists": response => jsonpath.query(response.json(), "`) 327 b.WriteString(template.JSEscapeString(assertion.Expression)) 328 assertionDescriptor.WriteString(`JsonPath expression `) 329 assertionDescriptor.WriteString(assertion.Expression) 330 b.WriteString(`").length > 0`) 331 332 case sm.MultiHttpEntryAssertionType_REGEX_ASSERTION: 333 switch assertion.Subject { 334 case sm.MultiHttpEntryAssertionSubjectVariant_RESPONSE_BODY, sm.MultiHttpEntryAssertionSubjectVariant_DEFAULT_SUBJECT: 335 b.WriteString(`body matches /`) 336 b.WriteString(template.JSEscapeString(assertion.Expression)) 337 b.WriteString(`/": response => { const expr = new RegExp("`) 338 b.WriteString(template.JSEscapeString(assertion.Expression)) 339 b.WriteString(`"); `) 340 b.WriteString(`return expr.test(response.body); }`) 341 assertionDescriptor.WriteString("Body matches") 342 assertionDescriptor.WriteString(assertion.Expression) 343 344 case sm.MultiHttpEntryAssertionSubjectVariant_RESPONSE_HEADERS: 345 b.WriteString(`headers matches /`) 346 b.WriteString(template.JSEscapeString(assertion.Expression)) 347 b.WriteString(`/": response => { const expr = new RegExp("`) 348 b.WriteString(template.JSEscapeString(assertion.Expression)) 349 b.WriteString(`"); `) 350 b.WriteString(`const values = Object.entries(response.headers).map(header => header[0].toLowerCase() + ': ' + header[1]); `) 351 b.WriteString(`return !!values.find(value => expr.test(value)); }`) 352 assertionDescriptor.WriteString("Headers match") 353 assertionDescriptor.WriteString(assertion.Expression) 354 355 case sm.MultiHttpEntryAssertionSubjectVariant_HTTP_STATUS_CODE: 356 b.WriteString(`status matches /`) 357 b.WriteString(template.JSEscapeString(assertion.Expression)) 358 b.WriteString(`/": response => { const expr = new RegExp("`) 359 b.WriteString(template.JSEscapeString(assertion.Expression)) 360 b.WriteString(`"); `) 361 b.WriteString(`return expr.test(response.status.toString()); }`) 362 assertionDescriptor.WriteString("Status matches") 363 assertionDescriptor.WriteString(assertion.Expression) 364 } 365 } 366 367 b.WriteString(` }`) 368 b.WriteString(`, `) 369 370 // Add tags to the check: url, method 371 b.WriteString(`{`) 372 b.WriteString(`"url": `) 373 b.WriteString(urlVarName) 374 b.WriteString(`.toString(), `) 375 b.WriteString(`"method": "`) 376 b.WriteString(method) 377 b.WriteRune('"') 378 b.WriteString(`}`) 379 380 b.WriteString(`);`) 381 382 b.WriteString("\n\t") 383 b.WriteString(`if(!currentCheck) {`) 384 b.WriteString("\n\t\t") 385 b.WriteString(`console.error("Assertion failed:", "`) 386 b.WriteString(template.JSEscapeString(assertionDescriptor.String())) 387 b.WriteString(`");`) 388 b.WriteString("\n\t\t") 389 b.WriteString(`fail()`) 390 b.WriteString("\n\t") 391 b.WriteString(`};`) 392 b.WriteString("\n") 393 394 return b.String() 395 } 396 397 func buildVars(variable *sm.MultiHttpEntryVariable) string { 398 var b strings.Builder 399 400 if variable.Type == sm.MultiHttpEntryVariableType_REGEX { 401 b.WriteString(`match = new RegExp('`) 402 b.WriteString(template.JSEscapeString(variable.Expression)) 403 b.WriteString(`').exec(response.body); `) 404 } 405 406 b.WriteString(`vars['`) 407 b.WriteString(template.JSEscapeString(variable.Name)) 408 b.WriteString(`'] = `) 409 410 switch variable.Type { 411 case sm.MultiHttpEntryVariableType_JSON_PATH: 412 b.WriteString(`jsonpath.query(response.json(), '`) 413 b.WriteString(template.JSEscapeString(variable.Expression)) 414 b.WriteString(`')[0]`) 415 416 case sm.MultiHttpEntryVariableType_REGEX: 417 b.WriteString(`match ? match[1] || match[0] : null`) 418 419 case sm.MultiHttpEntryVariableType_CSS_SELECTOR: 420 b.WriteString(`response.html('`) 421 b.WriteString(template.JSEscapeString(variable.Expression)) 422 b.WriteString(`')`) 423 424 if variable.Attribute == "" { 425 b.WriteString(`.html()`) 426 } else { 427 b.WriteString(`.first().attr('`) 428 b.WriteString(template.JSEscapeString(variable.Attribute)) 429 b.WriteString(`')`) 430 } 431 } 432 433 b.WriteString(`;`) 434 435 return b.String() 436 } 437 438 func settingsToScript(settings *sm.MultiHttpSettings) ([]byte, error) { 439 // Convert settings to script using a Go template 440 tmpl, err := template. 441 New(""). 442 Funcs(template.FuncMap{ 443 "buildBody": buildBody, 444 "buildChecks": buildChecks, 445 "buildHeaders": buildHeaders, 446 "buildUrl": performVariableExpansion, 447 "buildQueryParams": buildQueryParams, 448 "buildVars": buildVars, 449 "interpolateBodyVars": interpolateBodyVariables, 450 }). 451 ParseFS(templateFS, "*.tmpl") 452 if err != nil { 453 return nil, fmt.Errorf("parsing script template: %w", err) 454 } 455 456 var buf bytes.Buffer 457 458 // TODO(mem): figure out if we need to transform the data in some way 459 // before executing the template 460 if err := tmpl.ExecuteTemplate(&buf, "script.tmpl", settings); err != nil { 461 return nil, fmt.Errorf("executing script template: %w", err) 462 } 463 464 return buf.Bytes(), nil 465 }