c10779873b
P05 — Scheduler moves from daemon-side to CLI-side (R-001) with runtime-awareness. Scheduler (internal/scheduler/scheduler.go, REQ-083): - Pure Schedule(nodes, req) -> []Placement. Job=1 best-fit, Service=count replicas (anti-affinity default, colocation permitted), DaemonSet=1 per matching node. Score(node, req) = (FreeCPU*1000 + FreeMem); fits checks runtime compat (wasm->wasmtime, pve-vm/ct->proxmox), constraints (CEL AND), capacity. Affinity scoring (target + weight, anti-affinity for spreading). CEL evaluator (internal/scheduler/cel.go): - Hand-rolled recursive-descent (no CEL dep in go.mod). Subset: node.* attrs, literals, ==/!=/>=/<=/></>, in/not in, and/or/not, parens. Anything outside subset returns error (no silent wrong answer). Schedule treats eval errors as non-fit (node skipped). 23 packages pass, 20 bats pass, gofmt clean, verify-reqs 90 consistent. 89.5% coverage on internal/scheduler. ---ci--- project: orca phase: P05 milestone: v0.9 status: execute ---/ci---
537 lines
13 KiB
Go
537 lines
13 KiB
Go
// 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.<name>` object (hostname, kind,
|
|
// cpus, memory, tags, runtimes)
|
|
// - string and integer literals (double-quoted)
|
|
// - comparison operators: == != >= <= > <
|
|
// - membership: <expr> in <expr>, <expr> not in <expr>
|
|
// - 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.<field>
|
|
// | 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.<field>` 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.<field>
|
|
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
|
|
}
|