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...) }