VictoriaMetrics/app/vmalert/templates/template.go
Aliaksandr Valialkin a018b1d75e
app/vmalert/templates: properly escape all the special chars in quotesEscape function
Previously the `quotesEscape` function was escaping only double quotes.
This wasn't enough, since the input string could contain other special chars,
which must be escaped when put inside JSON string. For example, carriage return and line feed chars (\n\r),
backslash char, etc. This led to the following issues, which were improperly fixed:

- https://github.com/VictoriaMetrics/VictoriaMetrics/issues/890 - this issue
  was "fixed" by introducing the `crlfEscape` function, which led to unnecessary
  complications in user templates, while not fixing various corner cases
  such as backslash chars in the input string.
  See 1de15ad490

- https://github.com/VictoriaMetrics/VictoriaMetrics/issues/3139 - this issue
  was "fixed" by urlencoding the whole string passed to -external.alert.source
  command-line flag. This led to invalid urls, which couldn't be parsed by Grafana.
  See 00c838353d
  and 4bd0244599

This commit properly encodes the input string passed to `quotesEscape`, so it can be safely embedded inside JSON strings.

This commit deprecates crlfEscape template function and adds the following new template functions:

- strvalue and stripDomain - these functions are supported by Prometheus, so they were added
  for compatibility purposes.
- jsonEscape and htmlEscape for converting the input string to valid quoted JSON string
  and for html-escaping the input string, so it could be safely embedded as a plaintext
  into html.

This commit also documents all supported template functions at https://docs.victoriametrics.com/vmalert.html#template-functions
The deprecated crlfEscape function isn't documented on purpose, since its usefulness is negative in general case.
2022-10-28 00:01:16 +03:00

594 lines
17 KiB
Go

// Copyright 2013 The Prometheus Authors
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package templates
import (
"errors"
"fmt"
htmlTpl "html/template"
"io"
"math"
"net"
"net/url"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
"sync"
"time"
textTpl "text/template"
"github.com/VictoriaMetrics/VictoriaMetrics/app/vmalert/datasource"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/promutils"
)
// go template execution fails when it's tree is empty
const defaultTemplate = `{{- define "default.template" -}}{{- end -}}`
var tplMu sync.RWMutex
type textTemplate struct {
current *textTpl.Template
replacement *textTpl.Template
}
var masterTmpl textTemplate
func newTemplate() *textTpl.Template {
tmpl := textTpl.New("").Option("missingkey=zero").Funcs(templateFuncs())
return textTpl.Must(tmpl.Parse(defaultTemplate))
}
// Load func loads templates from multiple globs specified in pathPatterns and either
// sets them directly to current template if it's undefined or with overwrite=true
// or sets replacement templates and adds templates with new names to a current
func Load(pathPatterns []string, overwrite bool) error {
var err error
tmpl := newTemplate()
for _, tp := range pathPatterns {
p, err := filepath.Glob(tp)
if err != nil {
return fmt.Errorf("failed to retrieve a template glob %q: %w", tp, err)
}
if len(p) > 0 {
tmpl, err = tmpl.ParseGlob(tp)
if err != nil {
return fmt.Errorf("failed to parse template glob %q: %w", tp, err)
}
}
}
if len(tmpl.Templates()) > 0 {
err := tmpl.Execute(io.Discard, nil)
if err != nil {
return fmt.Errorf("failed to execute template: %w", err)
}
}
tplMu.Lock()
defer tplMu.Unlock()
if masterTmpl.current == nil || overwrite {
masterTmpl.replacement = nil
masterTmpl.current = newTemplate()
} else {
masterTmpl.replacement = newTemplate()
if err = copyTemplates(tmpl, masterTmpl.replacement, overwrite); err != nil {
return err
}
}
return copyTemplates(tmpl, masterTmpl.current, overwrite)
}
func copyTemplates(from *textTpl.Template, to *textTpl.Template, overwrite bool) error {
if from == nil {
return nil
}
if to == nil {
to = newTemplate()
}
tmpl, err := from.Clone()
if err != nil {
return err
}
for _, t := range tmpl.Templates() {
if to.Lookup(t.Name()) == nil || overwrite {
to, err = to.AddParseTree(t.Name(), t.Tree)
if err != nil {
return fmt.Errorf("failed to add template %q: %w", t.Name(), err)
}
}
}
return nil
}
// Reload func replaces current template with a replacement template
// which was set by Load with override=false
func Reload() {
tplMu.Lock()
defer tplMu.Unlock()
if masterTmpl.replacement != nil {
masterTmpl.current = masterTmpl.replacement
masterTmpl.replacement = nil
}
}
// metric is private copy of datasource.Metric,
// it is used for templating annotations,
// Labels as map simplifies templates evaluation.
type metric struct {
Labels map[string]string
Timestamp int64
Value float64
}
// datasourceMetricsToTemplateMetrics converts Metrics from datasource package to private copy for templating.
func datasourceMetricsToTemplateMetrics(ms []datasource.Metric) []metric {
mss := make([]metric, 0, len(ms))
for _, m := range ms {
labelsMap := make(map[string]string, len(m.Labels))
for _, labelValue := range m.Labels {
labelsMap[labelValue.Name] = labelValue.Value
}
mss = append(mss, metric{
Labels: labelsMap,
Timestamp: m.Timestamps[0],
Value: m.Values[0]})
}
return mss
}
// QueryFn is used to wrap a call to datasource into simple-to-use function
// for templating functions.
type QueryFn func(query string) ([]datasource.Metric, error)
// UpdateWithFuncs updates existing or sets a new function map for a template
func UpdateWithFuncs(funcs textTpl.FuncMap) {
tplMu.Lock()
defer tplMu.Unlock()
masterTmpl.current = masterTmpl.current.Funcs(funcs)
}
// GetWithFuncs returns a copy of current template with additional FuncMap
// provided with funcs argument
func GetWithFuncs(funcs textTpl.FuncMap) (*textTpl.Template, error) {
tplMu.RLock()
defer tplMu.RUnlock()
tmpl, err := masterTmpl.current.Clone()
if err != nil {
return nil, err
}
return tmpl.Funcs(funcs), nil
}
// Get returns a copy of a template
func Get() (*textTpl.Template, error) {
tplMu.RLock()
defer tplMu.RUnlock()
return masterTmpl.current.Clone()
}
// FuncsWithQuery returns a function map that depends on metric data
func FuncsWithQuery(query QueryFn) textTpl.FuncMap {
return textTpl.FuncMap{
"query": func(q string) ([]metric, error) {
result, err := query(q)
if err != nil {
return nil, err
}
return datasourceMetricsToTemplateMetrics(result), nil
},
}
}
// FuncsWithExternalURL returns a function map that depends on externalURL value
func FuncsWithExternalURL(externalURL *url.URL) textTpl.FuncMap {
return textTpl.FuncMap{
"externalURL": func() string {
return externalURL.String()
},
"pathPrefix": func() string {
return externalURL.Path
},
}
}
// templateFuncs initiates template helper functions
func templateFuncs() textTpl.FuncMap {
// See https://prometheus.io/docs/prometheus/latest/configuration/template_reference/
// and https://github.com/prometheus/prometheus/blob/fa6e05903fd3ce52e374a6e1bf4eb98c9f1f45a7/template/template.go#L150
return textTpl.FuncMap{
/* Strings */
// title returns a copy of the string s with all Unicode letters
// that begin words mapped to their Unicode title case.
// alias for https://golang.org/pkg/strings/#Title
"title": strings.Title,
// toUpper returns s with all Unicode letters mapped to their upper case.
// alias for https://golang.org/pkg/strings/#ToUpper
"toUpper": strings.ToUpper,
// toLower returns s with all Unicode letters mapped to their lower case.
// alias for https://golang.org/pkg/strings/#ToLower
"toLower": strings.ToLower,
// crlfEscape replaces '\n' and '\r' chars with `\\n` and `\\r`.
// This funcion is deprectated.
//
// It is better to use quotesEscape, jsonEscape, queryEscape or pathEscape instead -
// these functions properly escape `\n` and `\r` chars according to their purpose.
"crlfEscape": func(q string) string {
q = strings.Replace(q, "\n", `\n`, -1)
return strings.Replace(q, "\r", `\r`, -1)
},
// quotesEscape escapes the string, so it can be safely put inside JSON string.
//
// See also jsonEscape.
"quotesEscape": quotesEscape,
// jsonEscape converts the string to properly encoded JSON string.
//
// See also quotesEscape.
"jsonEscape": jsonEscape,
// htmlEscape applies html-escaping to q, so it can be safely embedded as plaintext into html.
//
// See also safeHtml.
"htmlEscape": htmlEscape,
// stripPort splits string into host and port, then returns only host.
"stripPort": func(hostPort string) string {
host, _, err := net.SplitHostPort(hostPort)
if err != nil {
return hostPort
}
return host
},
// stripDomain removes the domain part of a FQDN. Leaves port untouched.
"stripDomain": func(hostPort string) string {
host, port, err := net.SplitHostPort(hostPort)
if err != nil {
host = hostPort
}
ip := net.ParseIP(host)
if ip != nil {
return hostPort
}
host = strings.Split(host, ".")[0]
if port != "" {
return net.JoinHostPort(host, port)
}
return host
},
// match reports whether the string s
// contains any match of the regular expression pattern.
// alias for https://golang.org/pkg/regexp/#MatchString
"match": regexp.MatchString,
// reReplaceAll ReplaceAllString returns a copy of src, replacing matches of the Regexp with
// the replacement string repl. Inside repl, $ signs are interpreted as in Expand,
// so for instance $1 represents the text of the first submatch.
// alias for https://golang.org/pkg/regexp/#Regexp.ReplaceAllString
"reReplaceAll": func(pattern, repl, text string) string {
re := regexp.MustCompile(pattern)
return re.ReplaceAllString(text, repl)
},
// parseDuration parses a duration string such as "1h" into the number of seconds it represents
"parseDuration": func(s string) (float64, error) {
d, err := promutils.ParseDuration(s)
if err != nil {
return 0, err
}
return d.Seconds(), nil
},
// same with parseDuration but returns a time.Duration
"parseDurationTime": func(s string) (time.Duration, error) {
d, err := promutils.ParseDuration(s)
if err != nil {
return 0, err
}
return d, nil
},
/* Numbers */
// humanize converts given number to a human readable format
// by adding metric prefixes https://en.wikipedia.org/wiki/Metric_prefix
"humanize": func(i interface{}) (string, error) {
v, err := toFloat64(i)
if err != nil {
return "", err
}
if v == 0 || math.IsNaN(v) || math.IsInf(v, 0) {
return fmt.Sprintf("%.4g", v), nil
}
if math.Abs(v) >= 1 {
prefix := ""
for _, p := range []string{"k", "M", "G", "T", "P", "E", "Z", "Y"} {
if math.Abs(v) < 1000 {
break
}
prefix = p
v /= 1000
}
return fmt.Sprintf("%.4g%s", v, prefix), nil
}
prefix := ""
for _, p := range []string{"m", "u", "n", "p", "f", "a", "z", "y"} {
if math.Abs(v) >= 1 {
break
}
prefix = p
v *= 1000
}
return fmt.Sprintf("%.4g%s", v, prefix), nil
},
// humanize1024 converts given number to a human readable format with 1024 as base
"humanize1024": func(i interface{}) (string, error) {
v, err := toFloat64(i)
if err != nil {
return "", err
}
if math.Abs(v) <= 1 || math.IsNaN(v) || math.IsInf(v, 0) {
return fmt.Sprintf("%.4g", v), nil
}
prefix := ""
for _, p := range []string{"ki", "Mi", "Gi", "Ti", "Pi", "Ei", "Zi", "Yi"} {
if math.Abs(v) < 1024 {
break
}
prefix = p
v /= 1024
}
return fmt.Sprintf("%.4g%s", v, prefix), nil
},
// humanizeDuration converts given seconds to a human readable duration
"humanizeDuration": func(i interface{}) (string, error) {
v, err := toFloat64(i)
if err != nil {
return "", err
}
if math.IsNaN(v) || math.IsInf(v, 0) {
return fmt.Sprintf("%.4g", v), nil
}
if v == 0 {
return fmt.Sprintf("%.4gs", v), nil
}
if math.Abs(v) >= 1 {
sign := ""
if v < 0 {
sign = "-"
v = -v
}
seconds := int64(v) % 60
minutes := (int64(v) / 60) % 60
hours := (int64(v) / 60 / 60) % 24
days := int64(v) / 60 / 60 / 24
// For days to minutes, we display seconds as an integer.
if days != 0 {
return fmt.Sprintf("%s%dd %dh %dm %ds", sign, days, hours, minutes, seconds), nil
}
if hours != 0 {
return fmt.Sprintf("%s%dh %dm %ds", sign, hours, minutes, seconds), nil
}
if minutes != 0 {
return fmt.Sprintf("%s%dm %ds", sign, minutes, seconds), nil
}
// For seconds, we display 4 significant digits.
return fmt.Sprintf("%s%.4gs", sign, v), nil
}
prefix := ""
for _, p := range []string{"m", "u", "n", "p", "f", "a", "z", "y"} {
if math.Abs(v) >= 1 {
break
}
prefix = p
v *= 1000
}
return fmt.Sprintf("%.4g%ss", v, prefix), nil
},
// humanizePercentage converts given ratio value to a fraction of 100
"humanizePercentage": func(i interface{}) (string, error) {
v, err := toFloat64(i)
if err != nil {
return "", err
}
return fmt.Sprintf("%.4g%%", v*100), nil
},
// humanizeTimestamp converts given timestamp to a human readable time equivalent
"humanizeTimestamp": func(i interface{}) (string, error) {
v, err := toFloat64(i)
if err != nil {
return "", err
}
if math.IsNaN(v) || math.IsInf(v, 0) {
return fmt.Sprintf("%.4g", v), nil
}
t := timeFromUnixTimestamp(v).Time().UTC()
return fmt.Sprint(t), nil
},
// toTime converts given timestamp to a time.Time.
"toTime": func(i interface{}) (time.Time, error) {
v, err := toFloat64(i)
if err != nil {
return time.Time{}, err
}
if math.IsNaN(v) || math.IsInf(v, 0) {
return time.Time{}, fmt.Errorf("cannot convert %v to time.Time", v)
}
t := timeFromUnixTimestamp(v).Time().UTC()
return t, nil
},
/* URLs */
// externalURL returns value of `external.url` flag
"externalURL": func() string {
// externalURL function supposed to be substituted at FuncsWithExteralURL().
// it is present here only for validation purposes, when there is no
// provided datasource.
//
// return non-empty slice to pass validation with chained functions in template
return ""
},
// pathPrefix returns a Path segment from the URL value in `external.url` flag
"pathPrefix": func() string {
// pathPrefix function supposed to be substituted at FuncsWithExteralURL().
// it is present here only for validation purposes, when there is no
// provided datasource.
//
// return non-empty slice to pass validation with chained functions in template
return ""
},
// pathEscape escapes the string so it can be safely placed inside a URL path segment.
//
// See also queryEscape.
"pathEscape": url.PathEscape,
// queryEscape escapes the string so it can be safely placed inside a query arg in URL.
//
// See also queryEscape.
"queryEscape": url.QueryEscape,
// query executes the MetricsQL/PromQL query against
// configured `datasource.url` address.
// For example, {{ query "foo" | first | value }} will
// execute "/api/v1/query?query=foo" request and will return
// the first value in response.
"query": func(q string) ([]metric, error) {
// query function supposed to be substituted at FuncsWithQuery().
// it is present here only for validation purposes, when there is no
// provided datasource.
//
// return non-empty slice to pass validation with chained functions in template
// see issue #989 for details
return []metric{{}}, nil
},
// first returns the first by order element from the given metrics list.
// usually used alongside with `query` template function.
"first": func(metrics []metric) (metric, error) {
if len(metrics) > 0 {
return metrics[0], nil
}
return metric{}, errors.New("first() called on vector with no elements")
},
// label returns the value of the given label name for the given metric.
// usually used alongside with `query` template function.
"label": func(label string, m metric) string {
return m.Labels[label]
},
// value returns the value of the given metric.
// usually used alongside with `query` template function.
"value": func(m metric) float64 {
return m.Value
},
// strvalue returns metric name.
"strvalue": func(m metric) string {
return m.Labels["__name__"]
},
// sortByLabel sorts the given metrics by provided label key
"sortByLabel": func(label string, metrics []metric) []metric {
sort.SliceStable(metrics, func(i, j int) bool {
return metrics[i].Labels[label] < metrics[j].Labels[label]
})
return metrics
},
/* Helpers */
// Converts a list of objects to a map with keys arg0, arg1 etc.
// This is intended to allow multiple arguments to be passed to templates.
"args": func(args ...interface{}) map[string]interface{} {
result := make(map[string]interface{})
for i, a := range args {
result[fmt.Sprintf("arg%d", i)] = a
}
return result
},
// safeHtml marks string as HTML not requiring auto-escaping.
//
// See also htmlEscape.
"safeHtml": func(text string) htmlTpl.HTML {
return htmlTpl.HTML(text)
},
}
}
// Time is the number of milliseconds since the epoch
// (1970-01-01 00:00 UTC) excluding leap seconds.
type Time int64
// timeFromUnixTimestamp returns the Time equivalent to t in unix timestamp.
func timeFromUnixTimestamp(t float64) Time {
return Time(t * 1e3)
}
// The number of nanoseconds per minimum tick.
const nanosPerTick = int64(minimumTick / time.Nanosecond)
// MinimumTick is the minimum supported time resolution. This has to be
// at least time.Second in order for the code below to work.
const minimumTick = time.Millisecond
// second is the Time duration equivalent to one second.
const second = int64(time.Second / minimumTick)
// Time returns the time.Time representation of t.
func (t Time) Time() time.Time {
return time.Unix(int64(t)/second, (int64(t)%second)*nanosPerTick)
}
func toFloat64(v interface{}) (float64, error) {
switch i := v.(type) {
case float64:
return i, nil
case float32:
return float64(i), nil
case int64:
return float64(i), nil
case int32:
return float64(i), nil
case int:
return float64(i), nil
case uint64:
return float64(i), nil
case uint32:
return float64(i), nil
case uint:
return float64(i), nil
case string:
return strconv.ParseFloat(i, 64)
default:
return 0, fmt.Errorf("unexpected value type %v", i)
}
}