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 ecbd0d9 commit eec4c04
Show file tree
Hide file tree
Showing 5 changed files with 426 additions and 0 deletions.
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ go 1.21.0
require (
github.com/VictoriaMetrics/metrics v1.23.1
github.com/andybalholm/cascadia v1.3.1
github.com/antonmedv/expr v1.15.2
github.com/cardigann/harhar v0.0.0-20161005032312-acb91b7a8682
github.com/hashicorp/go-envparse v0.1.0
github.com/jmoiron/sqlx v1.3.5
Expand Down
6 changes: 6 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,12 @@ 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=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/go-sql-driver/mysql v1.6.0 h1:BCTh4TKNUYmOmMUcQ3IipzF5prigylS7XXjEkfCHuOE=
github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
Expand Down Expand Up @@ -32,12 +35,14 @@ github.com/mmcloughlin/geohash v0.10.0/go.mod h1:oNZxQo5yWJh0eMQEP/8hwQuVx9Z9tjw
github.com/pg9182/ip2x v1.0.0 h1:aNIWIjzFYmaVHIsbT6OYDVgiySQkgjmecySvjXOCgsU=
github.com/pg9182/ip2x v1.0.0/go.mod h1:iJzts7yWZDWUaldqNGFVOZ0MW9uYrGk1hv9R/wTJdNQ=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/rs/xid v1.4.0 h1:qd7wPTDkN6KQx2VmMBLrpHkiyQwgFXRnkOLacUiaSNY=
github.com/rs/xid v1.4.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
github.com/rs/zerolog v1.29.0 h1:Zes4hju04hjbvkVkOhdl2HpZa+0PmVwigmo8XoORE5w=
github.com/rs/zerolog v1.29.0/go.mod h1:NILgTygv/Uej1ra5XxGf82ZFSLk58MFGAUS2o6usyD0=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/valyala/fastrand v1.1.0 h1:f+5HkLW4rsgzdNoleUOB69hyT9IlD2ZQh9GyDMfb5G8=
github.com/valyala/fastrand v1.1.0/go.mod h1:HWqCzkrkg6QXT8V2EXWvXCoow7vLwOFN002oeRzjapQ=
github.com/valyala/histogram v1.2.0 h1:wyYGAZZt3CpwUiIb9AU/Zbllg1llXyrtApRS815OLoQ=
Expand All @@ -57,3 +62,4 @@ golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
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 eec4c04

Please sign in to comment.