Implemented confi snapshots

This commit is contained in:
Martino Ferrari
2026-06-21 23:50:03 +02:00
parent 04d31a15c4
commit 113e5a0fe8
51 changed files with 4921 additions and 241 deletions
+183
View File
@@ -6,6 +6,7 @@ import (
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net"
@@ -105,6 +106,11 @@ func (h *Handler) Register(mux *http.ServeMux, prefix string) {
mux.HandleFunc("GET "+prefix+"/synthetic/{name}", h.getSynthetic)
mux.HandleFunc("PUT "+prefix+"/synthetic/{name}", h.updateSynthetic)
mux.HandleFunc("DELETE "+prefix+"/synthetic/{name}", h.deleteSynthetic)
// Synthetic signal git-style versioning (list / view / promote / fork).
mux.HandleFunc("GET "+prefix+"/synthetic/{name}/versions", h.listSyntheticVersions)
mux.HandleFunc("GET "+prefix+"/synthetic/{name}/versions/{version}", h.getSyntheticVersion)
mux.HandleFunc("POST "+prefix+"/synthetic/{name}/versions/{version}/promote", h.promoteSyntheticVersion)
mux.HandleFunc("POST "+prefix+"/synthetic/{name}/versions/{version}/fork", h.forkSyntheticVersion)
// Server-side control logic CRUD (mutations are write-gated by the access
// middleware; each mutation reloads the running engine).
mux.HandleFunc("GET "+prefix+"/controllogic", h.listControlLogic)
@@ -112,6 +118,11 @@ func (h *Handler) Register(mux *http.ServeMux, prefix string) {
mux.HandleFunc("GET "+prefix+"/controllogic/{id}", h.getControlLogic)
mux.HandleFunc("PUT "+prefix+"/controllogic/{id}", h.updateControlLogic)
mux.HandleFunc("DELETE "+prefix+"/controllogic/{id}", h.deleteControlLogic)
// Control-logic git-style versioning (list / view / promote / fork).
mux.HandleFunc("GET "+prefix+"/controllogic/{id}/versions", h.listControlLogicVersions)
mux.HandleFunc("GET "+prefix+"/controllogic/{id}/versions/{version}", h.getControlLogicVersion)
mux.HandleFunc("POST "+prefix+"/controllogic/{id}/versions/{version}/promote", h.promoteControlLogicVersion)
mux.HandleFunc("POST "+prefix+"/controllogic/{id}/versions/{version}/fork", h.forkControlLogicVersion)
// Configuration manager — config sets (schemas) and instances (values),
// both versioned git-style. Mutations are write-gated by the access
// middleware; "apply" writes each value to its target signal.
@@ -124,6 +135,7 @@ func (h *Handler) Register(mux *http.ServeMux, prefix string) {
mux.HandleFunc("GET "+prefix+"/config/sets/{id}/versions/{version}", h.getConfigSetVersion)
mux.HandleFunc("POST "+prefix+"/config/sets/{id}/versions/{version}/promote", h.promoteConfigSet)
mux.HandleFunc("POST "+prefix+"/config/sets/{id}/versions/{version}/fork", h.forkConfigSet)
mux.HandleFunc("POST "+prefix+"/config/sets/{id}/snapshot", h.snapshotConfigSet)
mux.HandleFunc("GET "+prefix+"/config/sets/diff", h.diffConfigSets)
mux.HandleFunc("GET "+prefix+"/config/instances", h.listConfigInstances)
mux.HandleFunc("POST "+prefix+"/config/instances", h.createConfigInstance)
@@ -135,7 +147,22 @@ func (h *Handler) Register(mux *http.ServeMux, prefix string) {
mux.HandleFunc("POST "+prefix+"/config/instances/{id}/versions/{version}/promote", h.promoteConfigInstance)
mux.HandleFunc("POST "+prefix+"/config/instances/{id}/versions/{version}/fork", h.forkConfigInstance)
mux.HandleFunc("POST "+prefix+"/config/instances/{id}/apply", h.applyConfigInstance)
mux.HandleFunc("POST "+prefix+"/config/instances/{id}/validate", h.validateConfigInstance)
mux.HandleFunc("GET "+prefix+"/config/instances/{id}/livediff", h.diffConfigInstanceLive)
mux.HandleFunc("GET "+prefix+"/config/instances/diff", h.diffConfigInstances)
// Config rules — CUE validation/transformation logic bound to a set, run
// when instances of that set are saved. Versioned git-style; "check"
// evaluates an unsaved source for the live editor.
mux.HandleFunc("GET "+prefix+"/config/rules", h.listConfigRules)
mux.HandleFunc("POST "+prefix+"/config/rules", h.createConfigRule)
mux.HandleFunc("POST "+prefix+"/config/rules/check", h.checkConfigRule)
mux.HandleFunc("GET "+prefix+"/config/rules/{id}", h.getConfigRule)
mux.HandleFunc("PUT "+prefix+"/config/rules/{id}", h.updateConfigRule)
mux.HandleFunc("DELETE "+prefix+"/config/rules/{id}", h.deleteConfigRule)
mux.HandleFunc("GET "+prefix+"/config/rules/{id}/versions", h.listConfigRuleVersions)
mux.HandleFunc("GET "+prefix+"/config/rules/{id}/versions/{version}", h.getConfigRuleVersion)
mux.HandleFunc("POST "+prefix+"/config/rules/{id}/versions/{version}/promote", h.promoteConfigRule)
mux.HandleFunc("POST "+prefix+"/config/rules/{id}/versions/{version}/fork", h.forkConfigRule)
}
// ── /me ─────────────────────────────────────────────────────────────────────
@@ -1194,6 +1221,80 @@ func (h *Handler) deleteSynthetic(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
}
func (h *Handler) listSyntheticVersions(w http.ResponseWriter, r *http.Request) {
if h.synthetic == nil {
jsonError(w, http.StatusServiceUnavailable, "synthetic data source not enabled")
return
}
name := r.PathValue("name")
versions, err := h.synthetic.Versions(name)
if err != nil {
jsonError(w, http.StatusNotFound, "synthetic signal not found: "+name)
return
}
jsonOK(w, versions)
}
func (h *Handler) getSyntheticVersion(w http.ResponseWriter, r *http.Request) {
if h.synthetic == nil {
jsonError(w, http.StatusServiceUnavailable, "synthetic data source not enabled")
return
}
name := r.PathValue("name")
version, err := strconv.Atoi(r.PathValue("version"))
if err != nil {
jsonError(w, http.StatusBadRequest, "invalid version: "+r.PathValue("version"))
return
}
def, err := h.synthetic.GetVersion(name, version)
if err != nil {
jsonError(w, http.StatusNotFound, "version not found")
return
}
jsonOK(w, def)
}
func (h *Handler) promoteSyntheticVersion(w http.ResponseWriter, r *http.Request) {
if h.synthetic == nil {
jsonError(w, http.StatusServiceUnavailable, "synthetic data source not enabled")
return
}
name := r.PathValue("name")
version, err := strconv.Atoi(r.PathValue("version"))
if err != nil {
jsonError(w, http.StatusBadRequest, "invalid version: "+r.PathValue("version"))
return
}
def, err := h.synthetic.PromoteVersion(name, version)
if err != nil {
jsonError(w, http.StatusNotFound, "version not found")
return
}
h.recordMutation(r, "synthetic.promote", fmt.Sprintf("%s v%d", name, version))
jsonOK(w, def)
}
func (h *Handler) forkSyntheticVersion(w http.ResponseWriter, r *http.Request) {
if h.synthetic == nil {
jsonError(w, http.StatusServiceUnavailable, "synthetic data source not enabled")
return
}
name := r.PathValue("name")
version, err := strconv.Atoi(r.PathValue("version"))
if err != nil {
jsonError(w, http.StatusBadRequest, "invalid version: "+r.PathValue("version"))
return
}
def, err := h.synthetic.ForkVersion(name, version)
if err != nil {
jsonError(w, http.StatusNotFound, "version not found")
return
}
h.recordMutation(r, "synthetic.fork", fmt.Sprintf("%s v%d -> %s", name, version, def.Name))
w.WriteHeader(http.StatusCreated)
jsonOK(w, map[string]string{"id": def.Name})
}
// ── Control logic ──────────────────────────────────────────────────────────────
func (h *Handler) listControlLogic(w http.ResponseWriter, _ *http.Request) {
@@ -1294,6 +1395,88 @@ func (h *Handler) deleteControlLogic(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
}
func (h *Handler) listControlLogicVersions(w http.ResponseWriter, r *http.Request) {
if h.ctrlLogic == nil {
jsonError(w, http.StatusServiceUnavailable, "control logic not enabled")
return
}
versions, err := h.ctrlLogic.Versions(r.PathValue("id"))
if err != nil {
jsonError(w, http.StatusNotFound, "control logic graph not found: "+r.PathValue("id"))
return
}
jsonOK(w, versions)
}
func (h *Handler) getControlLogicVersion(w http.ResponseWriter, r *http.Request) {
if h.ctrlLogic == nil {
jsonError(w, http.StatusServiceUnavailable, "control logic not enabled")
return
}
version, err := strconv.Atoi(r.PathValue("version"))
if err != nil {
jsonError(w, http.StatusBadRequest, "invalid version: "+r.PathValue("version"))
return
}
g, err := h.ctrlLogic.GetVersion(r.PathValue("id"), version)
if err != nil {
jsonError(w, http.StatusNotFound, "version not found")
return
}
jsonOK(w, g)
}
func (h *Handler) promoteControlLogicVersion(w http.ResponseWriter, r *http.Request) {
if h.ctrlLogic == nil {
jsonError(w, http.StatusServiceUnavailable, "control logic not enabled")
return
}
if !h.policy.CanEditLogic(caller(r)) {
jsonError(w, http.StatusForbidden, "you are not permitted to edit logic")
return
}
id := r.PathValue("id")
version, err := strconv.Atoi(r.PathValue("version"))
if err != nil {
jsonError(w, http.StatusBadRequest, "invalid version: "+r.PathValue("version"))
return
}
g, err := h.ctrlLogic.Promote(id, version)
if err != nil {
jsonError(w, http.StatusNotFound, "version not found")
return
}
h.ctrlEngine.Reload()
h.recordMutation(r, "controllogic.promote", fmt.Sprintf("%s v%d", id, version))
jsonOK(w, g)
}
func (h *Handler) forkControlLogicVersion(w http.ResponseWriter, r *http.Request) {
if h.ctrlLogic == nil {
jsonError(w, http.StatusServiceUnavailable, "control logic not enabled")
return
}
if !h.policy.CanEditLogic(caller(r)) {
jsonError(w, http.StatusForbidden, "you are not permitted to edit logic")
return
}
id := r.PathValue("id")
version, err := strconv.Atoi(r.PathValue("version"))
if err != nil {
jsonError(w, http.StatusBadRequest, "invalid version: "+r.PathValue("version"))
return
}
g, err := h.ctrlLogic.Fork(id, version)
if err != nil {
jsonError(w, http.StatusNotFound, "version not found")
return
}
h.ctrlEngine.Reload()
h.recordMutation(r, "controllogic.fork", fmt.Sprintf("%s v%d -> %s", id, version, g.ID))
w.WriteHeader(http.StatusCreated)
jsonOK(w, map[string]string{"id": g.ID})
}
// ── Helpers ───────────────────────────────────────────────────────────────────
// extractLogicBlock returns the verbatim <logic>…</logic> section of an
+2 -1
View File
@@ -52,13 +52,14 @@ func setup(t *testing.T) (*httptest.Server, func()) {
if err != nil {
t.Fatal("controllogic.NewStore:", err)
}
clEngine := controllogic.NewEngine(ctx, brk, clStore, audit.Nop(), log)
cfgStore, err := confmgr.New(dir)
if err != nil {
t.Fatal("confmgr.New:", err)
}
clEngine := controllogic.NewEngine(ctx, brk, clStore, cfgStore, audit.Nop(), log)
mux := http.NewServeMux()
api.New(brk, nil, store, cfgStore, access.New("", nil, nil, nil, nil), acl, clStore, clEngine, audit.Nop(), "", "", log).Register(mux, "/api/v1")
+321
View File
@@ -6,8 +6,10 @@ import (
"errors"
"net/http"
"strconv"
"sync"
"time"
"github.com/uopi/uopi/internal/broker"
"github.com/uopi/uopi/internal/confmgr"
)
@@ -444,3 +446,322 @@ func (h *Handler) resolveInstance(id, version string) (confmgr.ConfigInstance, e
}
return h.cfg.GetInstanceVersion(id, v)
}
// validateConfigInstance evaluates the CUE rules bound to an instance's set
// against its stored values, without persisting anything. The structured
// RuleResult (violations + any transformed values) lets the UI surface
// per-parameter failures.
func (h *Handler) validateConfigInstance(w http.ResponseWriter, r *http.Request) {
if !h.configEnabled(w) {
return
}
inst, err := h.cfg.GetInstance(r.PathValue("id"))
if err != nil {
jsonError(w, configStatus(err), err.Error())
return
}
res, err := h.cfg.ValidateInstanceRules(inst)
if err != nil {
jsonError(w, http.StatusInternalServerError, err.Error())
return
}
jsonOK(w, res)
}
// ── config snapshot / live diff ─────────────────────────────────────────────
// readResult is the per-signal outcome of a parallel live read.
type readResult struct {
val any
err error
}
// readSetSignals reads the current value of every distinct target signal of a
// set in parallel (deduplicated by ds+signal), bounded by ctx. The returned map
// is keyed by {ds, signal}.
func (h *Handler) readSetSignals(ctx context.Context, set confmgr.ConfigSet) map[[2]string]readResult {
jobs := make(map[[2]string]struct{}, len(set.Parameters))
for _, p := range set.Parameters {
jobs[[2]string{p.DS, p.Signal}] = struct{}{}
}
results := make(map[[2]string]readResult, len(jobs))
var mu sync.Mutex
var wg sync.WaitGroup
for k := range jobs {
wg.Add(1)
go func(ds, signal string) {
defer wg.Done()
v, err := h.broker.ReadNow(ctx, broker.SignalRef{DS: ds, Name: signal})
mu.Lock()
results[[2]string{ds, signal}] = readResult{val: v.Data, err: err}
mu.Unlock()
}(k[0], k[1])
}
wg.Wait()
return results
}
// snapshotReader builds a confmgr.ReadFunc backed by a pre-read results map.
func snapshotReader(results map[[2]string]readResult) confmgr.ReadFunc {
return func(ds, signal string) (any, error) {
r, ok := results[[2]string{ds, signal}]
if !ok {
return nil, errors.New("signal not read")
}
return r.val, r.err
}
}
// snapshotConfigSet captures the current value of every target signal of a set
// and stores them as a new config instance. Body: optional {name}. Returns the
// created instance plus the per-parameter capture result.
func (h *Handler) snapshotConfigSet(w http.ResponseWriter, r *http.Request) {
if !h.configEnabled(w) {
return
}
set, err := h.cfg.GetSet(r.PathValue("id"))
if err != nil {
jsonError(w, configStatus(err), err.Error())
return
}
var body struct {
Name string `json:"name"`
}
if r.Body != nil {
_ = json.NewDecoder(r.Body).Decode(&body) // body is optional
}
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
results := h.readSetSignals(ctx, set)
snap := confmgr.Snapshot(set, snapshotReader(results))
name := body.Name
if name == "" {
name = set.Name + " snapshot " + time.Now().Format("2006-01-02 15:04:05")
}
inst := confmgr.ConfigInstance{
Name: name,
SetID: set.ID,
Owner: caller(r),
Values: snap.Values,
}
out, err := h.cfg.CreateInstance(inst, r.URL.Query().Get("tag"))
if err != nil {
jsonError(w, configStatus(err), err.Error())
return
}
h.recordMutation(r, "config.instance.snapshot", out.ID+" "+out.Name+" captured="+strconv.Itoa(snap.Captured)+" failed="+strconv.Itoa(snap.Failed))
w.WriteHeader(http.StatusCreated)
jsonOK(w, struct {
Instance confmgr.ConfigInstance `json:"instance"`
Snapshot confmgr.SnapshotResult `json:"snapshot"`
}{out, snap})
}
// diffConfigInstanceLive compares a stored instance against the current live
// values of its set's target signals, so an operator can see how the saved
// config differs from what the hardware currently holds. The stored side uses
// resolved values (instance value or parameter default) so default-backed
// parameters are not reported as spurious additions.
func (h *Handler) diffConfigInstanceLive(w http.ResponseWriter, r *http.Request) {
if !h.configEnabled(w) {
return
}
inst, err := h.cfg.GetInstance(r.PathValue("id"))
if err != nil {
jsonError(w, configStatus(err), err.Error())
return
}
set, err := h.cfg.SetForInstance(inst)
if err != nil {
jsonError(w, configStatus(err), "load set: "+err.Error())
return
}
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
results := h.readSetSignals(ctx, set)
snap := confmgr.Snapshot(set, snapshotReader(results))
left := confmgr.ConfigInstance{ID: inst.ID, Name: inst.Name, Values: map[string]any{}}
for _, p := range set.Parameters {
if v, ok := inst.Resolve(p); ok {
left.Values[p.Key] = v
}
}
current := confmgr.ConfigInstance{Name: "current", Values: snap.Values}
jsonOK(w, confmgr.DiffInstances(left, current))
}
// ── config rules (CUE validation/transformation) ────────────────────────────
func (h *Handler) listConfigRules(w http.ResponseWriter, _ *http.Request) {
if !h.configEnabled(w) {
return
}
rules, err := h.cfg.List(confmgr.KindRule)
if err != nil {
jsonError(w, http.StatusInternalServerError, err.Error())
return
}
jsonOK(w, rules)
}
func (h *Handler) getConfigRule(w http.ResponseWriter, r *http.Request) {
if !h.configEnabled(w) {
return
}
rule, err := h.cfg.GetRule(r.PathValue("id"))
if err != nil {
jsonError(w, configStatus(err), err.Error())
return
}
jsonOK(w, rule)
}
func (h *Handler) createConfigRule(w http.ResponseWriter, r *http.Request) {
if !h.configEnabled(w) {
return
}
var rule confmgr.ConfigRule
if err := json.NewDecoder(r.Body).Decode(&rule); err != nil {
jsonError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
return
}
rule.ID = ""
rule.Owner = caller(r)
out, err := h.cfg.CreateRule(rule, r.URL.Query().Get("tag"))
if err != nil {
jsonError(w, configStatus(err), err.Error())
return
}
h.recordMutation(r, "config.rule.create", out.ID+" "+out.Name)
w.WriteHeader(http.StatusCreated)
jsonOK(w, out)
}
func (h *Handler) updateConfigRule(w http.ResponseWriter, r *http.Request) {
if !h.configEnabled(w) {
return
}
id := r.PathValue("id")
var rule confmgr.ConfigRule
if err := json.NewDecoder(r.Body).Decode(&rule); err != nil {
jsonError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
return
}
out, err := h.cfg.UpdateRule(id, rule, r.URL.Query().Get("tag"))
if err != nil {
jsonError(w, configStatus(err), err.Error())
return
}
h.recordMutation(r, "config.rule.update", out.ID+" v"+strconv.Itoa(out.Version))
jsonOK(w, out)
}
func (h *Handler) deleteConfigRule(w http.ResponseWriter, r *http.Request) {
if !h.configEnabled(w) {
return
}
id := r.PathValue("id")
if err := h.cfg.Delete(confmgr.KindRule, id); err != nil {
jsonError(w, configStatus(err), err.Error())
return
}
h.recordMutation(r, "config.rule.delete", id)
w.WriteHeader(http.StatusNoContent)
}
func (h *Handler) listConfigRuleVersions(w http.ResponseWriter, r *http.Request) {
if !h.configEnabled(w) {
return
}
versions, err := h.cfg.Versions(confmgr.KindRule, r.PathValue("id"))
if err != nil {
jsonError(w, configStatus(err), err.Error())
return
}
jsonOK(w, versions)
}
func (h *Handler) getConfigRuleVersion(w http.ResponseWriter, r *http.Request) {
if !h.configEnabled(w) {
return
}
version, err := pathVersion(r)
if err != nil {
jsonError(w, http.StatusBadRequest, "invalid version")
return
}
rule, err := h.cfg.GetRuleVersion(r.PathValue("id"), version)
if err != nil {
jsonError(w, configStatus(err), err.Error())
return
}
jsonOK(w, rule)
}
func (h *Handler) promoteConfigRule(w http.ResponseWriter, r *http.Request) {
if !h.configEnabled(w) {
return
}
version, err := pathVersion(r)
if err != nil {
jsonError(w, http.StatusBadRequest, "invalid version")
return
}
id := r.PathValue("id")
if err := h.cfg.Promote(confmgr.KindRule, id, version); err != nil {
jsonError(w, configStatus(err), err.Error())
return
}
h.recordMutation(r, "config.rule.promote", id+" v"+strconv.Itoa(version))
rule, err := h.cfg.GetRule(id)
if err != nil {
jsonError(w, configStatus(err), err.Error())
return
}
jsonOK(w, rule)
}
func (h *Handler) forkConfigRule(w http.ResponseWriter, r *http.Request) {
if !h.configEnabled(w) {
return
}
version, err := pathVersion(r)
if err != nil {
jsonError(w, http.StatusBadRequest, "invalid version")
return
}
id := r.PathValue("id")
newID, err := h.cfg.Fork(confmgr.KindRule, id, version)
if err != nil {
jsonError(w, configStatus(err), err.Error())
return
}
h.recordMutation(r, "config.rule.fork", id+" v"+strconv.Itoa(version)+" -> "+newID)
rule, err := h.cfg.GetRule(newID)
if err != nil {
jsonError(w, configStatus(err), err.Error())
return
}
w.WriteHeader(http.StatusCreated)
jsonOK(w, rule)
}
// checkConfigRule compiles a (possibly unsaved) CUE source and, when sample
// values are supplied, evaluates it against them. It powers the editor's live
// validation panel; nothing is persisted.
func (h *Handler) checkConfigRule(w http.ResponseWriter, r *http.Request) {
if !h.configEnabled(w) {
return
}
var body struct {
Source string `json:"source"`
Values map[string]any `json:"values"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
jsonError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
return
}
jsonOK(w, confmgr.EvaluateRule(body.Source, body.Values))
}
+25
View File
@@ -251,6 +251,31 @@ func (b *Broker) fanOut(ref SignalRef, sub *signalSub, rawCh <-chan datasource.V
}
}
// ReadNow performs a one-shot synchronous read of a signal's current value.
// It starts a dedicated upstream subscription, returns the first value the data
// source delivers, then tears the subscription down. The read is bounded by ctx
// (callers should pass a timeout). This bypasses the shared fan-out cache because
// not every signal of interest (e.g. arbitrary config-set targets) is otherwise
// subscribed.
func (b *Broker) ReadNow(ctx context.Context, ref SignalRef) (datasource.Value, error) {
ds, ok := b.Source(ref.DS)
if !ok {
return datasource.Value{}, fmt.Errorf("unknown data source %q", ref.DS)
}
ch := make(chan datasource.Value, 1)
cancel, err := ds.Subscribe(ctx, ref.Name, ch)
if err != nil {
return datasource.Value{}, fmt.Errorf("read %s/%s: %w", ref.DS, ref.Name, err)
}
defer cancel()
select {
case v := <-ch:
return v, nil
case <-ctx.Done():
return datasource.Value{}, ctx.Err()
}
}
// ActiveSubscriptions returns the number of currently active upstream signal
// subscriptions. Useful for diagnostics and tests.
func (b *Broker) ActiveSubscriptions() int {
+199
View File
@@ -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...)
}
+80
View File
@@ -0,0 +1,80 @@
package confmgr
import "testing"
func TestEvaluateRule_Valid(t *testing.T) {
src := `
current_limit: >=0 & <=max
max: 100
`
res := EvaluateRule(src, map[string]any{"current_limit": 50.0})
if !res.OK {
t.Fatalf("expected OK, got violations: %+v compile=%q", res.Violations, res.CompileError)
}
if res.CompileError != "" {
t.Fatalf("unexpected compile error: %s", res.CompileError)
}
}
func TestEvaluateRule_Violation(t *testing.T) {
src := `current_limit: >=0 & <=100`
res := EvaluateRule(src, map[string]any{"current_limit": 150.0})
if res.OK {
t.Fatalf("expected violation, got OK")
}
if len(res.Violations) == 0 {
t.Fatalf("expected at least one violation")
}
}
func TestEvaluateRule_Transform(t *testing.T) {
// power is derived from the supplied current & voltage.
src := `
current: number
voltage: number
power: current * voltage
`
res := EvaluateRule(src, map[string]any{"current": 2.0, "voltage": 3.0})
if !res.OK {
t.Fatalf("expected OK, got %+v", res.Violations)
}
got, ok := res.Transformed["power"]
if !ok {
t.Fatalf("expected transformed power, got %+v", res.Transformed)
}
if f, _ := toFloat(got); f != 6 {
t.Fatalf("expected power=6, got %v", got)
}
}
func TestEvaluateRule_DefaultFillsMissing(t *testing.T) {
src := `mode: *"auto" | "manual"`
res := EvaluateRule(src, map[string]any{})
if !res.OK {
t.Fatalf("expected OK, got %+v", res.Violations)
}
if res.Transformed["mode"] != "auto" {
t.Fatalf("expected mode default auto, got %v", res.Transformed["mode"])
}
}
func TestEvaluateRule_CompileError(t *testing.T) {
res := EvaluateRule(`this is : : not cue`, map[string]any{})
if res.OK || res.CompileError == "" {
t.Fatalf("expected compile error, got %+v", res)
}
}
func TestConfigRule_Validate(t *testing.T) {
r := ConfigRule{Name: "r", SetID: "s", Source: `x: >=0`}
if err := r.Validate(); err != nil {
t.Fatalf("expected valid rule, got %v", err)
}
bad := ConfigRule{Name: "r", SetID: "s", Source: `x: : :`}
if err := bad.Validate(); err == nil {
t.Fatalf("expected compile error for bad source")
}
if err := (ConfigRule{Source: `x: 1`}).Validate(); err == nil {
t.Fatalf("expected error for missing name/setID")
}
}
+129
View File
@@ -0,0 +1,129 @@
package confmgr
import (
"fmt"
"math"
"slices"
"strconv"
)
// ReadFunc reads the current raw value of a target signal. The API/engine layer
// supplies a closure backed by the broker (ReadNow) so confmgr stays decoupled
// from the transport. The returned value mirrors datasource.Value.Data
// (float64 | []float64 | string | int64 | bool; enums arrive as an int64 index).
type ReadFunc func(ds, signal string) (any, error)
// SnapshotEntry records the outcome of reading one parameter's target signal.
type SnapshotEntry struct {
Key string `json:"key"`
DS string `json:"ds"`
Signal string `json:"signal"`
Value any `json:"value,omitempty"`
OK bool `json:"ok"`
Error string `json:"error,omitempty"`
}
// SnapshotResult summarises a snapshot run. Values holds the captured,
// type-coerced values ready to populate a new ConfigInstance.
type SnapshotResult struct {
SetID string `json:"setId"`
Entries []SnapshotEntry `json:"entries"`
Captured int `json:"captured"`
Failed int `json:"failed"`
Values map[string]any `json:"-"`
}
// Snapshot reads the current value of every parameter's target signal via read
// and builds a value map for a new instance. Read failures and values that
// cannot be coerced to the parameter's type are recorded per-entry rather than
// aborting the whole snapshot, so a partial capture is reported faithfully.
func Snapshot(set ConfigSet, read ReadFunc) SnapshotResult {
res := SnapshotResult{
SetID: set.ID,
Entries: make([]SnapshotEntry, 0, len(set.Parameters)),
Values: make(map[string]any, len(set.Parameters)),
}
for _, p := range set.Parameters {
e := SnapshotEntry{Key: p.Key, DS: p.DS, Signal: p.Signal}
raw, err := read(p.DS, p.Signal)
if err != nil {
e.Error = err.Error()
res.Failed++
res.Entries = append(res.Entries, e)
continue
}
v, err := p.coerceSnapshot(raw)
if err != nil {
e.Error = err.Error()
res.Failed++
res.Entries = append(res.Entries, e)
continue
}
e.Value = v
e.OK = true
res.Values[p.Key] = v
res.Captured++
res.Entries = append(res.Entries, e)
}
return res
}
// coerceSnapshot converts a raw datasource value into the canonical Go value the
// parameter's type expects, so the result passes checkValue and serialises
// cleanly. Enum signals arrive as an int64 index and are mapped to their string.
func (p Parameter) coerceSnapshot(raw any) (any, error) {
switch p.Type {
case TypeFloat:
return toFloat(raw)
case TypeInt:
f, err := toFloat(raw)
if err != nil {
return nil, err
}
return int64(math.Round(f)), nil
case TypeBool:
switch b := raw.(type) {
case bool:
return b, nil
case string:
pb, err := strconv.ParseBool(b)
if err != nil {
return nil, fmt.Errorf("value %q is not a bool", b)
}
return pb, nil
default:
f, err := toFloat(raw)
if err != nil {
return nil, fmt.Errorf("value %v is not a bool", raw)
}
return f != 0, nil
}
case TypeString:
if s, ok := raw.(string); ok {
return s, nil
}
return fmt.Sprintf("%v", raw), nil
case TypeEnum:
// Enum signals deliver an int64 index into the metadata strings; map it
// to this parameter's enum value. A string already in range is kept.
if s, ok := raw.(string); ok {
if slices.Contains(p.EnumValues, s) {
return s, nil
}
return nil, fmt.Errorf("value %q is not an allowed enum value", s)
}
f, err := toFloat(raw)
if err != nil {
return nil, fmt.Errorf("enum value %v is not an index", raw)
}
idx := int(math.Round(f))
if idx < 0 || idx >= len(p.EnumValues) {
return nil, fmt.Errorf("enum index %d out of range", idx)
}
return p.EnumValues[idx], nil
case TypeFloatArray:
return toFloatArray(raw)
default:
return raw, nil
}
}
+83
View File
@@ -0,0 +1,83 @@
package confmgr
import (
"errors"
"testing"
)
func TestSnapshotCapturesAndCoerces(t *testing.T) {
set := ConfigSet{
ID: "set1",
Name: "s",
Parameters: []Parameter{
{Key: "v", DS: "epics", Signal: "PSU:V", Type: TypeFloat},
{Key: "n", DS: "epics", Signal: "PSU:N", Type: TypeInt},
{Key: "en", DS: "epics", Signal: "PSU:EN", Type: TypeBool},
{Key: "mode", DS: "epics", Signal: "PSU:MODE", Type: TypeEnum, EnumValues: []string{"off", "low", "high"}},
{Key: "wf", DS: "epics", Signal: "PSU:WF", Type: TypeFloatArray},
{Key: "lbl", DS: "epics", Signal: "PSU:LBL", Type: TypeString},
},
}
live := map[string]any{
"PSU:V": 24.5,
"PSU:N": 3.0, // float reading → coerced to int64
"PSU:EN": int64(1), // numeric → bool true
"PSU:MODE": int64(2), // enum index → "high"
"PSU:WF": []float64{1, 2, 3},
"PSU:LBL": "ready",
}
res := Snapshot(set, func(_, signal string) (any, error) {
v, ok := live[signal]
if !ok {
return nil, errors.New("not read")
}
return v, nil
})
if res.Captured != 6 || res.Failed != 0 {
t.Fatalf("summary: captured=%d failed=%d", res.Captured, res.Failed)
}
if res.Values["v"] != 24.5 {
t.Errorf("v = %v, want 24.5", res.Values["v"])
}
if res.Values["n"] != int64(3) {
t.Errorf("n = %v (%T), want int64(3)", res.Values["n"], res.Values["n"])
}
if res.Values["en"] != true {
t.Errorf("en = %v, want true", res.Values["en"])
}
if res.Values["mode"] != "high" {
t.Errorf("mode = %v, want high", res.Values["mode"])
}
if res.Values["lbl"] != "ready" {
t.Errorf("lbl = %v, want ready", res.Values["lbl"])
}
// The captured values must validate against the set.
inst := ConfigInstance{SetID: set.ID, Values: res.Values}
if err := inst.ValidateAgainst(set); err != nil {
t.Errorf("snapshot values fail validation: %v", err)
}
}
func TestSnapshotReadFailureRecorded(t *testing.T) {
set := ConfigSet{
ID: "set1",
Name: "s",
Parameters: []Parameter{
{Key: "a", DS: "epics", Signal: "A", Type: TypeFloat},
{Key: "b", DS: "epics", Signal: "B", Type: TypeFloat},
},
}
res := Snapshot(set, func(_, signal string) (any, error) {
if signal == "B" {
return nil, errors.New("offline")
}
return 1.0, nil
})
if res.Captured != 1 || res.Failed != 1 {
t.Fatalf("summary: captured=%d failed=%d", res.Captured, res.Failed)
}
if _, ok := res.Values["b"]; ok {
t.Errorf("failed parameter must not appear in values")
}
}
+138 -4
View File
@@ -22,13 +22,18 @@ type Kind int
const (
KindSet Kind = iota
KindInstance
KindRule
)
func (k Kind) sub() string {
if k == KindInstance {
switch k {
case KindInstance:
return "instances"
case KindRule:
return "rules"
default:
return "sets"
}
return "sets"
}
// Meta is the lightweight listing representation.
@@ -36,6 +41,10 @@ type Meta struct {
ID string `json:"id"`
Name string `json:"name"`
Version int `json:"version"`
// SetID is only populated for instances (the schema they belong to); it is
// empty for sets. Lets clients group/filter instances by set without a GET
// per instance.
SetID string `json:"setId,omitempty"`
}
// VersionMeta describes a single persisted revision.
@@ -59,7 +68,7 @@ type Store struct {
// New opens (and creates, if needed) the configs storage directories.
func New(storageDir string) (*Store, error) {
s := &Store{rootDir: storageDir, dirs: map[Kind]string{}}
for _, k := range []Kind{KindSet, KindInstance} {
for _, k := range []Kind{KindSet, KindInstance, KindRule} {
dir := filepath.Join(storageDir, "configs", k.sub())
if err := os.MkdirAll(dir, 0o755); err != nil {
return nil, fmt.Errorf("create %s dir: %w", k.sub(), err)
@@ -75,6 +84,7 @@ type header struct {
Name string `json:"name"`
Version int `json:"version"`
Tag string `json:"tag"`
SetID string `json:"setId"`
}
func validateID(id string) error {
@@ -139,7 +149,7 @@ func (s *Store) List(k Kind) ([]Meta, error) {
if err != nil {
continue
}
out = append(out, Meta{ID: id, Name: h.Name, Version: h.Version})
out = append(out, Meta{ID: id, Name: h.Name, Version: h.Version, SetID: h.SetID})
}
return out, nil
}
@@ -462,6 +472,9 @@ func (s *Store) CreateInstance(inst ConfigInstance, tag string) (ConfigInstance,
if err := inst.ValidateAgainst(set); err != nil {
return ConfigInstance{}, err
}
if err := s.applyRules(&inst, set); err != nil {
return ConfigInstance{}, err
}
obj, err := toMap(inst)
if err != nil {
return ConfigInstance{}, err
@@ -483,6 +496,9 @@ func (s *Store) UpdateInstance(id string, inst ConfigInstance, tag string) (Conf
if err := inst.ValidateAgainst(set); err != nil {
return ConfigInstance{}, err
}
if err := s.applyRules(&inst, set); err != nil {
return ConfigInstance{}, err
}
obj, err := toMap(inst)
if err != nil {
return ConfigInstance{}, err
@@ -500,6 +516,124 @@ func (s *Store) SetForInstance(inst ConfigInstance) (ConfigSet, error) {
return s.setForInstance(inst)
}
// ── rules ────────────────────────────────────────────────────────────────────
// GetRule returns the current revision of a CUE validation/transformation rule.
func (s *Store) GetRule(id string) (ConfigRule, error) {
data, err := s.get(KindRule, id)
if err != nil {
return ConfigRule{}, err
}
var rule ConfigRule
return rule, json.Unmarshal(data, &rule)
}
// GetRuleVersion returns a specific revision of a rule.
func (s *Store) GetRuleVersion(id string, version int) (ConfigRule, error) {
data, err := s.getVersion(KindRule, id, version)
if err != nil {
return ConfigRule{}, err
}
var rule ConfigRule
return rule, json.Unmarshal(data, &rule)
}
// CreateRule validates the rule's CUE source and stores it.
func (s *Store) CreateRule(rule ConfigRule, tag string) (ConfigRule, error) {
if err := rule.Validate(); err != nil {
return ConfigRule{}, err
}
obj, err := toMap(rule)
if err != nil {
return ConfigRule{}, err
}
_, data, err := s.create(KindRule, obj, tag)
if err != nil {
return ConfigRule{}, err
}
var out ConfigRule
return out, json.Unmarshal(data, &out)
}
// UpdateRule validates and stores a new revision of an existing rule.
func (s *Store) UpdateRule(id string, rule ConfigRule, tag string) (ConfigRule, error) {
if err := rule.Validate(); err != nil {
return ConfigRule{}, err
}
obj, err := toMap(rule)
if err != nil {
return ConfigRule{}, err
}
data, err := s.update(KindRule, id, obj, tag)
if err != nil {
return ConfigRule{}, err
}
var out ConfigRule
return out, json.Unmarshal(data, &out)
}
// rulesForSet loads every current-revision rule bound to the given set.
func (s *Store) rulesForSet(setID string) ([]ConfigRule, error) {
metas, err := s.List(KindRule)
if err != nil {
return nil, err
}
var out []ConfigRule
for _, m := range metas {
if m.SetID != setID {
continue
}
rule, err := s.GetRule(m.ID)
if err != nil {
continue
}
out = append(out, rule)
}
return out, nil
}
// applyRules runs every rule bound to inst's set against its values. On
// success it merges rule-derived values back into inst (parameter keys only) so
// the stored instance reflects the canonical, transformed configuration. A
// violation is returned as *RuleError so the caller can surface the structured
// details.
func (s *Store) applyRules(inst *ConfigInstance, set ConfigSet) error {
rules, err := s.rulesForSet(inst.SetID)
if err != nil {
return err
}
if len(rules) == 0 {
return nil
}
res := evaluateRules(rules, inst.Values)
if !res.OK {
return &RuleError{Result: res}
}
for k, v := range res.Transformed {
if _, ok := set.param(k); ok {
if inst.Values == nil {
inst.Values = map[string]any{}
}
inst.Values[k] = v
}
}
return nil
}
// ValidateInstanceRules evaluates the stored rules for an instance without
// persisting anything. It is the read-only counterpart used by the validate
// endpoint.
func (s *Store) ValidateInstanceRules(inst ConfigInstance) (RuleResult, error) {
rules, err := s.rulesForSet(inst.SetID)
if err != nil {
return RuleResult{}, err
}
if len(rules) == 0 {
return RuleResult{OK: true}, nil
}
return evaluateRules(rules, inst.Values), nil
}
// ── JSON helpers ────────────────────────────────────────────────────────────
func name(obj map[string]any) string {
+70
View File
@@ -41,6 +41,76 @@ func TestCreateAndGetSet(t *testing.T) {
}
}
func TestRuleBlocksInvalidInstance(t *testing.T) {
s, _ := New(t.TempDir())
set, _ := s.CreateSet(sampleSet(), "")
if _, err := s.CreateRule(ConfigRule{
Name: "voltage cap",
SetID: set.ID,
Source: "voltage: <=24",
}, ""); err != nil {
t.Fatal(err)
}
// In-range value passes.
if _, err := s.CreateInstance(ConfigInstance{
Name: "ok", SetID: set.ID, Values: map[string]any{"voltage": 20.0},
}, ""); err != nil {
t.Fatalf("expected in-range instance to save, got %v", err)
}
// Out-of-range value is rejected by the rule.
_, err := s.CreateInstance(ConfigInstance{
Name: "bad", SetID: set.ID, Values: map[string]any{"voltage": 40.0},
}, "")
if err == nil {
t.Fatalf("expected rule violation to block save")
}
var re *RuleError
if !asRuleError(err, &re) {
t.Fatalf("expected *RuleError, got %T: %v", err, err)
}
}
func TestRuleTransformPersists(t *testing.T) {
s, _ := New(t.TempDir())
set := sampleSet()
max := 48.0
set.Parameters = append(set.Parameters, Parameter{Key: "vmax", DS: "epics", Signal: "PSU:VMAX", Type: TypeFloat, Min: &max})
created, _ := s.CreateSet(set, "")
// Rule derives vmax = voltage * 2 (within range for voltage=20 -> 40).
if _, err := s.CreateRule(ConfigRule{
Name: "vmax derive", SetID: created.ID, Source: "voltage: number\nvmax: voltage * 2",
}, ""); err != nil {
t.Fatal(err)
}
inst, err := s.CreateInstance(ConfigInstance{
Name: "x", SetID: created.ID, Values: map[string]any{"voltage": 20.0},
}, "")
if err != nil {
t.Fatal(err)
}
if f, _ := toFloat(inst.Values["vmax"]); f != 40 {
t.Fatalf("expected derived vmax=40 to persist, got %v", inst.Values["vmax"])
}
}
func asRuleError(err error, target **RuleError) bool {
for err != nil {
if re, ok := err.(*RuleError); ok {
*target = re
return true
}
type unwrapper interface{ Unwrap() error }
u, ok := err.(unwrapper)
if !ok {
return false
}
err = u.Unwrap()
}
return false
}
func TestSetVersioning(t *testing.T) {
s, _ := New(t.TempDir())
created, _ := s.CreateSet(sampleSet(), "")
+234
View File
@@ -0,0 +1,234 @@
package controllogic
import (
"context"
"io"
"log/slog"
"sync"
"testing"
"time"
"github.com/uopi/uopi/internal/audit"
"github.com/uopi/uopi/internal/broker"
"github.com/uopi/uopi/internal/confmgr"
"github.com/uopi/uopi/internal/datasource"
)
// writableSource is a minimal in-memory DataSource that records the last value
// written to each signal, so config-apply/read nodes can be tested end-to-end.
type writableSource struct {
mu sync.Mutex
written map[string]any
}
func newWritableSource() *writableSource { return &writableSource{written: map[string]any{}} }
func (s *writableSource) Name() string { return "tgt" }
func (s *writableSource) Connect(context.Context) error { return nil }
func (s *writableSource) ListSignals(context.Context) ([]datasource.Metadata, error) {
return nil, nil
}
func (s *writableSource) GetMetadata(_ context.Context, sig string) (datasource.Metadata, error) {
return datasource.Metadata{Name: sig, Writable: true}, nil
}
// Subscribe delivers the signal's last-written value once (if any), so a
// one-shot ReadNow (used by config snapshot) resolves immediately.
func (s *writableSource) Subscribe(_ context.Context, sig string, ch chan<- datasource.Value) (datasource.CancelFunc, error) {
s.mu.Lock()
v, ok := s.written[sig]
s.mu.Unlock()
if ok {
select {
case ch <- datasource.Value{Data: v, Timestamp: time.Now()}:
default:
}
}
return func() {}, nil
}
func (s *writableSource) Write(_ context.Context, signal string, value any) error {
s.mu.Lock()
defer s.mu.Unlock()
s.written[signal] = value
return nil
}
func (s *writableSource) History(context.Context, string, time.Time, time.Time, int) ([]datasource.Value, error) {
return nil, datasource.ErrHistoryUnavailable
}
func (s *writableSource) get(signal string) (any, bool) {
s.mu.Lock()
defer s.mu.Unlock()
v, ok := s.written[signal]
return v, ok
}
// setupConfigEngine wires a broker (with a writable "tgt" source), a confmgr
// store seeded with a set + instance, and an Engine bound to both.
func setupConfigEngine(t *testing.T) (*Engine, *writableSource, string) {
t.Helper()
log := slog.New(slog.NewTextHandler(io.Discard, nil))
ctx := context.Background()
src := newWritableSource()
brk := broker.New(ctx, log)
brk.Register(src)
cfg, err := confmgr.New(t.TempDir())
if err != nil {
t.Fatal("confmgr.New:", err)
}
set, err := cfg.CreateSet(confmgr.ConfigSet{
Name: "tuning",
Parameters: []confmgr.Parameter{
{Key: "gain", DS: "tgt", Signal: "GAIN", Type: confmgr.TypeFloat, Default: 1.0},
{Key: "offset", DS: "tgt", Signal: "OFFSET", Type: confmgr.TypeFloat, Default: 0.0},
},
}, "")
if err != nil {
t.Fatal("CreateSet:", err)
}
inst, err := cfg.CreateInstance(confmgr.ConfigInstance{
Name: "warm",
SetID: set.ID,
Values: map[string]any{"gain": 2.5, "offset": 10.0},
}, "")
if err != nil {
t.Fatal("CreateInstance:", err)
}
e := NewEngine(ctx, brk, nil, cfg, audit.Nop(), log)
return e, src, inst.ID
}
func TestApplyConfigWritesEveryParam(t *testing.T) {
e, src, instID := setupConfigEngine(t)
cg := &compiledGraph{name: "flow", engine: e, locals: map[string]float64{}}
e.applyConfig(cg, instID)
if got, ok := src.get("GAIN"); !ok || toNum(got) != 2.5 {
t.Errorf("GAIN = %v (ok=%v), want 2.5", got, ok)
}
if got, ok := src.get("OFFSET"); !ok || toNum(got) != 10.0 {
t.Errorf("OFFSET = %v (ok=%v), want 10.0", got, ok)
}
}
func TestApplyConfigUnknownInstanceNoop(t *testing.T) {
e, src, _ := setupConfigEngine(t)
cg := &compiledGraph{name: "flow", engine: e, locals: map[string]float64{}}
e.applyConfig(cg, "does-not-exist")
if len(src.written) != 0 {
t.Errorf("expected no writes, got %v", src.written)
}
}
func TestReadConfigParam(t *testing.T) {
e, _, instID := setupConfigEngine(t)
if v, ok := e.readConfigParam(instID, "gain"); !ok || v != 2.5 {
t.Errorf("readConfigParam(gain) = %v (ok=%v), want 2.5", v, ok)
}
// Missing param key.
if _, ok := e.readConfigParam(instID, "nope"); ok {
t.Errorf("readConfigParam(nope) returned ok=true, want false")
}
// Missing instance.
if _, ok := e.readConfigParam("does-not-exist", "gain"); ok {
t.Errorf("readConfigParam(missing instance) returned ok=true, want false")
}
}
func TestWriteConfigParam(t *testing.T) {
e, _, instID := setupConfigEngine(t)
cg := &compiledGraph{name: "flow", engine: e, locals: map[string]float64{}}
e.writeConfigParam(cg, instID, "gain", 7.5)
// A new revision should now resolve to the written value.
if v, ok := e.readConfigParam(instID, "gain"); !ok || v != 7.5 {
t.Errorf("after write, gain = %v (ok=%v), want 7.5", v, ok)
}
inst, err := e.cfg.GetInstance(instID)
if err != nil {
t.Fatal("GetInstance:", err)
}
if inst.Version < 2 {
t.Errorf("instance version = %d, want >= 2 (a new revision)", inst.Version)
}
}
func TestCreateConfigInstance(t *testing.T) {
e, _, srcID := setupConfigEngine(t)
cg := &compiledGraph{name: "flow", engine: e, locals: map[string]float64{}}
src, err := e.cfg.GetInstance(srcID)
if err != nil {
t.Fatal("GetInstance:", err)
}
e.createConfigInstance(cg, src.SetID, "cold", srcID)
insts, err := e.cfg.List(confmgr.KindInstance)
if err != nil {
t.Fatal("List:", err)
}
var found *confmgr.ConfigInstance
for i := range insts {
if insts[i].Name == "cold" {
full, err := e.cfg.GetInstance(insts[i].ID)
if err != nil {
t.Fatal("GetInstance:", err)
}
found = &full
}
}
if found == nil {
t.Fatal("created instance 'cold' not found")
}
// Values copied from the source instance.
if got := toNum(found.Values["gain"]); got != 2.5 {
t.Errorf("copied gain = %v, want 2.5", got)
}
}
func TestSnapshotConfig(t *testing.T) {
e, src, instID := setupConfigEngine(t)
cg := &compiledGraph{name: "flow", engine: e, locals: map[string]float64{}}
// Seed current live values for the set's target signals.
_ = src.Write(context.Background(), "GAIN", 3.5)
_ = src.Write(context.Background(), "OFFSET", -2.0)
seed, err := e.cfg.GetInstance(instID)
if err != nil {
t.Fatal("GetInstance:", err)
}
e.snapshotConfig(cg, seed.SetID, "snap1")
insts, err := e.cfg.List(confmgr.KindInstance)
if err != nil {
t.Fatal("List:", err)
}
var found *confmgr.ConfigInstance
for i := range insts {
if insts[i].Name == "snap1" {
full, err := e.cfg.GetInstance(insts[i].ID)
if err != nil {
t.Fatal("GetInstance:", err)
}
found = &full
}
}
if found == nil {
t.Fatal("snapshot instance 'snap1' not found")
}
if got := toNum(found.Values["gain"]); got != 3.5 {
t.Errorf("snapshot gain = %v, want 3.5 (current live value)", got)
}
if got := toNum(found.Values["offset"]); got != -2.0 {
t.Errorf("snapshot offset = %v, want -2.0 (current live value)", got)
}
}
+277 -1
View File
@@ -2,6 +2,8 @@ package controllogic
import (
"context"
"errors"
"fmt"
"log/slog"
"math"
"regexp"
@@ -13,8 +15,25 @@ import (
"github.com/uopi/uopi/internal/audit"
"github.com/uopi/uopi/internal/broker"
"github.com/uopi/uopi/internal/confmgr"
)
// errUnknownSource is returned by config-apply writes targeting a data source
// that isn't registered with the broker.
var errUnknownSource = errors.New("unknown data source")
// formatAny renders a config value for the audit log.
func formatAny(v any) string {
switch x := v.(type) {
case float64:
return strconv.FormatFloat(x, 'g', -1, 64)
case string:
return x
default:
return fmt.Sprintf("%v", v)
}
}
// Guards against runaway flows (cycles / pathological loops).
const (
maxSteps = 100000
@@ -27,6 +46,7 @@ const (
type Engine struct {
broker *broker.Broker
store *Store
cfg *confmgr.Store
audit audit.Recorder
log *slog.Logger
root context.Context
@@ -59,13 +79,14 @@ func (e *Engine) SetNotifier(n Notifier) {
// NewEngine creates an engine bound to root. Call Reload to start it. rec records
// the writes performed by flows; pass audit.Nop() to disable auditing.
func NewEngine(root context.Context, brk *broker.Broker, store *Store, rec audit.Recorder, log *slog.Logger) *Engine {
func NewEngine(root context.Context, brk *broker.Broker, store *Store, cfg *confmgr.Store, rec audit.Recorder, log *slog.Logger) *Engine {
if rec == nil {
rec = audit.Nop()
}
return &Engine{
broker: brk,
store: store,
cfg: cfg,
audit: rec,
log: log,
root: root,
@@ -270,6 +291,237 @@ func (e *Engine) write(cg *compiledGraph, target string, val float64) {
e.audit.Record(ev)
}
// applyConfig resolves a config instance + its set and writes every parameter
// to its target signal via the owning data source (confmgr.Apply). Each write is
// audited. A bare instance id or a missing config store is a no-op (logged).
func (e *Engine) applyConfig(cg *compiledGraph, instanceID string) {
if e.cfg == nil || instanceID == "" {
return
}
inst, err := e.cfg.GetInstance(instanceID)
if err != nil {
e.log.Warn("control logic: config apply: unknown instance", "instance", instanceID, "err", err)
return
}
set, err := e.cfg.SetForInstance(inst)
if err != nil {
e.log.Warn("control logic: config apply: set lookup failed", "instance", instanceID, "err", err)
return
}
write := func(ds, signal string, value any) error {
ev := audit.Event{
Actor: cg.name,
ActorType: audit.ActorSystem,
Action: "signal.write",
DS: ds,
Signal: signal,
Value: formatAny(value),
Detail: "control logic config apply: " + inst.Name,
Outcome: audit.OutcomeOK,
}
var werr error
if ds == "local" {
cg.setLocal(signal, toNum(value))
} else if src, ok := e.broker.Source(ds); ok {
werr = src.Write(e.root, signal, value)
} else {
werr = errUnknownSource
}
if werr != nil {
ev.Outcome = audit.OutcomeError
ev.Error = werr.Error()
}
e.audit.Record(ev)
return werr
}
confmgr.Apply(set, inst, write)
}
// readConfigParam resolves a single parameter's value from a config instance,
// coercing it to float64 for use as a control-logic value. Returns ok=false if
// the store/instance/param is missing or the value isn't numeric.
func (e *Engine) readConfigParam(instanceID, key string) (float64, bool) {
if e.cfg == nil || instanceID == "" || key == "" {
return 0, false
}
inst, err := e.cfg.GetInstance(instanceID)
if err != nil {
e.log.Warn("control logic: config read: unknown instance", "instance", instanceID, "err", err)
return 0, false
}
set, err := e.cfg.SetForInstance(inst)
if err != nil {
e.log.Warn("control logic: config read: set lookup failed", "instance", instanceID, "err", err)
return 0, false
}
for _, p := range set.Parameters {
if p.Key != key {
continue
}
v, ok := inst.Resolve(p)
if !ok {
return 0, false
}
f := toNum(v)
if math.IsNaN(f) {
return 0, false
}
return f, true
}
return 0, false
}
// writeConfigParam sets a single parameter's value on a config instance and
// saves a new revision (git-style). The value is coerced to the parameter's
// declared type (numeric/bool/string) so it validates against the set. A
// missing store/instance/param is a no-op (logged). Audited.
func (e *Engine) writeConfigParam(cg *compiledGraph, instanceID, key string, val float64) {
if e.cfg == nil || instanceID == "" || key == "" {
return
}
inst, err := e.cfg.GetInstance(instanceID)
if err != nil {
e.log.Warn("control logic: config write: unknown instance", "instance", instanceID, "err", err)
return
}
set, err := e.cfg.SetForInstance(inst)
if err != nil {
e.log.Warn("control logic: config write: set lookup failed", "instance", instanceID, "err", err)
return
}
var param *confmgr.Parameter
for i := range set.Parameters {
if set.Parameters[i].Key == key {
param = &set.Parameters[i]
break
}
}
if param == nil {
e.log.Warn("control logic: config write: unknown parameter", "instance", instanceID, "key", key)
return
}
if inst.Values == nil {
inst.Values = map[string]any{}
}
inst.Values[key] = coerceParamValue(*param, val)
ev := audit.Event{
Actor: cg.name,
ActorType: audit.ActorSystem,
Action: "config.instance.update",
Detail: "control logic config write: " + inst.Name + " " + key + "=" + formatAny(inst.Values[key]),
Outcome: audit.OutcomeOK,
}
if _, err := e.cfg.UpdateInstance(instanceID, inst, ""); err != nil {
e.log.Warn("control logic: config write failed", "instance", instanceID, "key", key, "err", err)
ev.Outcome = audit.OutcomeError
ev.Error = err.Error()
}
e.audit.Record(ev)
}
// createConfigInstance creates a new config instance for setID, optionally
// copying values from an existing instance (fromID). A missing store/set is a
// no-op (logged). Audited. The new instance's id is logged but not written back
// to a flow variable (control-logic variables are numeric, ids are strings).
func (e *Engine) createConfigInstance(cg *compiledGraph, setID, name, fromID string) {
if e.cfg == nil || setID == "" {
return
}
if name == "" {
name = "auto"
}
values := map[string]any{}
if fromID != "" {
if src, err := e.cfg.GetInstance(fromID); err == nil {
for k, v := range src.Values {
values[k] = v
}
} else {
e.log.Warn("control logic: config create: copy source missing", "from", fromID, "err", err)
}
}
inst := confmgr.ConfigInstance{Name: name, SetID: setID, Values: values}
ev := audit.Event{
Actor: cg.name,
ActorType: audit.ActorSystem,
Action: "config.instance.create",
Detail: "control logic config create: set=" + setID + " name=" + name,
Outcome: audit.OutcomeOK,
}
out, err := e.cfg.CreateInstance(inst, "")
if err != nil {
e.log.Warn("control logic: config create failed", "set", setID, "err", err)
ev.Outcome = audit.OutcomeError
ev.Error = err.Error()
} else {
ev.Detail += " -> " + out.ID
}
e.audit.Record(ev)
}
// snapshotConfig captures the current value of every target signal of a set and
// stores them as a new config instance. A missing store/set is a no-op (logged).
// Audited. The new instance's id is logged but not written back to a flow
// variable (control-logic variables are numeric, ids are strings).
func (e *Engine) snapshotConfig(cg *compiledGraph, setID, name string) {
if e.cfg == nil || setID == "" {
return
}
set, err := e.cfg.GetSet(setID)
if err != nil {
e.log.Warn("control logic: config snapshot: unknown set", "set", setID, "err", err)
return
}
ctx, cancel := context.WithTimeout(e.root, 5*time.Second)
defer cancel()
read := func(ds, signal string) (any, error) {
if ds == "local" {
return cg.getLocal(signal), nil
}
v, err := e.broker.ReadNow(ctx, broker.SignalRef{DS: ds, Name: signal})
if err != nil {
return nil, err
}
return v.Data, nil
}
snap := confmgr.Snapshot(set, read)
if name == "" {
name = set.Name + " snapshot"
}
inst := confmgr.ConfigInstance{Name: name, SetID: setID, Values: snap.Values}
ev := audit.Event{
Actor: cg.name,
ActorType: audit.ActorSystem,
Action: "config.instance.snapshot",
Detail: "control logic config snapshot: set=" + setID + " name=" + name,
Outcome: audit.OutcomeOK,
}
out, err := e.cfg.CreateInstance(inst, "")
if err != nil {
e.log.Warn("control logic: config snapshot failed", "set", setID, "err", err)
ev.Outcome = audit.OutcomeError
ev.Error = err.Error()
} else {
ev.Detail += " -> " + out.ID + " captured=" + strconv.Itoa(snap.Captured) + " failed=" + strconv.Itoa(snap.Failed)
}
e.audit.Record(ev)
}
// coerceParamValue converts a numeric flow value to the Go type a config
// parameter expects, so the resulting instance validates against its set.
func coerceParamValue(p confmgr.Parameter, val float64) any {
switch p.Type {
case confmgr.TypeInt:
return int64(val)
case confmgr.TypeBool:
return val != 0
case confmgr.TypeString, confmgr.TypeEnum:
return strconv.FormatFloat(val, 'g', -1, 64)
default:
return val
}
}
// emitDialog delivers an action.dialog node's request to the installed Notifier
// (the WebSocket dialog hub). It is lock-free so it never deadlocks against a
// concurrent Reload that is waiting for this flow goroutine to finish.
@@ -673,6 +925,30 @@ func (cg *compiledGraph) run(nodeID string, ctx *runCtx) {
cg.engine.write(cg, node.param("target"), val)
cg.follow(node.ID, "out", ctx)
case "action.config.apply":
cg.engine.applyConfig(cg, strings.TrimSpace(node.param("instance")))
cg.follow(node.ID, "out", ctx)
case "action.config.read":
v, ok := cg.engine.readConfigParam(strings.TrimSpace(node.param("instance")), strings.TrimSpace(node.param("key")))
if ok {
cg.engine.write(cg, node.param("target"), v)
}
cg.follow(node.ID, "out", ctx)
case "action.config.write":
val := EvalExpr(node.param("expr"), ctx.resolve)
cg.engine.writeConfigParam(cg, strings.TrimSpace(node.param("instance")), strings.TrimSpace(node.param("key")), val)
cg.follow(node.ID, "out", ctx)
case "action.config.create":
cg.engine.createConfigInstance(cg, strings.TrimSpace(node.param("set")), strings.TrimSpace(node.param("name")), strings.TrimSpace(node.param("from")))
cg.follow(node.ID, "out", ctx)
case "action.config.snapshot":
cg.engine.snapshotConfig(cg, strings.TrimSpace(node.param("set")), strings.TrimSpace(node.param("name")))
cg.follow(node.ID, "out", ctx)
case "action.delay":
ms := 0
if v, err := strconv.Atoi(strings.TrimSpace(node.param("ms"))); err == nil && v > 0 {
+2
View File
@@ -55,6 +55,8 @@ type Graph struct {
ID string `json:"id"`
Name string `json:"name"`
Enabled bool `json:"enabled"`
Version int `json:"version,omitempty"` // git-style revision; bumped on each Save
Tag string `json:"tag,omitempty"` // optional revision label (e.g. "restored from v3")
Nodes []Node `json:"nodes"`
Wires []Wire `json:"wires"`
}
+46 -8
View File
@@ -17,19 +17,25 @@ var ErrNotFound = errors.New("control logic graph not found")
// Store persists control-logic graphs as a single JSON file in the storage dir.
// Writes are atomic (tmp file + rename); all access is mutex-guarded.
//
// Git-style versioning: the live graph lives in controllogic.json (the current
// revision), while every superseded revision is preserved as a backup file
// {id}.vN.json under versionsDir. Promote/Fork build on these backups.
type Store struct {
mu sync.RWMutex
path string
trashDir string
items map[string]Graph
mu sync.RWMutex
path string
trashDir string
versionsDir string
items map[string]Graph
}
// NewStore opens (or initialises) the control-logic store under storageDir.
func NewStore(storageDir string) (*Store, error) {
s := &Store{
path: filepath.Join(storageDir, definitionsFile),
trashDir: filepath.Join(storageDir, "trash", "controllogic"),
items: map[string]Graph{},
path: filepath.Join(storageDir, definitionsFile),
trashDir: filepath.Join(storageDir, "trash", "controllogic"),
versionsDir: filepath.Join(storageDir, "controllogic_versions"),
items: map[string]Graph{},
}
if err := s.load(); err != nil {
return nil, err
@@ -94,14 +100,46 @@ func (s *Store) Get(id string) (Graph, error) {
return g, nil
}
// Save inserts or replaces a graph and persists the store.
// Save inserts or replaces a graph and persists the store. When replacing an
// existing graph the superseded revision is backed up as {id}.vN.json and the
// new graph's Version is bumped; a brand-new graph starts at version 1.
func (s *Store) Save(g Graph) error {
s.mu.Lock()
defer s.mu.Unlock()
if old, ok := s.items[g.ID]; ok {
oldV := old.Version
if oldV < 1 {
oldV = 1
old.Version = 1
}
if err := s.backupLocked(old); err != nil {
return fmt.Errorf("back up control logic revision: %w", err)
}
g.Version = oldV + 1
} else if g.Version < 1 {
g.Version = 1
}
s.items[g.ID] = g
return s.saveLocked()
}
// backupLocked writes a single graph revision to versionsDir as {id}.vN.json.
// Caller must hold s.mu.
func (s *Store) backupLocked(g Graph) error {
if err := os.MkdirAll(s.versionsDir, 0o755); err != nil {
return err
}
data, err := json.MarshalIndent(g, "", " ")
if err != nil {
return err
}
return os.WriteFile(s.versionPath(g.ID, g.Version), data, 0o644)
}
func (s *Store) versionPath(id string, version int) string {
return filepath.Join(s.versionsDir, fmt.Sprintf("%s.v%d.json", id, version))
}
// Delete removes a graph by id, first writing a copy into the trash folder so it
// can be recovered if needed. The trash backup is best-effort: a failure to write
// it does not prevent the delete (but is reported).
+142
View File
@@ -0,0 +1,142 @@
package controllogic
import (
"encoding/json"
"fmt"
"os"
"sort"
"strconv"
"strings"
"time"
)
// VersionMeta describes a single persisted revision of a control-logic graph,
// mirroring storage.VersionMeta so the frontend can treat all versioned
// document types uniformly.
type VersionMeta struct {
Version int `json:"version"`
Name string `json:"name"`
Tag string `json:"tag,omitempty"`
Current bool `json:"current"`
SavedAt time.Time `json:"savedAt"`
}
// Versions returns metadata for every persisted revision of the graph, newest
// first. The live revision (held in controllogic.json) is flagged Current.
func (s *Store) Versions(id string) ([]VersionMeta, error) {
s.mu.RLock()
defer s.mu.RUnlock()
cur, ok := s.items[id]
if !ok {
return nil, ErrNotFound
}
curV := cur.Version
if curV < 1 {
curV = 1
}
var savedAt time.Time
if info, err := os.Stat(s.path); err == nil {
savedAt = info.ModTime()
}
out := []VersionMeta{{Version: curV, Name: cur.Name, Tag: cur.Tag, Current: true, SavedAt: savedAt}}
entries, err := os.ReadDir(s.versionsDir)
if err != nil && !os.IsNotExist(err) {
return nil, err
}
prefix := id + ".v"
for _, e := range entries {
name := e.Name()
if e.IsDir() || !strings.HasPrefix(name, prefix) || !strings.HasSuffix(name, ".json") {
continue
}
vStr := strings.TrimSuffix(strings.TrimPrefix(name, prefix), ".json")
v, err := strconv.Atoi(vStr)
if err != nil {
continue
}
g, err := s.readVersion(id, v)
if err != nil {
continue
}
info, err := e.Info()
if err != nil {
continue
}
out = append(out, VersionMeta{Version: v, Name: g.Name, Tag: g.Tag, SavedAt: info.ModTime()})
}
sort.Slice(out, func(i, j int) bool { return out[i].Version > out[j].Version })
return out, nil
}
// GetVersion returns a specific revision of the graph. The current revision is
// served from the live store; older revisions come from their backup file.
func (s *Store) GetVersion(id string, version int) (Graph, error) {
s.mu.RLock()
defer s.mu.RUnlock()
cur, ok := s.items[id]
if !ok {
return Graph{}, ErrNotFound
}
curV := cur.Version
if curV < 1 {
curV = 1
}
if version == curV {
return cur, nil
}
return s.readVersion(id, version)
}
// readVersion loads a backup revision file. Caller must hold s.mu.
func (s *Store) readVersion(id string, version int) (Graph, error) {
data, err := os.ReadFile(s.versionPath(id, version))
if os.IsNotExist(err) {
return Graph{}, ErrNotFound
}
if err != nil {
return Graph{}, err
}
var g Graph
if err := json.Unmarshal(data, &g); err != nil {
return Graph{}, fmt.Errorf("parse revision: %w", err)
}
return g, nil
}
// Promote makes a past revision current by re-saving it on top of history. The
// existing current revision is preserved as a backup, so promotion is
// non-destructive. Returns the resulting (new current) graph.
func (s *Store) Promote(id string, version int) (Graph, error) {
g, err := s.GetVersion(id, version)
if err != nil {
return Graph{}, err
}
g.Tag = fmt.Sprintf("restored from v%d", version)
if err := s.Save(g); err != nil {
return Graph{}, err
}
return s.Get(id)
}
// Fork creates a brand-new graph from a specific revision, assigning a fresh id
// and resetting its version to 1. Returns the new graph.
func (s *Store) Fork(id string, version int) (Graph, error) {
g, err := s.GetVersion(id, version)
if err != nil {
return Graph{}, err
}
g.ID = fmt.Sprintf("%s-fork-%d", id, time.Now().UnixMilli())
g.Version = 1
g.Tag = ""
if g.Name != "" {
g.Name = g.Name + " (fork)"
}
if err := s.Save(g); err != nil {
return Graph{}, err
}
return g, nil
}
+70
View File
@@ -0,0 +1,70 @@
package controllogic
import "testing"
func TestControlLogicVersioning(t *testing.T) {
dir := t.TempDir()
s, err := NewStore(dir)
if err != nil {
t.Fatal(err)
}
g := Graph{ID: "cl-1", Name: "loop", Enabled: true,
Nodes: []Node{{ID: "n1", Kind: "trigger.timer", Params: map[string]string{"interval": "1000"}}}}
if err := s.Save(g); err != nil {
t.Fatalf("Save v1: %v", err)
}
if got, _ := s.Get("cl-1"); got.Version != 1 {
t.Fatalf("after create: version=%d", got.Version)
}
// Two edits → v2, v3.
g.Name = "loop-2"
if err := s.Save(g); err != nil {
t.Fatalf("Save v2: %v", err)
}
g.Name = "loop-3"
if err := s.Save(g); err != nil {
t.Fatalf("Save v3: %v", err)
}
if got, _ := s.Get("cl-1"); got.Version != 3 || got.Name != "loop-3" {
t.Fatalf("current: version=%d name=%q", got.Version, got.Name)
}
versions, err := s.Versions("cl-1")
if err != nil {
t.Fatalf("Versions: %v", err)
}
if len(versions) != 3 {
t.Fatalf("want 3 versions, got %d", len(versions))
}
if !versions[0].Current || versions[0].Version != 3 {
t.Errorf("newest should be current v3: %+v", versions[0])
}
v1, err := s.GetVersion("cl-1", 1)
if err != nil || v1.Name != "loop" {
t.Fatalf("GetVersion v1: name=%q err=%v", v1.Name, err)
}
// Promote v1 → v4.
promoted, err := s.Promote("cl-1", 1)
if err != nil {
t.Fatalf("Promote: %v", err)
}
if promoted.Version != 4 || promoted.Name != "loop" {
t.Errorf("promote: version=%d name=%q", promoted.Version, promoted.Name)
}
// Fork v3 → new id, version 1.
forked, err := s.Fork("cl-1", 3)
if err != nil {
t.Fatalf("Fork: %v", err)
}
if forked.Version != 1 || forked.ID == "cl-1" {
t.Errorf("fork: id=%q version=%d", forked.ID, forked.Version)
}
if got, err := s.Get(forked.ID); err != nil || got.Name != forked.Name {
t.Errorf("forked graph not stored: %v", err)
}
}
@@ -21,6 +21,12 @@ type SignalDef struct {
Visibility string `json:"visibility,omitempty"`
Owner string `json:"owner,omitempty"` // creator identity (stamped server-side)
Panel string `json:"panel,omitempty"` // bound interface id for "panel" visibility
// Version and Tag implement git-style revisioning. Version is bumped on
// every UpdateSignal; superseded revisions are kept as backup files. Tag is
// an optional human label for a revision (e.g. "restored from v3").
Version int `json:"version,omitempty"`
Tag string `json:"tag,omitempty"`
}
// InputRef names one upstream signal used as input to the pipeline.
@@ -263,6 +263,9 @@ func (s *Synthetic) AddSignal(def SignalDef) error {
if def.Name == "" {
return errors.New("signal name must not be empty")
}
if def.Version < 1 {
def.Version = 1
}
rg, err := compileGraph(def)
if err != nil {
@@ -340,6 +343,16 @@ func (s *Synthetic) UpdateSignal(def SignalDef) error {
s.mu.Unlock()
return datasource.ErrNotFound
}
// Preserve the superseded revision as a backup and bump the version.
oldDef := old.def
if oldDef.Version < 1 {
oldDef.Version = 1
}
if err := s.backupVersion(oldDef); err != nil {
s.mu.Unlock()
return fmt.Errorf("back up revision: %w", err)
}
def.Version = oldDef.Version + 1
if old.cancel != nil {
old.cancel()
}
+174
View File
@@ -0,0 +1,174 @@
package synthetic
import (
"encoding/json"
"fmt"
"hash/fnv"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
"unicode"
"github.com/uopi/uopi/internal/datasource"
)
// VersionMeta describes a single persisted revision of a synthetic signal,
// mirroring storage.VersionMeta so the frontend treats every versioned document
// type uniformly.
type VersionMeta struct {
Version int `json:"version"`
Name string `json:"name"`
Tag string `json:"tag,omitempty"`
Current bool `json:"current"`
SavedAt time.Time `json:"savedAt"`
}
func (s *Synthetic) versionsDir() string {
return filepath.Join(s.storePath, "synthetic_versions")
}
// slugForFile maps an arbitrary signal name to a filesystem-safe stem. A short
// hash of the full name is appended so distinct names that sanitise to the same
// stem (e.g. "A:B" and "A_B") never share backup files.
func slugForFile(name string) string {
var b strings.Builder
for _, r := range name {
if unicode.IsLetter(r) || unicode.IsDigit(r) || r == '-' || r == '_' {
b.WriteRune(r)
} else {
b.WriteByte('_')
}
}
h := fnv.New32a()
_, _ = h.Write([]byte(name))
return fmt.Sprintf("%s-%08x", b.String(), h.Sum32())
}
func (s *Synthetic) versionPath(name string, version int) string {
return filepath.Join(s.versionsDir(), fmt.Sprintf("%s.v%d.json", slugForFile(name), version))
}
// backupVersion writes a single signal revision to the versions directory.
func (s *Synthetic) backupVersion(def SignalDef) error {
if err := os.MkdirAll(s.versionsDir(), 0o755); err != nil {
return err
}
data, err := json.MarshalIndent(def, "", " ")
if err != nil {
return err
}
return os.WriteFile(s.versionPath(def.Name, def.Version), data, 0o644)
}
// Versions returns metadata for every persisted revision of the named signal,
// newest first. The live revision is flagged Current.
func (s *Synthetic) Versions(name string) ([]VersionMeta, error) {
cur, err := s.GetSignal(name)
if err != nil {
return nil, err
}
curV := cur.Version
if curV < 1 {
curV = 1
}
var savedAt time.Time
if info, err := os.Stat(s.defsFilePath()); err == nil {
savedAt = info.ModTime()
}
out := []VersionMeta{{Version: curV, Name: cur.Name, Tag: cur.Tag, Current: true, SavedAt: savedAt}}
entries, err := os.ReadDir(s.versionsDir())
if err != nil && !os.IsNotExist(err) {
return nil, err
}
prefix := slugForFile(name) + ".v"
for _, e := range entries {
fn := e.Name()
if e.IsDir() || !strings.HasPrefix(fn, prefix) || !strings.HasSuffix(fn, ".json") {
continue
}
vStr := strings.TrimSuffix(strings.TrimPrefix(fn, prefix), ".json")
v, err := strconv.Atoi(vStr)
if err != nil {
continue
}
def, err := s.readVersion(name, v)
if err != nil {
continue
}
info, err := e.Info()
if err != nil {
continue
}
out = append(out, VersionMeta{Version: v, Name: def.Name, Tag: def.Tag, SavedAt: info.ModTime()})
}
sort.Slice(out, func(i, j int) bool { return out[i].Version > out[j].Version })
return out, nil
}
// GetVersion returns a specific revision of the named signal. The current
// revision comes from the live store; older revisions from their backup file.
func (s *Synthetic) GetVersion(name string, version int) (SignalDef, error) {
cur, err := s.GetSignal(name)
if err != nil {
return SignalDef{}, err
}
curV := cur.Version
if curV < 1 {
curV = 1
}
if version == curV {
return cur, nil
}
return s.readVersion(name, version)
}
func (s *Synthetic) readVersion(name string, version int) (SignalDef, error) {
data, err := os.ReadFile(s.versionPath(name, version))
if os.IsNotExist(err) {
return SignalDef{}, datasource.ErrNotFound
}
if err != nil {
return SignalDef{}, err
}
var def SignalDef
if err := json.Unmarshal(data, &def); err != nil {
return SignalDef{}, fmt.Errorf("parse revision: %w", err)
}
return def, nil
}
// PromoteVersion makes a past revision current by re-saving it on top of
// history (non-destructive). Returns the resulting current definition.
func (s *Synthetic) PromoteVersion(name string, version int) (SignalDef, error) {
def, err := s.GetVersion(name, version)
if err != nil {
return SignalDef{}, err
}
def.Tag = fmt.Sprintf("restored from v%d", version)
if err := s.UpdateSignal(def); err != nil {
return SignalDef{}, err
}
return s.GetSignal(name)
}
// ForkVersion creates a brand-new synthetic signal from a specific revision,
// assigning a fresh unique name and resetting its version to 1.
func (s *Synthetic) ForkVersion(name string, version int) (SignalDef, error) {
def, err := s.GetVersion(name, version)
if err != nil {
return SignalDef{}, err
}
def.Name = fmt.Sprintf("%s_fork_%d", name, time.Now().UnixMilli())
def.Version = 1
def.Tag = ""
if err := s.AddSignal(def); err != nil {
return SignalDef{}, err
}
return def, nil
}
@@ -0,0 +1,87 @@
package synthetic
import (
"context"
"testing"
)
func TestSyntheticVersioning(t *testing.T) {
syn, _, cancel := newTestSynthetic(t)
defer cancel()
if err := syn.Connect(context.Background()); err != nil {
t.Fatal(err)
}
// defWithGain builds a fresh def each time, mirroring how the REST handler
// decodes a new SignalDef per request (no aliasing of slices/maps).
defWithGain := func(g float64) SignalDef {
return SignalDef{Name: "wf", DS: "stub", Signal: "sine_1hz",
Pipeline: []NodeDef{{Type: "gain", Params: map[string]any{"gain": g}}}}
}
if err := syn.AddSignal(defWithGain(1.0)); err != nil {
t.Fatalf("AddSignal: %v", err)
}
cur, err := syn.GetSignal("wf")
if err != nil || cur.Version != 1 {
t.Fatalf("after add: version=%d err=%v", cur.Version, err)
}
// Two edits → v2, v3.
if err := syn.UpdateSignal(defWithGain(2.0)); err != nil {
t.Fatalf("UpdateSignal: %v", err)
}
if err := syn.UpdateSignal(defWithGain(3.0)); err != nil {
t.Fatalf("UpdateSignal: %v", err)
}
cur, _ = syn.GetSignal("wf")
if cur.Version != 3 {
t.Fatalf("want current v3, got v%d", cur.Version)
}
versions, err := syn.Versions("wf")
if err != nil {
t.Fatalf("Versions: %v", err)
}
if len(versions) != 3 {
t.Fatalf("want 3 versions, got %d", len(versions))
}
if !versions[0].Current || versions[0].Version != 3 {
t.Errorf("newest should be current v3: %+v", versions[0])
}
// v1 backup retrievable with original gain.
v1, err := syn.GetVersion("wf", 1)
if err != nil {
t.Fatalf("GetVersion v1: %v", err)
}
if v1.Pipeline[0].Params["gain"] != 1.0 {
t.Errorf("v1 gain: want 1.0, got %v", v1.Pipeline[0].Params["gain"])
}
// Promote v1 → becomes v4 (non-destructive).
promoted, err := syn.PromoteVersion("wf", 1)
if err != nil {
t.Fatalf("Promote: %v", err)
}
if promoted.Version != 4 || promoted.Pipeline[0].Params["gain"] != 1.0 {
t.Errorf("promote: version=%d gain=%v", promoted.Version, promoted.Pipeline[0].Params["gain"])
}
// Fork v3 → fresh signal, version 1.
forked, err := syn.ForkVersion("wf", 3)
if err != nil {
t.Fatalf("Fork: %v", err)
}
if forked.Version != 1 || forked.Name == "wf" {
t.Errorf("fork: name=%q version=%d", forked.Name, forked.Version)
}
if forked.Pipeline[0].Params["gain"] != 3.0 {
t.Errorf("fork gain: want 3.0, got %v", forked.Pipeline[0].Params["gain"])
}
if _, err := syn.GetSignal(forked.Name); err != nil {
t.Errorf("forked signal not registered: %v", err)
}
}