821 lines
23 KiB
Go
821 lines
23 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
"strconv"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/uopi/uopi/internal/broker"
|
|
"github.com/uopi/uopi/internal/confmgr"
|
|
)
|
|
|
|
// configEnabled guards every config-manager handler; the store is always
|
|
// constructed today, but the nil check keeps the handlers safe if it is ever
|
|
// made optional.
|
|
func (h *Handler) configEnabled(w http.ResponseWriter) bool {
|
|
if h.cfg == nil {
|
|
jsonError(w, http.StatusServiceUnavailable, "configuration manager not enabled")
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
func pathVersion(r *http.Request) (int, error) {
|
|
return strconv.Atoi(r.PathValue("version"))
|
|
}
|
|
|
|
func configStatus(err error) int {
|
|
if errors.Is(err, confmgr.ErrNotFound) {
|
|
return http.StatusNotFound
|
|
}
|
|
return http.StatusBadRequest
|
|
}
|
|
|
|
// ── config sets ─────────────────────────────────────────────────────────────
|
|
|
|
func (h *Handler) listConfigSets(w http.ResponseWriter, r *http.Request) {
|
|
if !h.configEnabled(w) {
|
|
return
|
|
}
|
|
sets, err := h.cfg.List(confmgr.KindSet)
|
|
if err != nil {
|
|
jsonError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
jsonOK(w, h.filterConfigMetas(r, sets))
|
|
}
|
|
|
|
func (h *Handler) getConfigSet(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
|
|
}
|
|
jsonOK(w, set)
|
|
}
|
|
|
|
func (h *Handler) createConfigSet(w http.ResponseWriter, r *http.Request) {
|
|
if !h.configEnabled(w) {
|
|
return
|
|
}
|
|
var set confmgr.ConfigSet
|
|
if err := json.NewDecoder(r.Body).Decode(&set); err != nil {
|
|
jsonError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
|
|
return
|
|
}
|
|
set.ID = ""
|
|
set.Owner = caller(r)
|
|
out, err := h.cfg.CreateSet(set, r.URL.Query().Get("tag"))
|
|
if err != nil {
|
|
jsonError(w, configStatus(err), err.Error())
|
|
return
|
|
}
|
|
h.recordMutation(r, "config.set.create", out.ID+" "+out.Name)
|
|
w.WriteHeader(http.StatusCreated)
|
|
jsonOK(w, out)
|
|
}
|
|
|
|
func (h *Handler) updateConfigSet(w http.ResponseWriter, r *http.Request) {
|
|
if !h.configEnabled(w) {
|
|
return
|
|
}
|
|
id := r.PathValue("id")
|
|
var set confmgr.ConfigSet
|
|
if err := json.NewDecoder(r.Body).Decode(&set); err != nil {
|
|
jsonError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
|
|
return
|
|
}
|
|
if prev, err := h.cfg.GetSet(id); err == nil {
|
|
set.Owner = prev.Owner // owner is immutable across revisions
|
|
}
|
|
out, err := h.cfg.UpdateSet(id, set, r.URL.Query().Get("tag"))
|
|
if err != nil {
|
|
jsonError(w, configStatus(err), err.Error())
|
|
return
|
|
}
|
|
h.recordMutation(r, "config.set.update", out.ID+" v"+strconv.Itoa(out.Version))
|
|
jsonOK(w, out)
|
|
}
|
|
|
|
func (h *Handler) deleteConfigSet(w http.ResponseWriter, r *http.Request) {
|
|
if !h.configEnabled(w) {
|
|
return
|
|
}
|
|
id := r.PathValue("id")
|
|
if err := h.cfg.Delete(confmgr.KindSet, id); err != nil {
|
|
jsonError(w, configStatus(err), err.Error())
|
|
return
|
|
}
|
|
h.recordMutation(r, "config.set.delete", id)
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
func (h *Handler) listConfigSetVersions(w http.ResponseWriter, r *http.Request) {
|
|
if !h.configEnabled(w) {
|
|
return
|
|
}
|
|
versions, err := h.cfg.Versions(confmgr.KindSet, r.PathValue("id"))
|
|
if err != nil {
|
|
jsonError(w, configStatus(err), err.Error())
|
|
return
|
|
}
|
|
jsonOK(w, versions)
|
|
}
|
|
|
|
func (h *Handler) getConfigSetVersion(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
|
|
}
|
|
set, err := h.cfg.GetSetVersion(r.PathValue("id"), version)
|
|
if err != nil {
|
|
jsonError(w, configStatus(err), err.Error())
|
|
return
|
|
}
|
|
jsonOK(w, set)
|
|
}
|
|
|
|
func (h *Handler) promoteConfigSet(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.KindSet, id, version); err != nil {
|
|
jsonError(w, configStatus(err), err.Error())
|
|
return
|
|
}
|
|
h.recordMutation(r, "config.set.promote", id+" v"+strconv.Itoa(version))
|
|
set, err := h.cfg.GetSet(id)
|
|
if err != nil {
|
|
jsonError(w, configStatus(err), err.Error())
|
|
return
|
|
}
|
|
jsonOK(w, set)
|
|
}
|
|
|
|
func (h *Handler) forkConfigSet(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.KindSet, id, version)
|
|
if err != nil {
|
|
jsonError(w, configStatus(err), err.Error())
|
|
return
|
|
}
|
|
h.recordMutation(r, "config.set.fork", id+" v"+strconv.Itoa(version)+" -> "+newID)
|
|
set, err := h.cfg.GetSet(newID)
|
|
if err != nil {
|
|
jsonError(w, configStatus(err), err.Error())
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusCreated)
|
|
jsonOK(w, set)
|
|
}
|
|
|
|
// diffConfigSets compares two set revisions. Query params: a, b (ids);
|
|
// optional av, bv (versions, default current).
|
|
func (h *Handler) diffConfigSets(w http.ResponseWriter, r *http.Request) {
|
|
if !h.configEnabled(w) {
|
|
return
|
|
}
|
|
q := r.URL.Query()
|
|
left, err := h.resolveSet(q.Get("a"), q.Get("av"))
|
|
if err != nil {
|
|
jsonError(w, configStatus(err), "left set: "+err.Error())
|
|
return
|
|
}
|
|
right, err := h.resolveSet(q.Get("b"), q.Get("bv"))
|
|
if err != nil {
|
|
jsonError(w, configStatus(err), "right set: "+err.Error())
|
|
return
|
|
}
|
|
jsonOK(w, confmgr.DiffSets(left, right))
|
|
}
|
|
|
|
func (h *Handler) resolveSet(id, version string) (confmgr.ConfigSet, error) {
|
|
if id == "" {
|
|
return confmgr.ConfigSet{}, errors.New("missing set id")
|
|
}
|
|
if version == "" {
|
|
return h.cfg.GetSet(id)
|
|
}
|
|
v, err := strconv.Atoi(version)
|
|
if err != nil {
|
|
return confmgr.ConfigSet{}, errors.New("invalid version")
|
|
}
|
|
return h.cfg.GetSetVersion(id, v)
|
|
}
|
|
|
|
// ── config instances ────────────────────────────────────────────────────────
|
|
|
|
func (h *Handler) listConfigInstances(w http.ResponseWriter, r *http.Request) {
|
|
if !h.configEnabled(w) {
|
|
return
|
|
}
|
|
insts, err := h.cfg.List(confmgr.KindInstance)
|
|
if err != nil {
|
|
jsonError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
jsonOK(w, h.filterConfigMetas(r, insts))
|
|
}
|
|
|
|
// filterConfigMetas drops entries the caller may not see per their scope
|
|
// (private/group/global). Owner always sees their own; empty scope = global.
|
|
func (h *Handler) filterConfigMetas(r *http.Request, metas []confmgr.Meta) []confmgr.Meta {
|
|
user := caller(r)
|
|
out := make([]confmgr.Meta, 0, len(metas))
|
|
for _, m := range metas {
|
|
if h.policy.CanSee(user, m.Owner, m.Scope, m.Groups) {
|
|
out = append(out, m)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (h *Handler) getConfigInstance(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
|
|
}
|
|
jsonOK(w, inst)
|
|
}
|
|
|
|
func (h *Handler) createConfigInstance(w http.ResponseWriter, r *http.Request) {
|
|
if !h.configEnabled(w) {
|
|
return
|
|
}
|
|
var inst confmgr.ConfigInstance
|
|
if err := json.NewDecoder(r.Body).Decode(&inst); err != nil {
|
|
jsonError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
|
|
return
|
|
}
|
|
inst.ID = ""
|
|
inst.Owner = caller(r)
|
|
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.create", out.ID+" "+out.Name)
|
|
w.WriteHeader(http.StatusCreated)
|
|
jsonOK(w, out)
|
|
}
|
|
|
|
func (h *Handler) updateConfigInstance(w http.ResponseWriter, r *http.Request) {
|
|
if !h.configEnabled(w) {
|
|
return
|
|
}
|
|
id := r.PathValue("id")
|
|
var inst confmgr.ConfigInstance
|
|
if err := json.NewDecoder(r.Body).Decode(&inst); err != nil {
|
|
jsonError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
|
|
return
|
|
}
|
|
if prev, err := h.cfg.GetInstance(id); err == nil {
|
|
inst.Owner = prev.Owner // owner is immutable across revisions
|
|
}
|
|
out, err := h.cfg.UpdateInstance(id, inst, r.URL.Query().Get("tag"))
|
|
if err != nil {
|
|
jsonError(w, configStatus(err), err.Error())
|
|
return
|
|
}
|
|
h.recordMutation(r, "config.instance.update", out.ID+" v"+strconv.Itoa(out.Version))
|
|
jsonOK(w, out)
|
|
}
|
|
|
|
func (h *Handler) deleteConfigInstance(w http.ResponseWriter, r *http.Request) {
|
|
if !h.configEnabled(w) {
|
|
return
|
|
}
|
|
id := r.PathValue("id")
|
|
if err := h.cfg.Delete(confmgr.KindInstance, id); err != nil {
|
|
jsonError(w, configStatus(err), err.Error())
|
|
return
|
|
}
|
|
h.recordMutation(r, "config.instance.delete", id)
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
func (h *Handler) listConfigInstanceVersions(w http.ResponseWriter, r *http.Request) {
|
|
if !h.configEnabled(w) {
|
|
return
|
|
}
|
|
versions, err := h.cfg.Versions(confmgr.KindInstance, r.PathValue("id"))
|
|
if err != nil {
|
|
jsonError(w, configStatus(err), err.Error())
|
|
return
|
|
}
|
|
jsonOK(w, versions)
|
|
}
|
|
|
|
func (h *Handler) getConfigInstanceVersion(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
|
|
}
|
|
inst, err := h.cfg.GetInstanceVersion(r.PathValue("id"), version)
|
|
if err != nil {
|
|
jsonError(w, configStatus(err), err.Error())
|
|
return
|
|
}
|
|
jsonOK(w, inst)
|
|
}
|
|
|
|
func (h *Handler) promoteConfigInstance(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.KindInstance, id, version); err != nil {
|
|
jsonError(w, configStatus(err), err.Error())
|
|
return
|
|
}
|
|
h.recordMutation(r, "config.instance.promote", id+" v"+strconv.Itoa(version))
|
|
inst, err := h.cfg.GetInstance(id)
|
|
if err != nil {
|
|
jsonError(w, configStatus(err), err.Error())
|
|
return
|
|
}
|
|
jsonOK(w, inst)
|
|
}
|
|
|
|
func (h *Handler) forkConfigInstance(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.KindInstance, id, version)
|
|
if err != nil {
|
|
jsonError(w, configStatus(err), err.Error())
|
|
return
|
|
}
|
|
h.recordMutation(r, "config.instance.fork", id+" v"+strconv.Itoa(version)+" -> "+newID)
|
|
inst, err := h.cfg.GetInstance(newID)
|
|
if err != nil {
|
|
jsonError(w, configStatus(err), err.Error())
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusCreated)
|
|
jsonOK(w, inst)
|
|
}
|
|
|
|
// applyConfigInstance writes every resolvable parameter value of an instance to
|
|
// its target signal via the broker. Per-parameter outcomes are returned so a
|
|
// partial apply is reported faithfully.
|
|
func (h *Handler) applyConfigInstance(w http.ResponseWriter, r *http.Request) {
|
|
if !h.configEnabled(w) {
|
|
return
|
|
}
|
|
id := r.PathValue("id")
|
|
inst, err := h.cfg.GetInstance(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(), 15*time.Second)
|
|
defer cancel()
|
|
write := func(ds, signal string, value any) error {
|
|
src, ok := h.broker.Source(ds)
|
|
if !ok {
|
|
return errors.New("unknown data source: " + ds)
|
|
}
|
|
return src.Write(ctx, signal, value)
|
|
}
|
|
res := confmgr.Apply(set, inst, write)
|
|
h.recordMutation(r, "config.instance.apply", id+" applied="+strconv.Itoa(res.Applied)+" failed="+strconv.Itoa(res.Failed))
|
|
jsonOK(w, res)
|
|
}
|
|
|
|
// diffConfigInstances compares two instance revisions. Query params: a, b
|
|
// (ids); optional av, bv (versions, default current).
|
|
func (h *Handler) diffConfigInstances(w http.ResponseWriter, r *http.Request) {
|
|
if !h.configEnabled(w) {
|
|
return
|
|
}
|
|
q := r.URL.Query()
|
|
left, err := h.resolveInstance(q.Get("a"), q.Get("av"))
|
|
if err != nil {
|
|
jsonError(w, configStatus(err), "left instance: "+err.Error())
|
|
return
|
|
}
|
|
right, err := h.resolveInstance(q.Get("b"), q.Get("bv"))
|
|
if err != nil {
|
|
jsonError(w, configStatus(err), "right instance: "+err.Error())
|
|
return
|
|
}
|
|
jsonOK(w, confmgr.DiffInstances(left, right))
|
|
}
|
|
|
|
func (h *Handler) resolveInstance(id, version string) (confmgr.ConfigInstance, error) {
|
|
if id == "" {
|
|
return confmgr.ConfigInstance{}, errors.New("missing instance id")
|
|
}
|
|
if version == "" {
|
|
return h.cfg.GetInstance(id)
|
|
}
|
|
v, err := strconv.Atoi(version)
|
|
if err != nil {
|
|
return confmgr.ConfigInstance{}, errors.New("invalid version")
|
|
}
|
|
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))
|
|
}
|
|
|
|
// previewConfigRule evaluates a (possibly unsaved) CUE source against a live
|
|
// snapshot of the bound set's target signals, without storing anything. Unlike
|
|
// check (which uses sample/default values), preview reads the current hardware
|
|
// values so the operator sees exactly what the rule would derive from the real
|
|
// configuration. Body: {setId, source}. Returns the captured snapshot plus the
|
|
// rule result (violations + transformed values).
|
|
func (h *Handler) previewConfigRule(w http.ResponseWriter, r *http.Request) {
|
|
if !h.configEnabled(w) {
|
|
return
|
|
}
|
|
var body struct {
|
|
SetID string `json:"setId"`
|
|
Source string `json:"source"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
|
jsonError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
|
|
return
|
|
}
|
|
set, err := h.cfg.GetSet(body.SetID)
|
|
if err != nil {
|
|
jsonError(w, configStatus(err), 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))
|
|
res := confmgr.EvaluateRule(body.Source, snap.Values)
|
|
jsonOK(w, struct {
|
|
Snapshot confmgr.SnapshotResult `json:"snapshot"`
|
|
Result confmgr.RuleResult `json:"result"`
|
|
}{snap, res})
|
|
}
|