208 lines
6.8 KiB
Go
208 lines
6.8 KiB
Go
package confmgr
|
|
|
|
import (
|
|
"fmt"
|
|
"reflect"
|
|
"strings"
|
|
|
|
"cuelang.org/go/cue"
|
|
"cuelang.org/go/cue/cuecontext"
|
|
cueerrors "cuelang.org/go/cue/errors"
|
|
)
|
|
|
|
// ConfigRule is a CUE-based validation/transformation rule bound to a config
|
|
// set. Its Source is CUE describing constraints and/or derivations over the
|
|
// instance's parameter values. Regular CUE fields whose key matches a set
|
|
// parameter are unified with the instance value; constraints that fail produce
|
|
// violations, and concrete fields that differ from the instance value are
|
|
// reported (and persisted) as transformations. Hidden fields (_x) and
|
|
// definitions (#X) are available for helpers and are excluded from both
|
|
// concreteness checks and transformation output. Rules are versioned git-style,
|
|
// exactly like sets and instances.
|
|
type ConfigRule struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
SetID string `json:"setId"`
|
|
Description string `json:"description,omitempty"`
|
|
// Enabled gates whether the rule runs when instances of the bound set are
|
|
// saved/applied. A nil pointer (legacy rules created before the flag) is
|
|
// treated as enabled, so existing rules keep their behaviour.
|
|
Enabled *bool `json:"enabled,omitempty"`
|
|
Version int `json:"version"`
|
|
Tag string `json:"tag,omitempty"`
|
|
Owner string `json:"owner,omitempty"`
|
|
Source string `json:"source"`
|
|
}
|
|
|
|
// IsEnabled reports whether the rule participates in instance evaluation. A nil
|
|
// Enabled flag (legacy rules) is treated as enabled.
|
|
func (r ConfigRule) IsEnabled() bool { return r.Enabled == nil || *r.Enabled }
|
|
|
|
// RuleViolation is a single constraint failure from evaluating a rule.
|
|
type RuleViolation struct {
|
|
Rule string `json:"rule,omitempty"` // rule ID that produced it (aggregate runs)
|
|
Path string `json:"path,omitempty"` // dotted parameter path, when known
|
|
Message string `json:"message"`
|
|
}
|
|
|
|
// RuleResult is the outcome of evaluating one or more rules against a set of
|
|
// instance values. OK is true only when there is no compile error and no
|
|
// violation. Transformed holds the parameter values the rule(s) derived or
|
|
// overrode (keys are parameter keys; values are the new concrete values).
|
|
type RuleResult struct {
|
|
OK bool `json:"ok"`
|
|
Violations []RuleViolation `json:"violations,omitempty"`
|
|
Transformed map[string]any `json:"transformed,omitempty"`
|
|
CompileError string `json:"compileError,omitempty"`
|
|
}
|
|
|
|
// RuleError wraps a failing RuleResult so it can flow through the store's
|
|
// error-returning API while preserving the structured violations.
|
|
type RuleError struct {
|
|
Result RuleResult
|
|
}
|
|
|
|
func (e *RuleError) Error() string {
|
|
if e.Result.CompileError != "" {
|
|
return "rule compile error: " + e.Result.CompileError
|
|
}
|
|
msgs := make([]string, 0, len(e.Result.Violations))
|
|
for _, v := range e.Result.Violations {
|
|
if v.Path != "" {
|
|
msgs = append(msgs, v.Path+": "+v.Message)
|
|
} else {
|
|
msgs = append(msgs, v.Message)
|
|
}
|
|
}
|
|
if len(msgs) == 0 {
|
|
return "rule validation failed"
|
|
}
|
|
return "rule validation failed: " + strings.Join(msgs, "; ")
|
|
}
|
|
|
|
// Validate compiles the rule's CUE source and checks basic metadata. It does
|
|
// not require concrete values — only that the source is syntactically and
|
|
// structurally well-formed CUE.
|
|
func (r ConfigRule) Validate() error {
|
|
if strings.TrimSpace(r.Name) == "" {
|
|
return fmt.Errorf("rule name must not be empty")
|
|
}
|
|
if strings.TrimSpace(r.SetID) == "" {
|
|
return fmt.Errorf("rule must reference a config set")
|
|
}
|
|
ctx := cuecontext.New()
|
|
v := ctx.CompileString(r.Source)
|
|
if err := v.Err(); err != nil {
|
|
return fmt.Errorf("invalid CUE: %s", firstError(err))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// EvaluateRule unifies a single CUE source with the given instance values and
|
|
// reports violations plus any transformed values. A compile error yields a
|
|
// non-OK result with CompileError populated (and no violations), so callers can
|
|
// distinguish "the rule is broken" from "the values are invalid".
|
|
func EvaluateRule(source string, values map[string]any) RuleResult {
|
|
ctx := cuecontext.New()
|
|
schema := ctx.CompileString(source)
|
|
if err := schema.Err(); err != nil {
|
|
return RuleResult{OK: false, CompileError: firstError(err)}
|
|
}
|
|
data := ctx.Encode(values)
|
|
if err := data.Err(); err != nil {
|
|
return RuleResult{OK: false, CompileError: "cannot encode values: " + firstError(err)}
|
|
}
|
|
unified := schema.Unify(data)
|
|
|
|
res := RuleResult{OK: true}
|
|
if err := unified.Validate(cue.Concrete(true), cue.All()); err != nil {
|
|
res.OK = false
|
|
for _, e := range cueerrors.Errors(err) {
|
|
res.Violations = append(res.Violations, RuleViolation{
|
|
Path: strings.Join(e.Path(), "."),
|
|
Message: cleanMessage(e),
|
|
})
|
|
}
|
|
if len(res.Violations) == 0 {
|
|
res.Violations = append(res.Violations, RuleViolation{Message: firstError(err)})
|
|
}
|
|
return res
|
|
}
|
|
|
|
// Extract transformations: regular concrete fields whose value differs from
|
|
// the supplied input. Definitions and hidden fields are skipped by Fields().
|
|
iter, err := unified.Fields()
|
|
if err != nil {
|
|
return res
|
|
}
|
|
for iter.Next() {
|
|
key := iter.Selector().Unquoted()
|
|
if key == "" {
|
|
key = strings.Trim(iter.Selector().String(), `"`)
|
|
}
|
|
var v any
|
|
if err := iter.Value().Decode(&v); err != nil {
|
|
continue
|
|
}
|
|
if !reflect.DeepEqual(v, values[key]) {
|
|
if res.Transformed == nil {
|
|
res.Transformed = map[string]any{}
|
|
}
|
|
res.Transformed[key] = v
|
|
}
|
|
}
|
|
return res
|
|
}
|
|
|
|
// evaluateRules runs several rules against the same values and aggregates the
|
|
// outcome. Violations are tagged with the rule ID. Transformations are applied
|
|
// cumulatively in order; a later rule sees earlier rules' outputs.
|
|
func evaluateRules(rules []ConfigRule, values map[string]any) RuleResult {
|
|
agg := RuleResult{OK: true}
|
|
cur := make(map[string]any, len(values))
|
|
for k, v := range values {
|
|
cur[k] = v
|
|
}
|
|
for _, r := range rules {
|
|
one := EvaluateRule(r.Source, cur)
|
|
if one.CompileError != "" {
|
|
agg.OK = false
|
|
agg.Violations = append(agg.Violations, RuleViolation{Rule: r.ID, Message: "compile error: " + one.CompileError})
|
|
continue
|
|
}
|
|
if !one.OK {
|
|
agg.OK = false
|
|
for _, v := range one.Violations {
|
|
v.Rule = r.ID
|
|
agg.Violations = append(agg.Violations, v)
|
|
}
|
|
continue
|
|
}
|
|
for k, v := range one.Transformed {
|
|
if agg.Transformed == nil {
|
|
agg.Transformed = map[string]any{}
|
|
}
|
|
agg.Transformed[k] = v
|
|
cur[k] = v
|
|
}
|
|
}
|
|
return agg
|
|
}
|
|
|
|
// firstError renders the first CUE error as a single line.
|
|
func firstError(err error) string {
|
|
errs := cueerrors.Errors(err)
|
|
if len(errs) == 0 {
|
|
return err.Error()
|
|
}
|
|
return cleanMessage(errs[0])
|
|
}
|
|
|
|
// cleanMessage formats a CUE error to a compact single-line string without the
|
|
// noisy file:line position prefix that cuecontext synthesises for anonymous
|
|
// sources.
|
|
func cleanMessage(e cueerrors.Error) string {
|
|
format, args := e.Msg()
|
|
return fmt.Sprintf(format, args...)
|
|
}
|