Implemented confi snapshots

This commit is contained in:
Martino Ferrari
2026-06-21 23:50:03 +02:00
parent 04d31a15c4
commit 113e5a0fe8
51 changed files with 4921 additions and 241 deletions
+277 -1
View File
@@ -2,6 +2,8 @@ package controllogic
import (
"context"
"errors"
"fmt"
"log/slog"
"math"
"regexp"
@@ -13,8 +15,25 @@ import (
"github.com/uopi/uopi/internal/audit"
"github.com/uopi/uopi/internal/broker"
"github.com/uopi/uopi/internal/confmgr"
)
// errUnknownSource is returned by config-apply writes targeting a data source
// that isn't registered with the broker.
var errUnknownSource = errors.New("unknown data source")
// formatAny renders a config value for the audit log.
func formatAny(v any) string {
switch x := v.(type) {
case float64:
return strconv.FormatFloat(x, 'g', -1, 64)
case string:
return x
default:
return fmt.Sprintf("%v", v)
}
}
// Guards against runaway flows (cycles / pathological loops).
const (
maxSteps = 100000
@@ -27,6 +46,7 @@ const (
type Engine struct {
broker *broker.Broker
store *Store
cfg *confmgr.Store
audit audit.Recorder
log *slog.Logger
root context.Context
@@ -59,13 +79,14 @@ func (e *Engine) SetNotifier(n Notifier) {
// NewEngine creates an engine bound to root. Call Reload to start it. rec records
// the writes performed by flows; pass audit.Nop() to disable auditing.
func NewEngine(root context.Context, brk *broker.Broker, store *Store, rec audit.Recorder, log *slog.Logger) *Engine {
func NewEngine(root context.Context, brk *broker.Broker, store *Store, cfg *confmgr.Store, rec audit.Recorder, log *slog.Logger) *Engine {
if rec == nil {
rec = audit.Nop()
}
return &Engine{
broker: brk,
store: store,
cfg: cfg,
audit: rec,
log: log,
root: root,
@@ -270,6 +291,237 @@ func (e *Engine) write(cg *compiledGraph, target string, val float64) {
e.audit.Record(ev)
}
// applyConfig resolves a config instance + its set and writes every parameter
// to its target signal via the owning data source (confmgr.Apply). Each write is
// audited. A bare instance id or a missing config store is a no-op (logged).
func (e *Engine) applyConfig(cg *compiledGraph, instanceID string) {
if e.cfg == nil || instanceID == "" {
return
}
inst, err := e.cfg.GetInstance(instanceID)
if err != nil {
e.log.Warn("control logic: config apply: unknown instance", "instance", instanceID, "err", err)
return
}
set, err := e.cfg.SetForInstance(inst)
if err != nil {
e.log.Warn("control logic: config apply: set lookup failed", "instance", instanceID, "err", err)
return
}
write := func(ds, signal string, value any) error {
ev := audit.Event{
Actor: cg.name,
ActorType: audit.ActorSystem,
Action: "signal.write",
DS: ds,
Signal: signal,
Value: formatAny(value),
Detail: "control logic config apply: " + inst.Name,
Outcome: audit.OutcomeOK,
}
var werr error
if ds == "local" {
cg.setLocal(signal, toNum(value))
} else if src, ok := e.broker.Source(ds); ok {
werr = src.Write(e.root, signal, value)
} else {
werr = errUnknownSource
}
if werr != nil {
ev.Outcome = audit.OutcomeError
ev.Error = werr.Error()
}
e.audit.Record(ev)
return werr
}
confmgr.Apply(set, inst, write)
}
// readConfigParam resolves a single parameter's value from a config instance,
// coercing it to float64 for use as a control-logic value. Returns ok=false if
// the store/instance/param is missing or the value isn't numeric.
func (e *Engine) readConfigParam(instanceID, key string) (float64, bool) {
if e.cfg == nil || instanceID == "" || key == "" {
return 0, false
}
inst, err := e.cfg.GetInstance(instanceID)
if err != nil {
e.log.Warn("control logic: config read: unknown instance", "instance", instanceID, "err", err)
return 0, false
}
set, err := e.cfg.SetForInstance(inst)
if err != nil {
e.log.Warn("control logic: config read: set lookup failed", "instance", instanceID, "err", err)
return 0, false
}
for _, p := range set.Parameters {
if p.Key != key {
continue
}
v, ok := inst.Resolve(p)
if !ok {
return 0, false
}
f := toNum(v)
if math.IsNaN(f) {
return 0, false
}
return f, true
}
return 0, false
}
// writeConfigParam sets a single parameter's value on a config instance and
// saves a new revision (git-style). The value is coerced to the parameter's
// declared type (numeric/bool/string) so it validates against the set. A
// missing store/instance/param is a no-op (logged). Audited.
func (e *Engine) writeConfigParam(cg *compiledGraph, instanceID, key string, val float64) {
if e.cfg == nil || instanceID == "" || key == "" {
return
}
inst, err := e.cfg.GetInstance(instanceID)
if err != nil {
e.log.Warn("control logic: config write: unknown instance", "instance", instanceID, "err", err)
return
}
set, err := e.cfg.SetForInstance(inst)
if err != nil {
e.log.Warn("control logic: config write: set lookup failed", "instance", instanceID, "err", err)
return
}
var param *confmgr.Parameter
for i := range set.Parameters {
if set.Parameters[i].Key == key {
param = &set.Parameters[i]
break
}
}
if param == nil {
e.log.Warn("control logic: config write: unknown parameter", "instance", instanceID, "key", key)
return
}
if inst.Values == nil {
inst.Values = map[string]any{}
}
inst.Values[key] = coerceParamValue(*param, val)
ev := audit.Event{
Actor: cg.name,
ActorType: audit.ActorSystem,
Action: "config.instance.update",
Detail: "control logic config write: " + inst.Name + " " + key + "=" + formatAny(inst.Values[key]),
Outcome: audit.OutcomeOK,
}
if _, err := e.cfg.UpdateInstance(instanceID, inst, ""); err != nil {
e.log.Warn("control logic: config write failed", "instance", instanceID, "key", key, "err", err)
ev.Outcome = audit.OutcomeError
ev.Error = err.Error()
}
e.audit.Record(ev)
}
// createConfigInstance creates a new config instance for setID, optionally
// copying values from an existing instance (fromID). A missing store/set is a
// no-op (logged). Audited. The new instance's id is logged but not written back
// to a flow variable (control-logic variables are numeric, ids are strings).
func (e *Engine) createConfigInstance(cg *compiledGraph, setID, name, fromID string) {
if e.cfg == nil || setID == "" {
return
}
if name == "" {
name = "auto"
}
values := map[string]any{}
if fromID != "" {
if src, err := e.cfg.GetInstance(fromID); err == nil {
for k, v := range src.Values {
values[k] = v
}
} else {
e.log.Warn("control logic: config create: copy source missing", "from", fromID, "err", err)
}
}
inst := confmgr.ConfigInstance{Name: name, SetID: setID, Values: values}
ev := audit.Event{
Actor: cg.name,
ActorType: audit.ActorSystem,
Action: "config.instance.create",
Detail: "control logic config create: set=" + setID + " name=" + name,
Outcome: audit.OutcomeOK,
}
out, err := e.cfg.CreateInstance(inst, "")
if err != nil {
e.log.Warn("control logic: config create failed", "set", setID, "err", err)
ev.Outcome = audit.OutcomeError
ev.Error = err.Error()
} else {
ev.Detail += " -> " + out.ID
}
e.audit.Record(ev)
}
// snapshotConfig captures the current value of every target signal of a set and
// stores them as a new config instance. A missing store/set is a no-op (logged).
// Audited. The new instance's id is logged but not written back to a flow
// variable (control-logic variables are numeric, ids are strings).
func (e *Engine) snapshotConfig(cg *compiledGraph, setID, name string) {
if e.cfg == nil || setID == "" {
return
}
set, err := e.cfg.GetSet(setID)
if err != nil {
e.log.Warn("control logic: config snapshot: unknown set", "set", setID, "err", err)
return
}
ctx, cancel := context.WithTimeout(e.root, 5*time.Second)
defer cancel()
read := func(ds, signal string) (any, error) {
if ds == "local" {
return cg.getLocal(signal), nil
}
v, err := e.broker.ReadNow(ctx, broker.SignalRef{DS: ds, Name: signal})
if err != nil {
return nil, err
}
return v.Data, nil
}
snap := confmgr.Snapshot(set, read)
if name == "" {
name = set.Name + " snapshot"
}
inst := confmgr.ConfigInstance{Name: name, SetID: setID, Values: snap.Values}
ev := audit.Event{
Actor: cg.name,
ActorType: audit.ActorSystem,
Action: "config.instance.snapshot",
Detail: "control logic config snapshot: set=" + setID + " name=" + name,
Outcome: audit.OutcomeOK,
}
out, err := e.cfg.CreateInstance(inst, "")
if err != nil {
e.log.Warn("control logic: config snapshot failed", "set", setID, "err", err)
ev.Outcome = audit.OutcomeError
ev.Error = err.Error()
} else {
ev.Detail += " -> " + out.ID + " captured=" + strconv.Itoa(snap.Captured) + " failed=" + strconv.Itoa(snap.Failed)
}
e.audit.Record(ev)
}
// coerceParamValue converts a numeric flow value to the Go type a config
// parameter expects, so the resulting instance validates against its set.
func coerceParamValue(p confmgr.Parameter, val float64) any {
switch p.Type {
case confmgr.TypeInt:
return int64(val)
case confmgr.TypeBool:
return val != 0
case confmgr.TypeString, confmgr.TypeEnum:
return strconv.FormatFloat(val, 'g', -1, 64)
default:
return val
}
}
// emitDialog delivers an action.dialog node's request to the installed Notifier
// (the WebSocket dialog hub). It is lock-free so it never deadlocks against a
// concurrent Reload that is waiting for this flow goroutine to finish.
@@ -673,6 +925,30 @@ func (cg *compiledGraph) run(nodeID string, ctx *runCtx) {
cg.engine.write(cg, node.param("target"), val)
cg.follow(node.ID, "out", ctx)
case "action.config.apply":
cg.engine.applyConfig(cg, strings.TrimSpace(node.param("instance")))
cg.follow(node.ID, "out", ctx)
case "action.config.read":
v, ok := cg.engine.readConfigParam(strings.TrimSpace(node.param("instance")), strings.TrimSpace(node.param("key")))
if ok {
cg.engine.write(cg, node.param("target"), v)
}
cg.follow(node.ID, "out", ctx)
case "action.config.write":
val := EvalExpr(node.param("expr"), ctx.resolve)
cg.engine.writeConfigParam(cg, strings.TrimSpace(node.param("instance")), strings.TrimSpace(node.param("key")), val)
cg.follow(node.ID, "out", ctx)
case "action.config.create":
cg.engine.createConfigInstance(cg, strings.TrimSpace(node.param("set")), strings.TrimSpace(node.param("name")), strings.TrimSpace(node.param("from")))
cg.follow(node.ID, "out", ctx)
case "action.config.snapshot":
cg.engine.snapshotConfig(cg, strings.TrimSpace(node.param("set")), strings.TrimSpace(node.param("name")))
cg.follow(node.ID, "out", ctx)
case "action.delay":
ms := 0
if v, err := strconv.Atoi(strings.TrimSpace(node.param("ms"))); err == nil && v > 0 {