Implemented confi snapshots
This commit is contained in:
@@ -0,0 +1,199 @@
|
||||
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"`
|
||||
Version int `json:"version"`
|
||||
Tag string `json:"tag,omitempty"`
|
||||
Owner string `json:"owner,omitempty"`
|
||||
Source string `json:"source"`
|
||||
}
|
||||
|
||||
// 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...)
|
||||
}
|
||||
Reference in New Issue
Block a user