// Package scheduler — cel.go implements a minimal CEL-subset evaluator // for the CLI-side scheduler constraint expressions (REQ-083, P05). // // The full CEL specification (google.golang.org/genproto/... // googleapis/api/expr/v1alpha1) is intentionally NOT a dependency of // this module (see go.mod): adding it for a single callsite would pull // in a large transitive graph and contradict the "stdlib + minimal // deps" guardrail. Instead this file implements a hand-rolled // recursive-descent evaluator for the subset the PRD exercises: // // - attribute access on a `node.` object (hostname, kind, // cpus, memory, tags, runtimes) // - string and integer literals (double-quoted) // - comparison operators: == != >= <= > < // - membership: in , not in // - boolean composition: and, or, not (parenthesised) // // Anything outside this subset returns an error rather than a silent // wrong answer; that is the documented limitation. The grammar is // small enough to be unambiguous with a top-down precedence-climbing // parser. package scheduler import ( "fmt" "strconv" "strings" "unicode" ) // EvaluateConstraint evaluates a single CEL-subset expression against // the supplied NodeInfo. Returns (matched, err). An expression that // references an unknown attribute, uses an unsupported operator, or // fails to parse yields an error. Schedule treats a constraint // evaluation error as a non-fit (the node is silently skipped) rather // than a hard fail because operators routinely write exploratory // constraints against attributes the local cluster does not expose. func EvaluateConstraint(expr string, node NodeInfo) (bool, error) { p := newParser(strings.TrimSpace(expr), node) if p.len() == 0 { return false, fmt.Errorf("cel: empty expression") } v, err := p.parseExpr() if err != nil { return false, err } if p.tok.kind != tokEOF { return false, fmt.Errorf("cel: trailing input near %q", p.tok.text) } b, ok := v.(bool) if !ok { return false, fmt.Errorf("cel: expression did not evaluate to bool (got %T)", v) } return b, nil } // EvaluateAll returns true iff every constraint evaluates to true // against the node (logical AND). An empty constraint list is vacuously // true. The first evaluation error short-circuits and is returned. func EvaluateAll(constraints []string, node NodeInfo) (bool, error) { for _, c := range constraints { ok, err := EvaluateConstraint(c, node) if err != nil { return false, fmt.Errorf("constraint %q: %w", c, err) } if !ok { return false, nil } } return true, nil } // ---------------------------------------------------------------------------- // Value model // ---------------------------------------------------------------------------- // celValue is the union of values the evaluator produces. We use the // Go interface{} representation so that comparisons can be polymorphic // without a tagged-union ceremony; the supported concrete types are // bool, int64, and string. Lists are []celValue of the above. type celValue = interface{} // ---------------------------------------------------------------------------- // Tokenizer // ---------------------------------------------------------------------------- type tokKind int const ( tokEOF tokKind = iota tokIdent tokInt tokStr tokOp // ==, !=, >=, <=, >, <, (, ), . tokIn // "in" tokAnd // "and" tokOr // "or" tokNot // "not" ) type token struct { kind tokKind text string } type lexer struct { src string pos int } func (l *lexer) next() (token, error) { for l.pos < len(l.src) && unicode.IsSpace(rune(l.src[l.pos])) { l.pos++ } if l.pos >= len(l.src) { return token{kind: tokEOF}, nil } c := l.src[l.pos] // string literal if c == '"' { start := l.pos l.pos++ for l.pos < len(l.src) && l.src[l.pos] != '"' { l.pos++ } if l.pos >= len(l.src) { return token{}, fmt.Errorf("cel: unterminated string at %d", start) } val := l.src[start+1 : l.pos] l.pos++ // consume closing quote return token{kind: tokStr, text: val}, nil } // integer literal if unicode.IsDigit(rune(c)) { start := l.pos for l.pos < len(l.src) && unicode.IsDigit(rune(l.src[l.pos])) { l.pos++ } return token{kind: tokInt, text: l.src[start:l.pos]}, nil } // identifier / keyword if isIdentStart(c) { start := l.pos for l.pos < len(l.src) && isIdentPart(l.src[l.pos]) { l.pos++ } word := l.src[start:l.pos] switch word { case "in": return token{kind: tokIn, text: word}, nil case "and": return token{kind: tokAnd, text: word}, nil case "or": return token{kind: tokOr, text: word}, nil case "not": return token{kind: tokNot, text: word}, nil default: return token{kind: tokIdent, text: word}, nil } } // operators if strings.ContainsRune("()=!<>.", rune(c)) { // multi-char operators if l.pos+1 < len(l.src) { two := l.src[l.pos : l.pos+2] switch two { case "==", "!=", ">=", "<=": l.pos += 2 return token{kind: tokOp, text: two}, nil } } l.pos++ return token{kind: tokOp, text: string(c)}, nil } return token{}, fmt.Errorf("cel: unexpected character %q at %d", c, l.pos) } func isIdentStart(c byte) bool { return c == '_' || (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') } func isIdentPart(c byte) bool { return isIdentStart(c) || (c >= '0' && c <= '9') } // ---------------------------------------------------------------------------- // Parser (recursive descent, precedence climbing) // ---------------------------------------------------------------------------- type parser struct { src string pos int tok token err error node NodeInfo } func newParser(src string, node NodeInfo) *parser { p := &parser{src: src, node: node} p.advance() return p } func (p *parser) len() int { return len(p.src) } func (p *parser) advance() { if p.err != nil { return } l := lexer{src: p.src, pos: p.pos} t, err := l.next() if err != nil { p.err = err return } p.pos = l.pos p.tok = t } // Grammar (lowest precedence first): // // expr := orExpr // orExpr := andExpr ("or" andExpr)* // andExpr := notExpr ("and" notExpr)* // notExpr := "not" notExpr | cmpExpr // cmpExpr := primary (op primary | "in" primary | "not" "in" primary)? // primary := "(" expr ")" // | int // | str // | "true" | "false" // | nodeAttr ("." ident)? // node. // | ident // bare attribute (e.g. region) // nodeAttr := "node" func (p *parser) parseExpr() (celValue, error) { if p.err != nil { return nil, p.err } return p.parseOr() } func (p *parser) parseOr() (celValue, error) { left, err := p.parseAnd() if err != nil { return nil, err } for p.tok.kind == tokOr { p.advance() right, err := p.parseAnd() if err != nil { return nil, err } lb, ok := left.(bool) if !ok { return nil, fmt.Errorf("cel: 'or' operand not bool: %T", left) } rb, ok := right.(bool) if !ok { return nil, fmt.Errorf("cel: 'or' operand not bool: %T", right) } left = lb || rb } return left, nil } func (p *parser) parseAnd() (celValue, error) { left, err := p.parseNot() if err != nil { return nil, err } for p.tok.kind == tokAnd { p.advance() right, err := p.parseNot() if err != nil { return nil, err } lb, ok := left.(bool) if !ok { return nil, fmt.Errorf("cel: 'and' operand not bool: %T", left) } rb, ok := right.(bool) if !ok { return nil, fmt.Errorf("cel: 'and' operand not bool: %T", right) } left = lb && rb } return left, nil } func (p *parser) parseNot() (celValue, error) { if p.tok.kind == tokNot { // "not" at the start of a primary is logical negation. "not in" // is handled in parseCmp where it follows a primary. p.advance() v, err := p.parseNot() if err != nil { return nil, err } b, ok := v.(bool) if !ok { return nil, fmt.Errorf("cel: 'not' operand not bool: %T", v) } return !b, nil } return p.parseCmp() } func (p *parser) parseCmp() (celValue, error) { left, err := p.parsePrimary() if err != nil { return nil, err } // "not in" if p.tok.kind == tokNot { p.advance() if p.tok.kind != tokIn { return nil, fmt.Errorf("cel: expected 'in' after 'not', got %q", p.tok.text) } p.advance() right, err := p.parsePrimary() if err != nil { return nil, err } member, err := inMember(left, right) if err != nil { return nil, err } return !member, nil } // "in" if p.tok.kind == tokIn { p.advance() right, err := p.parsePrimary() if err != nil { return nil, err } return inMember(left, right) } // comparison operators if p.tok.kind == tokOp { op := p.tok.text switch op { case "==", "!=", ">=", "<=", ">", "<": p.advance() right, err := p.parsePrimary() if err != nil { return nil, err } return compare(op, left, right) default: return nil, fmt.Errorf("cel: unexpected operator %q", op) } } return left, nil } // inMember reports whether left is a member of right. right must be a // list ([]celValue) of comparable values; left may be a string or // int64. func inMember(left, right celValue) (bool, error) { list, ok := right.([]celValue) if !ok { return false, fmt.Errorf("cel: 'in' rhs not a list: %T", right) } for _, e := range list { if valuesEqual(left, e) { return true, nil } } return false, nil } func valuesEqual(a, b celValue) bool { switch av := a.(type) { case string: bv, ok := b.(string) return ok && av == bv case int64: bv, ok := b.(int64) return ok && av == bv case bool: bv, ok := b.(bool) return ok && av == bv } return false } // compare applies a binary comparison operator to two scalar values. // Strings compare lexicographically; ints numerically; bools only via // ==/!=. func compare(op string, left, right celValue) (bool, error) { switch op { case "==": return valuesEqual(left, right), nil case "!=": return !valuesEqual(left, right), nil } // ordered comparisons require ordered operands ls, lok := left.(string) rs, rok := right.(string) if lok && rok { switch op { case "<": return ls < rs, nil case "<=": return ls <= rs, nil case ">": return ls > rs, nil case ">=": return ls >= rs, nil } } li, lok := left.(int64) ri, rok := right.(int64) if lok && rok { switch op { case "<": return li < ri, nil case "<=": return li <= ri, nil case ">": return li > ri, nil case ">=": return li >= ri, nil } } return false, fmt.Errorf("cel: cannot apply %q to %T and %T", op, left, right) } // parsePrimary parses the smallest standalone unit: parenthesised // expressions, literals, and attribute references. func (p *parser) parsePrimary() (celValue, error) { switch p.tok.kind { case tokOp: if p.tok.text == "(" { p.advance() v, err := p.parseExpr() if err != nil { return nil, err } if p.tok.kind != tokOp || p.tok.text != ")" { return nil, fmt.Errorf("cel: expected ')' got %q", p.tok.text) } p.advance() return v, nil } return nil, fmt.Errorf("cel: unexpected operator %q", p.tok.text) case tokInt: n, err := strconv.ParseInt(p.tok.text, 10, 64) if err != nil { return nil, fmt.Errorf("cel: bad int %q: %w", p.tok.text, err) } p.advance() return n, nil case tokStr: v := p.tok.text p.advance() return v, nil case tokIdent: return p.parseAttrRef() } return nil, fmt.Errorf("cel: unexpected token %q", p.tok.text) } // parseAttrRef resolves a bare or `node.` attribute reference // against the node being evaluated. Bare identifiers (e.g. `region`) // resolve against the same attribute map as `node.region`; the PRD // examples use both forms interchangeably (see // TestParseMarkdown_ConstraintsInlineArray). func (p *parser) parseAttrRef() (celValue, error) { name := p.tok.text p.advance() // dotted access: node. if p.tok.kind == tokOp && p.tok.text == "." { if name != "node" { return nil, fmt.Errorf("cel: dotted access on non-node: %q", name) } p.advance() if p.tok.kind != tokIdent { return nil, fmt.Errorf("cel: expected attribute name after '.', got %q", p.tok.text) } field := p.tok.text p.advance() return p.nodeAttr(name + "." + field) } // bare identifier switch name { case "true": return true, nil case "false": return false, nil default: return p.nodeAttr(name) } } // nodeAttr resolves an attribute name to its value on the parser's // active node. Mapping (per PRD T2): // // node.hostname -> Hostname (string) // node.kind -> Kind (string) // node.cpus -> CPU (int64) // node.memory -> Memory (int64) // node.tags -> Tags ([]string -> []celValue) // node.runtimes -> Runtimes ([]string -> []celValue) // // Bare names (without the `node.` prefix) resolve through the same // map, so `region == "us"` and `node.region == "us"` are equivalent // when the attribute exists. func (p *parser) nodeAttr(name string) (celValue, error) { switch name { case "node.hostname", "hostname": return p.node.Hostname, nil case "node.kind", "kind": return p.node.Kind, nil case "node.cpus", "cpus": return p.node.CPU, nil case "node.memory", "memory": return p.node.Memory, nil case "node.tags", "tags": return toStringValues(p.node.Tags), nil case "node.runtimes", "runtimes": return toStringValues(p.node.Runtimes), nil } return nil, fmt.Errorf("cel: unknown attribute %q", name) } // toStringValues converts a []string to []celValue so the membership // operators can compare element-wise. func toStringValues(in []string) []celValue { out := make([]celValue, len(in)) for i, s := range in { out[i] = s } return out }