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  }