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