Skip to content

Commit

Permalink
pkg/nsrule: Implement basic ruleset parsing and evaluation
Browse files Browse the repository at this point in the history
  • Loading branch information
pg9182 committed Sep 14, 2023
1 parent f6fbcf6 commit c201d90
Show file tree
Hide file tree
Showing 5 changed files with 422 additions and 0 deletions.
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ require (
)

require (
github.com/antonmedv/expr v1.15.2 // indirect
github.com/lib/pq v1.10.7 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.17 // indirect
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ github.com/VictoriaMetrics/metrics v1.23.1 h1:/j8DzeJBxSpL2qSIdqnRFLvQQhbJyJbbEi
github.com/VictoriaMetrics/metrics v1.23.1/go.mod h1:rAr/llLpEnAdTehiNlUxKgnjcOuROSzpw0GvjpEbvFc=
github.com/andybalholm/cascadia v1.3.1 h1:nhxRkql1kdYCc8Snf7D5/D3spOX+dBgjA6u8x004T2c=
github.com/andybalholm/cascadia v1.3.1/go.mod h1:R4bJ1UQfqADjvDa4P6HZHLh/3OxWWEqc0Sk8XGwHqvA=
github.com/antonmedv/expr v1.15.2 h1:afFXpDWIC2n3bF+kTZE1JvFo+c34uaM3sTqh8z0xfdU=
github.com/antonmedv/expr v1.15.2/go.mod h1:0E/6TxnOlRNp81GMzX9QfDPAmHo2Phg00y4JUv1ihsE=
github.com/cardigann/harhar v0.0.0-20161005032312-acb91b7a8682 h1:Ce5LRUcDnICPpYjWych45AXKaV61l9oqqfMd1hORNPg=
github.com/cardigann/harhar v0.0.0-20161005032312-acb91b7a8682/go.mod h1:cDq9S+BVx7XyKnnivCLcKW1oUTnXHkUSs6+LFs4ZrXA=
github.com/coreos/go-systemd/v22 v22.3.3-0.20220203105225-a9a7ef127534/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
Expand Down
18 changes: 18 additions & 0 deletions pkg/nsrule/env.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package nsrule

import "github.com/antonmedv/expr"

// Env contains data used to evaluate rules.
type Env struct {
env map[string]any
}

var dummyEnv = expr.Env(NewEnv().env)

// NewEnv initializes an env using the provided information.
//
// TODO
func NewEnv() Env {
env := map[string]any{}
return Env{env}
}
223 changes: 223 additions & 0 deletions pkg/nsrule/rule.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,223 @@
// Package nsrule provides a mechanism for adding arbitrary tags to requests.
package nsrule

import (
"bufio"
"fmt"
"io"
"io/fs"
"path"
"strings"
"sync/atomic"
"unicode"

"github.com/antonmedv/expr"
"github.com/antonmedv/expr/vm"
)

// RuleSet is a goroutine-safe container holding rules from a directory.
type RuleSet struct {
rules atomic.Pointer[[]Rule]
}

// LoadFS loads rules from the provided filesystem in lexical order, replacing
// all existing ones. On error, the ruleset is left as-is.
func (s *RuleSet) LoadFS(fsys fs.FS) error {
var rules []Rule
if err := fs.WalkDir(fsys, ".", func(p string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if !d.IsDir() {
f, err := fsys.Open(p)
if err != nil {
return err
}
defer f.Close()

r, err := ParseRules(f, path.Clean(p))
if err != nil {
return fmt.Errorf("parse rules from %q: %w", p, err)
}
rules = append(rules, r...)
}
return nil
}); err != nil {
return err
}
s.rules.Store(&rules)
return nil
}

// Evaluate evaluates r into t (which should not be nil) against e. The returned
// error list will almost always be nil since expressions are checked during
// parsing.
func (s *RuleSet) Evaluate(e Env, t Tags) []error {
var errs []error
if rs := s.rules.Load(); rs != nil {
for _, r := range *rs {
if err := r.Evaluate(e, t); err != nil {
errs = append(errs, err)
}
}
}
return errs
}

// Rule is a single rule consisting of an expression and tag mutations.
type Rule struct {
name string
line int
expr *vm.Program
muts []tagMut
}

// ParseRules parses rules from r, labeling them with name if provided.
//
// Each rule consists of an expression, continued on indented lines, followed by
// one or more further indented lines specifying tag mutations, like:
//
// expression
// continued expression
// continued expression
// tag mutation
// tag mutation
//
// The exact amount and type of indentation doesn't matter, but has to be
// consistent within a rule. Blank lines or lines starting with # ignoring
// preceding whitespace are ignored.
//
// Expressions are checked for syntax errors and undefined names, but tag
// mutations are only checked for syntax errors.
func ParseRules(r io.Reader, name string) ([]Rule, error) {
var (
rs []Rule
sc = bufio.NewScanner(r)

line string
lineN int
expB strings.Builder
expN int
muts []string
mutNs []int
last int // last indentation
level int
)
for eof := false; !eof; {
expLines:
for {
if !sc.Scan() {
eof = true
break expLines
} else {
line = sc.Text()
lineN++
}

// ignore blank lines and comments
if x := strings.TrimSpace(line); x == "" || strings.HasPrefix(x, "#") {
continue
}

// determine indentation
var indent int
for _, x := range line {
if !unicode.IsSpace(x) {
break
}
indent++
}

// parse
if indent == 0 {
break expLines
}
if expB.Len() == 0 {
return rs, fmt.Errorf("line %d: expected rule expression start, got indented line", lineN)
}
if indent > last {
if level++; level > 2 {
return rs, fmt.Errorf("line %d: too many indentation levels", lineN)
}
// we have another indent level, so tack the mutation lines onto
// the expression
for _, x := range muts {
expB.WriteByte('\n')
expB.WriteString(x)
}
muts = muts[:0]
mutNs = mutNs[:0]
last = indent
}
if indent != last {
return rs, fmt.Errorf("line %d: unexpected de-indentation", lineN)
}
// we have another line at the current indent level, so assume
// it's a mutation
muts = append(muts, line)
mutNs = append(mutNs, lineN)
}

// process the pending rule
if expB.Len() != 0 {
fmt.Println(expB.String())

// ensure the rule is complete
if len(muts) == 0 {
return rs, fmt.Errorf("line %d: expected rule (expression %q) to contain tag mutations", lineN, expB.String())
}

// compile the rule
r := Rule{
name: name,
line: expN,
}
if v, err := expr.Compile(expB.String(), expr.AsBool(), expr.Optimize(true), dummyEnv); err != nil { // TODO: dummy env
return rs, fmt.Errorf("line %d: compile rule expression: %w", expN, err)
} else {
r.expr = v
}
r.muts = make([]tagMut, len(muts))
for i := range r.muts {
if v, err := parseTagMut(muts[i]); err != nil {
return rs, fmt.Errorf("line %d: parse tag mutation: %w", mutNs[i], err)
} else {
r.muts[i] = v
}
}
rs = append(rs, r)

// clear the rule state
expB.Reset()
expN = 0
muts = muts[:0]
mutNs = mutNs[:0]
last = 0
level = 0
}

// start the new rule
if !eof {
expB.WriteString(line)
expN = lineN
}
}
return rs, sc.Err()
}

// Evaluate evaluates r into t (which should not be nil) against e. The returned
// error will almost always be nil since expressions are checked during parsing.
func (r Rule) Evaluate(e Env, t Tags) error {
v, err := expr.Run(r.expr, e)
if err != nil {
return fmt.Errorf("evaluate rule at %s:%d: %w", r.name, r.line, err)
}
if v.(bool) {
if t != nil {
for _, m := range r.muts {
m.Apply(t)
}
}
}
return nil
}
Loading

0 comments on commit c201d90

Please sign in to comment.