Implemented confi snapshots
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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...)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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(), "")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user