grafana/pkg/services/ngalert/migration/template.go
William Wernert e562250f72
Alerting: Handle edge cases without panicking during template migration (#76890)
* Handle empty variable, remove panics

* Use fmt.Errorf only where appropriate
2023-11-02 13:24:54 -04:00

214 lines
4.7 KiB
Go

// This file contains code that parses templates from old alerting into a sequence
// of tokens. Each token can be either a string literal or a variable.
package migration
import (
"bytes"
"errors"
"fmt"
"strconv"
"strings"
"unicode"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/services/ngalert/state/template"
)
// Token contains either a string literal or a variable.
type Token struct {
Literal string
Variable string
}
func (t Token) IsLiteral() bool {
return t.Literal != ""
}
func (t Token) IsVariable() bool {
return t.Variable != ""
}
func (t Token) String() string {
if t.IsLiteral() {
return t.Literal
} else if t.IsVariable() {
return t.Variable
} else {
return ""
}
}
func MigrateTmpl(l log.Logger, oldTmpl string) string {
var newTmpl string
tokens := tokenizeTmpl(l, oldTmpl)
tokens = escapeLiterals(tokens)
if anyVariableToken(tokens) {
tokens = variablesToMapLookups(tokens, "mergedLabels")
newTmpl += fmt.Sprintf("{{- $mergedLabels := %s $values -}}\n", template.MergeLabelValuesFuncName)
}
newTmpl += tokensToTmpl(tokens)
return newTmpl
}
func tokenizeTmpl(logger log.Logger, tmpl string) []Token {
var (
tokens []Token
l int
r int
err error
)
in := []rune(tmpl)
for r < len(in) {
if !startVariable(in[r:]) {
r++
continue
}
token, offset, tokenErr := tokenizeVariable(in[r:])
if tokenErr != nil {
err = errors.Join(err, tokenErr)
r += offset
continue
}
// we've found a variable, so everything from l -> r is the literal before the variable
// ex: "foo ${bar}" -> Literal: "foo ", Variable: "bar"
if r > l {
tokens = append(tokens, Token{Literal: string(in[l:r])})
}
tokens = append(tokens, token)
// seek l and r past the variable
r += offset
l = r
}
// any remaining runes will be a final literal
if r > l {
tokens = append(tokens, Token{Literal: string(in[l:r])})
}
if err != nil {
logger.Warn("Encountered malformed template", "template", tmpl, "err", err)
}
return tokens
}
func tokenizeVariable(in []rune) (Token, int, error) {
var (
pos int
r rune
runes []rune
)
if !startVariable(in) {
return Token{}, pos, fmt.Errorf("expected '${', got '%s'", string(in[:2]))
}
pos += 2 // seek past opening delimiter
// consume valid runes until we hit a closing brace
// non-space whitespace and the opening delimiter are invalid
for pos < len(in) {
r = in[pos]
if unicode.IsSpace(r) && r != ' ' {
return Token{}, pos, errors.New("unexpected whitespace")
}
if startVariable(in[pos:]) {
return Token{}, pos, errors.New("ambiguous delimiter")
}
if r == '}' {
pos++
break
}
runes = append(runes, r)
pos++
}
// variable must end with '}' delimiter
if r != '}' {
return Token{}, pos, fmt.Errorf("expected '}', got '%c'", r)
}
token := Token{Variable: string(runes)}
if !token.IsVariable() {
return Token{}, pos, errors.New("empty variable")
}
return token, pos, nil
}
func startVariable(in []rune) bool {
return len(in) >= 2 && in[0] == '$' && in[1] == '{'
}
func anyVariableToken(tokens []Token) bool {
for _, token := range tokens {
if token.IsVariable() {
return true
}
}
return false
}
// tokensToTmpl returns the tokens as a Go template
func tokensToTmpl(tokens []Token) string {
buf := bytes.Buffer{}
for _, token := range tokens {
if token.IsVariable() {
buf.WriteString("{{")
buf.WriteString(token.String())
buf.WriteString("}}")
} else {
buf.WriteString(token.String())
}
}
return buf.String()
}
// escapeLiterals escapes any token literals with substrings that would be interpreted as Go template syntax
func escapeLiterals(tokens []Token) []Token {
result := make([]Token, 0, len(tokens))
for _, token := range tokens {
if token.IsLiteral() && shouldEscape(token.Literal) {
token.Literal = fmt.Sprintf("{{`%s`}}", token.Literal)
}
result = append(result, token)
}
return result
}
func shouldEscape(literal string) bool {
return strings.Contains(literal, "{{") || literal[len(literal)-1] == '{'
}
// variablesToMapLookups converts any variables in a slice of tokens to Go template map lookups
func variablesToMapLookups(tokens []Token, mapName string) []Token {
result := make([]Token, 0, len(tokens))
for _, token := range tokens {
if token.IsVariable() {
token.Variable = mapLookupString(token.Variable, mapName)
}
result = append(result, token)
}
return result
}
func mapLookupString(v string, mapName string) string {
for _, r := range v {
if !(unicode.IsDigit(r) || unicode.IsLetter(r) || r == '_') {
return fmt.Sprintf(`index $%s %s`, mapName, strconv.Quote(v)) // quote v to escape any special characters
}
}
return fmt.Sprintf(`$%s.%s`, mapName, v)
}