initial config manager
This commit is contained in:
@@ -0,0 +1,55 @@
|
||||
package confmgr
|
||||
|
||||
// WriteFunc writes a resolved value to a target signal. The API layer supplies
|
||||
// a closure backed by the broker/datasource so confmgr stays decoupled from the
|
||||
// transport.
|
||||
type WriteFunc func(ds, signal string, value any) error
|
||||
|
||||
// ApplyEntry records the outcome of writing one parameter.
|
||||
type ApplyEntry struct {
|
||||
Key string `json:"key"`
|
||||
DS string `json:"ds"`
|
||||
Signal string `json:"signal"`
|
||||
Value any `json:"value,omitempty"`
|
||||
OK bool `json:"ok"`
|
||||
Skipped bool `json:"skipped,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// ApplyResult summarises an apply run.
|
||||
type ApplyResult struct {
|
||||
InstanceID string `json:"instanceId"`
|
||||
SetID string `json:"setId"`
|
||||
Entries []ApplyEntry `json:"entries"`
|
||||
Applied int `json:"applied"`
|
||||
Failed int `json:"failed"`
|
||||
Skipped int `json:"skipped"`
|
||||
}
|
||||
|
||||
// Apply writes every resolvable parameter value of an instance to its target
|
||||
// signal via write. Optional parameters with no value and no default are
|
||||
// skipped. Individual write failures are recorded per-entry rather than
|
||||
// aborting the whole apply, so a partial apply is reported faithfully.
|
||||
func Apply(set ConfigSet, inst ConfigInstance, write WriteFunc) ApplyResult {
|
||||
res := ApplyResult{InstanceID: inst.ID, SetID: set.ID, Entries: make([]ApplyEntry, 0, len(set.Parameters))}
|
||||
for _, p := range set.Parameters {
|
||||
e := ApplyEntry{Key: p.Key, DS: p.DS, Signal: p.Signal}
|
||||
v, ok := inst.Resolve(p)
|
||||
if !ok {
|
||||
e.Skipped = true
|
||||
res.Skipped++
|
||||
res.Entries = append(res.Entries, e)
|
||||
continue
|
||||
}
|
||||
e.Value = v
|
||||
if err := write(p.DS, p.Signal, v); err != nil {
|
||||
e.Error = err.Error()
|
||||
res.Failed++
|
||||
} else {
|
||||
e.OK = true
|
||||
res.Applied++
|
||||
}
|
||||
res.Entries = append(res.Entries, e)
|
||||
}
|
||||
return res
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package confmgr
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestApplyWritesValues(t *testing.T) {
|
||||
set := ConfigSet{
|
||||
ID: "set1",
|
||||
Name: "s",
|
||||
Parameters: []Parameter{
|
||||
{Key: "v", DS: "epics", Signal: "PSU:V", Type: TypeFloat, Default: 12.0},
|
||||
{Key: "en", DS: "epics", Signal: "PSU:EN", Type: TypeBool},
|
||||
{Key: "opt", DS: "epics", Signal: "PSU:OPT", Type: TypeFloat}, // no value, no default → skipped
|
||||
},
|
||||
}
|
||||
inst := ConfigInstance{ID: "i1", SetID: "set1", Values: map[string]any{"v": 24.0, "en": true}}
|
||||
|
||||
type write struct {
|
||||
ds, sig string
|
||||
val any
|
||||
}
|
||||
var writes []write
|
||||
res := Apply(set, inst, func(ds, signal string, value any) error {
|
||||
writes = append(writes, write{ds, signal, value})
|
||||
return nil
|
||||
})
|
||||
|
||||
if res.Applied != 2 || res.Skipped != 1 || res.Failed != 0 {
|
||||
t.Fatalf("summary: applied=%d skipped=%d failed=%d", res.Applied, res.Skipped, res.Failed)
|
||||
}
|
||||
if len(writes) != 2 {
|
||||
t.Fatalf("want 2 writes, got %d", len(writes))
|
||||
}
|
||||
if writes[0].sig != "PSU:V" || writes[0].val != 24.0 {
|
||||
t.Errorf("first write: %+v", writes[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyUsesDefaultWhenNoValue(t *testing.T) {
|
||||
set := ConfigSet{
|
||||
ID: "set1", Name: "s",
|
||||
Parameters: []Parameter{{Key: "v", DS: "d", Signal: "S", Type: TypeFloat, Default: 7.0}},
|
||||
}
|
||||
inst := ConfigInstance{ID: "i", SetID: "set1", Values: map[string]any{}}
|
||||
var got any
|
||||
res := Apply(set, inst, func(_, _ string, value any) error { got = value; return nil })
|
||||
if res.Applied != 1 || got != 7.0 {
|
||||
t.Errorf("want default 7.0 applied, got %v (applied=%d)", got, res.Applied)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyRecordsFailures(t *testing.T) {
|
||||
set := ConfigSet{
|
||||
ID: "set1", Name: "s",
|
||||
Parameters: []Parameter{
|
||||
{Key: "a", DS: "d", Signal: "A", Type: TypeFloat, Default: 1.0},
|
||||
{Key: "b", DS: "d", Signal: "B", Type: TypeFloat, Default: 2.0},
|
||||
},
|
||||
}
|
||||
inst := ConfigInstance{ID: "i", SetID: "set1", Values: map[string]any{}}
|
||||
res := Apply(set, inst, func(_, signal string, _ any) error {
|
||||
if signal == "B" {
|
||||
return errors.New("write rejected")
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if res.Applied != 1 || res.Failed != 1 {
|
||||
t.Fatalf("want applied=1 failed=1, got applied=%d failed=%d", res.Applied, res.Failed)
|
||||
}
|
||||
for _, e := range res.Entries {
|
||||
if e.Key == "b" && (e.OK || e.Error == "") {
|
||||
t.Errorf("entry b should record failure: %+v", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package confmgr
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
)
|
||||
|
||||
// ChangeStatus classifies a single diff entry.
|
||||
type ChangeStatus string
|
||||
|
||||
const (
|
||||
StatusAdded ChangeStatus = "added"
|
||||
StatusRemoved ChangeStatus = "removed"
|
||||
StatusChanged ChangeStatus = "changed"
|
||||
StatusUnchanged ChangeStatus = "unchanged"
|
||||
)
|
||||
|
||||
// SetDiffEntry is one parameter-level difference between two config sets.
|
||||
type SetDiffEntry struct {
|
||||
Key string `json:"key"`
|
||||
Status ChangeStatus `json:"status"`
|
||||
Left *Parameter `json:"left,omitempty"`
|
||||
Right *Parameter `json:"right,omitempty"`
|
||||
}
|
||||
|
||||
// InstanceDiffEntry is one value-level difference between two config instances.
|
||||
type InstanceDiffEntry struct {
|
||||
Key string `json:"key"`
|
||||
Status ChangeStatus `json:"status"`
|
||||
Left any `json:"left,omitempty"`
|
||||
Right any `json:"right,omitempty"`
|
||||
}
|
||||
|
||||
// DiffSets compares two config sets parameter-by-parameter (keyed by Key),
|
||||
// returning entries sorted by key. Both sides are included so the frontend can
|
||||
// render either unified or side-by-side.
|
||||
func DiffSets(left, right ConfigSet) []SetDiffEntry {
|
||||
keys := map[string]bool{}
|
||||
li := map[string]Parameter{}
|
||||
ri := map[string]Parameter{}
|
||||
for _, p := range left.Parameters {
|
||||
li[p.Key] = p
|
||||
keys[p.Key] = true
|
||||
}
|
||||
for _, p := range right.Parameters {
|
||||
ri[p.Key] = p
|
||||
keys[p.Key] = true
|
||||
}
|
||||
out := make([]SetDiffEntry, 0, len(keys))
|
||||
for k := range keys {
|
||||
l, lok := li[k]
|
||||
r, rok := ri[k]
|
||||
e := SetDiffEntry{Key: k}
|
||||
switch {
|
||||
case lok && !rok:
|
||||
e.Status = StatusRemoved
|
||||
lp := l
|
||||
e.Left = &lp
|
||||
case !lok && rok:
|
||||
e.Status = StatusAdded
|
||||
rp := r
|
||||
e.Right = &rp
|
||||
default:
|
||||
lp, rp := l, r
|
||||
e.Left, e.Right = &lp, &rp
|
||||
if paramEqual(l, r) {
|
||||
e.Status = StatusUnchanged
|
||||
} else {
|
||||
e.Status = StatusChanged
|
||||
}
|
||||
}
|
||||
out = append(out, e)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Key < out[j].Key })
|
||||
return out
|
||||
}
|
||||
|
||||
// DiffInstances compares two config instances value-by-value (keyed by
|
||||
// parameter key), returning entries sorted by key.
|
||||
func DiffInstances(left, right ConfigInstance) []InstanceDiffEntry {
|
||||
keys := map[string]bool{}
|
||||
for k := range left.Values {
|
||||
keys[k] = true
|
||||
}
|
||||
for k := range right.Values {
|
||||
keys[k] = true
|
||||
}
|
||||
out := make([]InstanceDiffEntry, 0, len(keys))
|
||||
for k := range keys {
|
||||
lv, lok := left.Values[k]
|
||||
rv, rok := right.Values[k]
|
||||
e := InstanceDiffEntry{Key: k, Left: lv, Right: rv}
|
||||
switch {
|
||||
case lok && !rok:
|
||||
e.Status = StatusRemoved
|
||||
case !lok && rok:
|
||||
e.Status = StatusAdded
|
||||
case valueEqual(lv, rv):
|
||||
e.Status = StatusUnchanged
|
||||
default:
|
||||
e.Status = StatusChanged
|
||||
}
|
||||
out = append(out, e)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Key < out[j].Key })
|
||||
return out
|
||||
}
|
||||
|
||||
func paramEqual(a, b Parameter) bool {
|
||||
if a.Label != b.Label || a.Group != b.Group || a.Subgroup != b.Subgroup ||
|
||||
a.DS != b.DS || a.Signal != b.Signal || a.Type != b.Type ||
|
||||
a.Mandatory != b.Mandatory || a.Unit != b.Unit || a.Description != b.Description {
|
||||
return false
|
||||
}
|
||||
if !valueEqual(a.Default, b.Default) || !floatPtrEqual(a.Min, b.Min) || !floatPtrEqual(a.Max, b.Max) {
|
||||
return false
|
||||
}
|
||||
if len(a.EnumValues) != len(b.EnumValues) {
|
||||
return false
|
||||
}
|
||||
for i := range a.EnumValues {
|
||||
if a.EnumValues[i] != b.EnumValues[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func floatPtrEqual(a, b *float64) bool {
|
||||
if a == nil || b == nil {
|
||||
return a == b
|
||||
}
|
||||
return *a == *b
|
||||
}
|
||||
|
||||
// valueEqual compares two JSON-decoded scalar values for diff purposes by
|
||||
// rendering them to a canonical string.
|
||||
func valueEqual(a, b any) bool {
|
||||
return fmt.Sprintf("%v", a) == fmt.Sprintf("%v", b)
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package confmgr
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestDiffSets(t *testing.T) {
|
||||
left := ConfigSet{Parameters: []Parameter{
|
||||
{Key: "a", DS: "d", Signal: "A", Type: TypeFloat, Default: 1.0},
|
||||
{Key: "b", DS: "d", Signal: "B", Type: TypeFloat},
|
||||
}}
|
||||
right := ConfigSet{Parameters: []Parameter{
|
||||
{Key: "a", DS: "d", Signal: "A", Type: TypeFloat, Default: 2.0}, // changed default
|
||||
{Key: "c", DS: "d", Signal: "C", Type: TypeFloat}, // added
|
||||
}}
|
||||
diff := DiffSets(left, right)
|
||||
got := map[string]ChangeStatus{}
|
||||
for _, e := range diff {
|
||||
got[e.Key] = e.Status
|
||||
}
|
||||
if got["a"] != StatusChanged {
|
||||
t.Errorf("a: want changed, got %s", got["a"])
|
||||
}
|
||||
if got["b"] != StatusRemoved {
|
||||
t.Errorf("b: want removed, got %s", got["b"])
|
||||
}
|
||||
if got["c"] != StatusAdded {
|
||||
t.Errorf("c: want added, got %s", got["c"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiffSetsUnchanged(t *testing.T) {
|
||||
p := Parameter{Key: "a", DS: "d", Signal: "A", Type: TypeFloat, Default: 1.0}
|
||||
diff := DiffSets(ConfigSet{Parameters: []Parameter{p}}, ConfigSet{Parameters: []Parameter{p}})
|
||||
if len(diff) != 1 || diff[0].Status != StatusUnchanged {
|
||||
t.Errorf("want single unchanged entry, got %+v", diff)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiffInstances(t *testing.T) {
|
||||
left := ConfigInstance{Values: map[string]any{"a": 1.0, "b": "x"}}
|
||||
right := ConfigInstance{Values: map[string]any{"a": 2.0, "c": true}}
|
||||
diff := DiffInstances(left, right)
|
||||
got := map[string]ChangeStatus{}
|
||||
for _, e := range diff {
|
||||
got[e.Key] = e.Status
|
||||
}
|
||||
if got["a"] != StatusChanged {
|
||||
t.Errorf("a: want changed, got %s", got["a"])
|
||||
}
|
||||
if got["b"] != StatusRemoved {
|
||||
t.Errorf("b: want removed, got %s", got["b"])
|
||||
}
|
||||
if got["c"] != StatusAdded {
|
||||
t.Errorf("c: want added, got %s", got["c"])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
// Package confmgr implements the configuration manager: versioned, git-style
|
||||
// configuration Sets (schemas of parameters bound to target signals) and
|
||||
// configuration Instances (concrete values for a set), persisted as versioned
|
||||
// JSON files. Applying an instance writes each value to its target signal.
|
||||
package confmgr
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ParamType enumerates the value kinds a parameter may hold.
|
||||
type ParamType string
|
||||
|
||||
const (
|
||||
TypeFloat ParamType = "float64"
|
||||
TypeInt ParamType = "int64"
|
||||
TypeBool ParamType = "bool"
|
||||
TypeString ParamType = "string"
|
||||
TypeEnum ParamType = "enum"
|
||||
)
|
||||
|
||||
func (t ParamType) valid() bool {
|
||||
switch t {
|
||||
case TypeFloat, TypeInt, TypeBool, TypeString, TypeEnum:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Parameter is one entry in a configuration set's schema. It names a target
|
||||
// signal (DS + Signal), a value type, an optional default, and validation
|
||||
// metadata. Parameters may be grouped for presentation via Group/Subgroup.
|
||||
type Parameter struct {
|
||||
Key string `json:"key"` // unique within the set
|
||||
Label string `json:"label,omitempty"` // human-friendly name
|
||||
Group string `json:"group,omitempty"` // top-level grouping
|
||||
Subgroup string `json:"subgroup,omitempty"`
|
||||
DS string `json:"ds"` // target data source
|
||||
Signal string `json:"signal"` // target signal name
|
||||
Type ParamType `json:"type"` // value kind
|
||||
Default any `json:"default,omitempty"`
|
||||
Mandatory bool `json:"mandatory,omitempty"`
|
||||
Min *float64 `json:"min,omitempty"` // numeric lower bound
|
||||
Max *float64 `json:"max,omitempty"` // numeric upper bound
|
||||
EnumValues []string `json:"enumValues,omitempty"` // allowed values for enum
|
||||
Unit string `json:"unit,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
}
|
||||
|
||||
// ConfigSet is the schema half of the two-tier model: an ordered list of
|
||||
// parameters bound to target signals. It is versioned git-style.
|
||||
type ConfigSet struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Version int `json:"version"`
|
||||
Tag string `json:"tag,omitempty"`
|
||||
Owner string `json:"owner,omitempty"`
|
||||
Parameters []Parameter `json:"parameters"`
|
||||
}
|
||||
|
||||
// ConfigInstance is the value half: concrete values for a set's parameters,
|
||||
// keyed by parameter key. SetID pins the schema it belongs to; SetVersion pins
|
||||
// the schema revision (0 means "track current"). It is versioned git-style.
|
||||
type ConfigInstance struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
SetID string `json:"setId"`
|
||||
SetVersion int `json:"setVersion,omitempty"` // 0 = current set version
|
||||
Version int `json:"version"`
|
||||
Tag string `json:"tag,omitempty"`
|
||||
Owner string `json:"owner,omitempty"`
|
||||
Values map[string]any `json:"values"`
|
||||
}
|
||||
|
||||
// Validate checks structural invariants of a config set: non-empty name,
|
||||
// unique non-empty parameter keys, valid types, a target signal per parameter,
|
||||
// and well-formed enum/default metadata.
|
||||
func (s ConfigSet) Validate() error {
|
||||
if strings.TrimSpace(s.Name) == "" {
|
||||
return fmt.Errorf("config set name must not be empty")
|
||||
}
|
||||
seen := make(map[string]bool, len(s.Parameters))
|
||||
for i, p := range s.Parameters {
|
||||
if strings.TrimSpace(p.Key) == "" {
|
||||
return fmt.Errorf("parameter %d: key must not be empty", i)
|
||||
}
|
||||
if seen[p.Key] {
|
||||
return fmt.Errorf("duplicate parameter key %q", p.Key)
|
||||
}
|
||||
seen[p.Key] = true
|
||||
if !p.Type.valid() {
|
||||
return fmt.Errorf("parameter %q: invalid type %q", p.Key, p.Type)
|
||||
}
|
||||
if strings.TrimSpace(p.DS) == "" || strings.TrimSpace(p.Signal) == "" {
|
||||
return fmt.Errorf("parameter %q: target ds and signal are required", p.Key)
|
||||
}
|
||||
if p.Type == TypeEnum && len(p.EnumValues) == 0 {
|
||||
return fmt.Errorf("parameter %q: enum type requires enumValues", p.Key)
|
||||
}
|
||||
if p.Min != nil && p.Max != nil && *p.Min > *p.Max {
|
||||
return fmt.Errorf("parameter %q: min %v greater than max %v", p.Key, *p.Min, *p.Max)
|
||||
}
|
||||
if p.Default != nil {
|
||||
if err := p.checkValue(p.Default); err != nil {
|
||||
return fmt.Errorf("parameter %q default: %w", p.Key, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// param returns the parameter with the given key, or false.
|
||||
func (s ConfigSet) param(key string) (Parameter, bool) {
|
||||
for _, p := range s.Parameters {
|
||||
if p.Key == key {
|
||||
return p, true
|
||||
}
|
||||
}
|
||||
return Parameter{}, false
|
||||
}
|
||||
|
||||
// ValidateAgainst checks an instance against its set: every value references a
|
||||
// known parameter and is type/range valid; every mandatory parameter without a
|
||||
// default has a value.
|
||||
func (inst ConfigInstance) ValidateAgainst(set ConfigSet) error {
|
||||
for key, v := range inst.Values {
|
||||
p, ok := set.param(key)
|
||||
if !ok {
|
||||
return fmt.Errorf("value for unknown parameter %q", key)
|
||||
}
|
||||
if err := p.checkValue(v); err != nil {
|
||||
return fmt.Errorf("parameter %q: %w", key, err)
|
||||
}
|
||||
}
|
||||
for _, p := range set.Parameters {
|
||||
if !p.Mandatory {
|
||||
continue
|
||||
}
|
||||
if _, ok := inst.Values[p.Key]; ok {
|
||||
continue
|
||||
}
|
||||
if p.Default == nil {
|
||||
return fmt.Errorf("mandatory parameter %q has no value", p.Key)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Resolve returns the effective value for a parameter: the instance value when
|
||||
// present, otherwise the parameter default. The second result is false when no
|
||||
// value is available (optional parameter, no default).
|
||||
func (inst ConfigInstance) Resolve(p Parameter) (any, bool) {
|
||||
if v, ok := inst.Values[p.Key]; ok {
|
||||
return v, true
|
||||
}
|
||||
if p.Default != nil {
|
||||
return p.Default, true
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// checkValue verifies a value matches the parameter's type and constraints.
|
||||
func (p Parameter) checkValue(v any) error {
|
||||
switch p.Type {
|
||||
case TypeFloat, TypeInt:
|
||||
f, err := toFloat(v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if p.Type == TypeInt && f != math.Trunc(f) {
|
||||
return fmt.Errorf("value %v is not an integer", f)
|
||||
}
|
||||
if p.Min != nil && f < *p.Min {
|
||||
return fmt.Errorf("value %v below minimum %v", f, *p.Min)
|
||||
}
|
||||
if p.Max != nil && f > *p.Max {
|
||||
return fmt.Errorf("value %v above maximum %v", f, *p.Max)
|
||||
}
|
||||
case TypeBool:
|
||||
if _, ok := v.(bool); !ok {
|
||||
return fmt.Errorf("value %v is not a bool", v)
|
||||
}
|
||||
case TypeString:
|
||||
if _, ok := v.(string); !ok {
|
||||
return fmt.Errorf("value %v is not a string", v)
|
||||
}
|
||||
case TypeEnum:
|
||||
s, ok := v.(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("enum value %v is not a string", v)
|
||||
}
|
||||
if !slices.Contains(p.EnumValues, s) {
|
||||
return fmt.Errorf("value %q is not an allowed enum value", s)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// toFloat coerces a JSON-decoded numeric value to float64. JSON unmarshalling
|
||||
// yields float64 for numbers, but values may also arrive as int or string.
|
||||
func toFloat(v any) (float64, error) {
|
||||
switch n := v.(type) {
|
||||
case float64:
|
||||
return n, nil
|
||||
case float32:
|
||||
return float64(n), nil
|
||||
case int:
|
||||
return float64(n), nil
|
||||
case int64:
|
||||
return float64(n), nil
|
||||
case string:
|
||||
f, err := strconv.ParseFloat(n, 64)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("value %q is not numeric", n)
|
||||
}
|
||||
return f, nil
|
||||
default:
|
||||
return 0, fmt.Errorf("value %v is not numeric", v)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,550 @@
|
||||
package confmgr
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
// ErrNotFound is returned when the requested set or instance does not exist.
|
||||
var ErrNotFound = errors.New("config object not found")
|
||||
|
||||
// Kind selects which collection a store operation targets.
|
||||
type Kind int
|
||||
|
||||
const (
|
||||
KindSet Kind = iota
|
||||
KindInstance
|
||||
)
|
||||
|
||||
func (k Kind) sub() string {
|
||||
if k == KindInstance {
|
||||
return "instances"
|
||||
}
|
||||
return "sets"
|
||||
}
|
||||
|
||||
// Meta is the lightweight listing representation.
|
||||
type Meta struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Version int `json:"version"`
|
||||
}
|
||||
|
||||
// VersionMeta describes a single persisted revision.
|
||||
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"`
|
||||
}
|
||||
|
||||
// Store persists configuration sets and instances as versioned JSON files
|
||||
// under {storageDir}/configs/{sets,instances}/. Each object lives in
|
||||
// {id}.json with prior revisions preserved as {id}.v{N}.json backups, exactly
|
||||
// mirroring the panel storage versioning scheme.
|
||||
type Store struct {
|
||||
rootDir string
|
||||
dirs map[Kind]string
|
||||
}
|
||||
|
||||
// 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} {
|
||||
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)
|
||||
}
|
||||
s.dirs[k] = dir
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// header parses the metadata fields shared by sets and instances.
|
||||
type header struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Version int `json:"version"`
|
||||
Tag string `json:"tag"`
|
||||
}
|
||||
|
||||
func validateID(id string) error {
|
||||
if id == "" {
|
||||
return fmt.Errorf("config ID must not be empty")
|
||||
}
|
||||
for _, r := range id {
|
||||
if !unicode.IsLetter(r) && !unicode.IsDigit(r) && r != '-' && r != '_' {
|
||||
return fmt.Errorf("invalid character %q in config ID", r)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) filePath(k Kind, id string) string {
|
||||
return filepath.Join(s.dirs[k], id+".json")
|
||||
}
|
||||
|
||||
func (s *Store) backupPath(k Kind, id string, version int) string {
|
||||
return filepath.Join(s.dirs[k], fmt.Sprintf("%s.v%d.json", id, version))
|
||||
}
|
||||
|
||||
// isVersioned reports whether name matches <id>.v<number>.json.
|
||||
func isVersioned(name string) bool {
|
||||
parts := strings.Split(name, ".")
|
||||
if len(parts) != 3 || parts[2] != "json" {
|
||||
return false
|
||||
}
|
||||
if !strings.HasPrefix(parts[1], "v") {
|
||||
return false
|
||||
}
|
||||
_, err := strconv.Atoi(parts[1][1:])
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func readHeader(data []byte) (header, error) {
|
||||
var h header
|
||||
if err := json.Unmarshal(data, &h); err != nil {
|
||||
return header{}, err
|
||||
}
|
||||
return h, nil
|
||||
}
|
||||
|
||||
// List returns metadata for every stored object of the given kind.
|
||||
func (s *Store) List(k Kind) ([]Meta, error) {
|
||||
entries, err := os.ReadDir(s.dirs[k])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := []Meta{}
|
||||
for _, e := range entries {
|
||||
name := e.Name()
|
||||
if e.IsDir() || !strings.HasSuffix(name, ".json") || isVersioned(name) {
|
||||
continue
|
||||
}
|
||||
id := strings.TrimSuffix(name, ".json")
|
||||
data, err := os.ReadFile(s.filePath(k, id))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
h, err := readHeader(data)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
out = append(out, Meta{ID: id, Name: h.Name, Version: h.Version})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// get returns the raw JSON bytes for the current revision.
|
||||
func (s *Store) get(k Kind, id string) ([]byte, error) {
|
||||
if err := validateID(id); err != nil {
|
||||
return nil, fmt.Errorf("%w: %v", ErrNotFound, err)
|
||||
}
|
||||
data, err := os.ReadFile(s.filePath(k, id))
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return data, err
|
||||
}
|
||||
|
||||
// create writes a new object, deriving a unique ID from name when obj has none,
|
||||
// stamping version=1 and the given tag. It returns the raw stored bytes.
|
||||
func (s *Store) create(k Kind, obj map[string]any, tag string) (string, []byte, error) {
|
||||
id, _ := obj["id"].(string)
|
||||
if id == "" {
|
||||
id = slugify(name(obj))
|
||||
}
|
||||
if err := validateID(id); err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
if _, err := os.Stat(s.filePath(k, id)); err == nil {
|
||||
id = id + "-" + strconv.FormatInt(time.Now().UnixMilli(), 10)
|
||||
}
|
||||
obj["id"] = id
|
||||
obj["version"] = 1
|
||||
if tag != "" {
|
||||
obj["tag"] = tag
|
||||
}
|
||||
data, err := marshal(obj)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
return id, data, os.WriteFile(s.filePath(k, id), data, 0o644)
|
||||
}
|
||||
|
||||
// update replaces the current revision, preserving the prior one as a backup
|
||||
// and incrementing the version. It returns the raw stored bytes.
|
||||
func (s *Store) update(k Kind, id string, obj map[string]any, tag string) ([]byte, error) {
|
||||
if err := validateID(id); err != nil {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
oldData, err := os.ReadFile(s.filePath(k, id))
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
oldHdr, err := readHeader(oldData)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse existing JSON: %w", err)
|
||||
}
|
||||
if oldHdr.Version < 1 {
|
||||
oldHdr.Version = 1
|
||||
}
|
||||
if err := os.WriteFile(s.backupPath(k, id, oldHdr.Version), oldData, 0o644); err != nil {
|
||||
return nil, fmt.Errorf("create backup: %w", err)
|
||||
}
|
||||
obj["id"] = id
|
||||
obj["version"] = oldHdr.Version + 1
|
||||
if tag != "" {
|
||||
obj["tag"] = tag
|
||||
}
|
||||
data, err := marshal(obj)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return data, os.WriteFile(s.filePath(k, id), data, 0o644)
|
||||
}
|
||||
|
||||
// Versions returns metadata for every persisted revision, newest-first.
|
||||
func (s *Store) Versions(k Kind, id string) ([]VersionMeta, error) {
|
||||
if err := validateID(id); err != nil {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
curInfo, err := os.Stat(s.filePath(k, id))
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil, ErrNotFound
|
||||
} else if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
curData, err := os.ReadFile(s.filePath(k, id))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
curHdr, err := readHeader(curData)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := []VersionMeta{{
|
||||
Version: curHdr.Version,
|
||||
Name: curHdr.Name,
|
||||
Tag: curHdr.Tag,
|
||||
Current: true,
|
||||
SavedAt: curInfo.ModTime(),
|
||||
}}
|
||||
|
||||
entries, err := os.ReadDir(s.dirs[k])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
prefix := id + ".v"
|
||||
for _, e := range entries {
|
||||
name := e.Name()
|
||||
if e.IsDir() || !strings.HasPrefix(name, prefix) || !isVersioned(name) {
|
||||
continue
|
||||
}
|
||||
info, err := e.Info()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(s.dirs[k], name))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
h, err := readHeader(data)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
out = append(out, VersionMeta{
|
||||
Version: h.Version,
|
||||
Name: h.Name,
|
||||
Tag: h.Tag,
|
||||
SavedAt: info.ModTime(),
|
||||
})
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Version > out[j].Version })
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// getVersion returns the raw JSON bytes for a specific revision.
|
||||
func (s *Store) getVersion(k Kind, id string, version int) ([]byte, error) {
|
||||
if err := validateID(id); err != nil {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
curData, err := os.ReadFile(s.filePath(k, id))
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if h, err := readHeader(curData); err == nil && h.Version == version {
|
||||
return curData, nil
|
||||
}
|
||||
data, err := os.ReadFile(s.backupPath(k, id, version))
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return data, err
|
||||
}
|
||||
|
||||
// Promote re-saves a past revision as a new revision on top of history.
|
||||
func (s *Store) Promote(k Kind, id string, version int) error {
|
||||
data, err := s.getVersion(k, id, version)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
obj, err := unmarshal(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = s.update(k, id, obj, fmt.Sprintf("restored from v%d", version))
|
||||
return err
|
||||
}
|
||||
|
||||
// Fork creates a brand-new object from a revision, with a fresh ID and version 1.
|
||||
func (s *Store) Fork(k Kind, id string, version int) (string, error) {
|
||||
data, err := s.getVersion(k, id, version)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
obj, err := unmarshal(data)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
newID := id + "-fork-" + strconv.FormatInt(time.Now().UnixMilli(), 10)
|
||||
obj["id"] = newID
|
||||
obj["version"] = 1
|
||||
delete(obj, "tag")
|
||||
out, err := marshal(obj)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return newID, os.WriteFile(s.filePath(k, newID), out, 0o644)
|
||||
}
|
||||
|
||||
// Delete moves an object and all its backups to a timestamped trash folder so
|
||||
// it can be recovered. Nothing is destroyed.
|
||||
func (s *Store) Delete(k Kind, id string) error {
|
||||
if err := validateID(id); err != nil {
|
||||
return ErrNotFound
|
||||
}
|
||||
if _, err := os.Stat(s.filePath(k, id)); err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return ErrNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
trashDir := filepath.Join(s.rootDir, "trash", "configs", k.sub(), fmt.Sprintf("%s.%d", id, time.Now().UnixMilli()))
|
||||
if err := os.MkdirAll(trashDir, 0o755); err != nil {
|
||||
return fmt.Errorf("create trash dir: %w", err)
|
||||
}
|
||||
entries, err := os.ReadDir(s.dirs[k])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, e := range entries {
|
||||
name := e.Name()
|
||||
if e.IsDir() {
|
||||
continue
|
||||
}
|
||||
if name == id+".json" || (strings.HasPrefix(name, id+".v") && isVersioned(name)) {
|
||||
if err := os.Rename(filepath.Join(s.dirs[k], name), filepath.Join(trashDir, name)); err != nil {
|
||||
return fmt.Errorf("move %s to trash: %w", name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ── typed wrappers ──────────────────────────────────────────────────────────
|
||||
|
||||
// GetSet returns the current revision of a config set.
|
||||
func (s *Store) GetSet(id string) (ConfigSet, error) {
|
||||
data, err := s.get(KindSet, id)
|
||||
if err != nil {
|
||||
return ConfigSet{}, err
|
||||
}
|
||||
var set ConfigSet
|
||||
return set, json.Unmarshal(data, &set)
|
||||
}
|
||||
|
||||
// GetSetVersion returns a specific revision of a config set.
|
||||
func (s *Store) GetSetVersion(id string, version int) (ConfigSet, error) {
|
||||
data, err := s.getVersion(KindSet, id, version)
|
||||
if err != nil {
|
||||
return ConfigSet{}, err
|
||||
}
|
||||
var set ConfigSet
|
||||
return set, json.Unmarshal(data, &set)
|
||||
}
|
||||
|
||||
// CreateSet validates and stores a new config set, returning it with its
|
||||
// assigned ID and version.
|
||||
func (s *Store) CreateSet(set ConfigSet, tag string) (ConfigSet, error) {
|
||||
if err := set.Validate(); err != nil {
|
||||
return ConfigSet{}, err
|
||||
}
|
||||
obj, err := toMap(set)
|
||||
if err != nil {
|
||||
return ConfigSet{}, err
|
||||
}
|
||||
_, data, err := s.create(KindSet, obj, tag)
|
||||
if err != nil {
|
||||
return ConfigSet{}, err
|
||||
}
|
||||
var out ConfigSet
|
||||
return out, json.Unmarshal(data, &out)
|
||||
}
|
||||
|
||||
// UpdateSet validates and stores a new revision of an existing config set.
|
||||
func (s *Store) UpdateSet(id string, set ConfigSet, tag string) (ConfigSet, error) {
|
||||
if err := set.Validate(); err != nil {
|
||||
return ConfigSet{}, err
|
||||
}
|
||||
obj, err := toMap(set)
|
||||
if err != nil {
|
||||
return ConfigSet{}, err
|
||||
}
|
||||
data, err := s.update(KindSet, id, obj, tag)
|
||||
if err != nil {
|
||||
return ConfigSet{}, err
|
||||
}
|
||||
var out ConfigSet
|
||||
return out, json.Unmarshal(data, &out)
|
||||
}
|
||||
|
||||
// GetInstance returns the current revision of a config instance.
|
||||
func (s *Store) GetInstance(id string) (ConfigInstance, error) {
|
||||
data, err := s.get(KindInstance, id)
|
||||
if err != nil {
|
||||
return ConfigInstance{}, err
|
||||
}
|
||||
var inst ConfigInstance
|
||||
return inst, json.Unmarshal(data, &inst)
|
||||
}
|
||||
|
||||
// GetInstanceVersion returns a specific revision of a config instance.
|
||||
func (s *Store) GetInstanceVersion(id string, version int) (ConfigInstance, error) {
|
||||
data, err := s.getVersion(KindInstance, id, version)
|
||||
if err != nil {
|
||||
return ConfigInstance{}, err
|
||||
}
|
||||
var inst ConfigInstance
|
||||
return inst, json.Unmarshal(data, &inst)
|
||||
}
|
||||
|
||||
// setForInstance loads the schema an instance is bound to, honouring a pinned
|
||||
// SetVersion when set.
|
||||
func (s *Store) setForInstance(inst ConfigInstance) (ConfigSet, error) {
|
||||
if inst.SetVersion > 0 {
|
||||
return s.GetSetVersion(inst.SetID, inst.SetVersion)
|
||||
}
|
||||
return s.GetSet(inst.SetID)
|
||||
}
|
||||
|
||||
// CreateInstance validates an instance against its set and stores it.
|
||||
func (s *Store) CreateInstance(inst ConfigInstance, tag string) (ConfigInstance, error) {
|
||||
set, err := s.setForInstance(inst)
|
||||
if err != nil {
|
||||
return ConfigInstance{}, fmt.Errorf("load set %q: %w", inst.SetID, err)
|
||||
}
|
||||
if err := inst.ValidateAgainst(set); err != nil {
|
||||
return ConfigInstance{}, err
|
||||
}
|
||||
obj, err := toMap(inst)
|
||||
if err != nil {
|
||||
return ConfigInstance{}, err
|
||||
}
|
||||
_, data, err := s.create(KindInstance, obj, tag)
|
||||
if err != nil {
|
||||
return ConfigInstance{}, err
|
||||
}
|
||||
var out ConfigInstance
|
||||
return out, json.Unmarshal(data, &out)
|
||||
}
|
||||
|
||||
// UpdateInstance validates and stores a new revision of an existing instance.
|
||||
func (s *Store) UpdateInstance(id string, inst ConfigInstance, tag string) (ConfigInstance, error) {
|
||||
set, err := s.setForInstance(inst)
|
||||
if err != nil {
|
||||
return ConfigInstance{}, fmt.Errorf("load set %q: %w", inst.SetID, err)
|
||||
}
|
||||
if err := inst.ValidateAgainst(set); err != nil {
|
||||
return ConfigInstance{}, err
|
||||
}
|
||||
obj, err := toMap(inst)
|
||||
if err != nil {
|
||||
return ConfigInstance{}, err
|
||||
}
|
||||
data, err := s.update(KindInstance, id, obj, tag)
|
||||
if err != nil {
|
||||
return ConfigInstance{}, err
|
||||
}
|
||||
var out ConfigInstance
|
||||
return out, json.Unmarshal(data, &out)
|
||||
}
|
||||
|
||||
// SetForInstance is the exported loader used by apply/diff callers.
|
||||
func (s *Store) SetForInstance(inst ConfigInstance) (ConfigSet, error) {
|
||||
return s.setForInstance(inst)
|
||||
}
|
||||
|
||||
// ── JSON helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
func name(obj map[string]any) string {
|
||||
n, _ := obj["name"].(string)
|
||||
return n
|
||||
}
|
||||
|
||||
func marshal(obj map[string]any) ([]byte, error) {
|
||||
return json.MarshalIndent(obj, "", " ")
|
||||
}
|
||||
|
||||
func unmarshal(data []byte) (map[string]any, error) {
|
||||
var obj map[string]any
|
||||
if err := json.Unmarshal(data, &obj); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return obj, nil
|
||||
}
|
||||
|
||||
// toMap round-trips a typed value through JSON into a generic map so the store
|
||||
// can stamp id/version/tag without knowing the concrete type.
|
||||
func toMap(v any) (map[string]any, error) {
|
||||
data, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return unmarshal(data)
|
||||
}
|
||||
|
||||
// slugify converts a human-readable name into a URL-safe identifier.
|
||||
func slugify(s string) string {
|
||||
var b strings.Builder
|
||||
for _, r := range strings.ToLower(s) {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
|
||||
b.WriteRune(r)
|
||||
default:
|
||||
if b.Len() > 0 && b.String()[b.Len()-1] != '-' {
|
||||
b.WriteByte('-')
|
||||
}
|
||||
}
|
||||
}
|
||||
result := strings.Trim(b.String(), "-")
|
||||
if result == "" {
|
||||
return "config"
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
package confmgr
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func fptr(f float64) *float64 { return &f }
|
||||
|
||||
func sampleSet() ConfigSet {
|
||||
return ConfigSet{
|
||||
Name: "PSU",
|
||||
Description: "power supply config",
|
||||
Parameters: []Parameter{
|
||||
{Key: "voltage", DS: "epics", Signal: "PSU:V", Type: TypeFloat, Default: 12.0, Mandatory: true, Min: fptr(0), Max: fptr(48)},
|
||||
{Key: "enabled", DS: "epics", Signal: "PSU:EN", Type: TypeBool, Default: false},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateAndGetSet(t *testing.T) {
|
||||
s, err := New(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, err := s.CreateSet(sampleSet(), "initial")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if out.ID == "" {
|
||||
t.Fatal("expected generated ID")
|
||||
}
|
||||
if out.Version != 1 {
|
||||
t.Errorf("version: want 1, got %d", out.Version)
|
||||
}
|
||||
got, err := s.GetSet(out.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Name != "PSU" || len(got.Parameters) != 2 {
|
||||
t.Errorf("round-trip mismatch: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetVersioning(t *testing.T) {
|
||||
s, _ := New(t.TempDir())
|
||||
created, _ := s.CreateSet(sampleSet(), "")
|
||||
id := created.ID
|
||||
|
||||
// Update -> v2.
|
||||
upd := sampleSet()
|
||||
upd.Description = "edited"
|
||||
v2, err := s.UpdateSet(id, upd, "edit")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if v2.Version != 2 {
|
||||
t.Fatalf("version after update: want 2, got %d", v2.Version)
|
||||
}
|
||||
|
||||
versions, err := s.Versions(KindSet, id)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(versions) != 2 {
|
||||
t.Fatalf("want 2 versions, got %d", len(versions))
|
||||
}
|
||||
if !versions[0].Current || versions[0].Version != 2 {
|
||||
t.Errorf("newest should be current v2: %+v", versions[0])
|
||||
}
|
||||
|
||||
// Old version still retrievable.
|
||||
old, err := s.GetSetVersion(id, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if old.Description != "power supply config" {
|
||||
t.Errorf("v1 description changed: %q", old.Description)
|
||||
}
|
||||
|
||||
// Promote v1 -> becomes v3 with original content.
|
||||
if err := s.Promote(KindSet, id, 1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cur, _ := s.GetSet(id)
|
||||
if cur.Version != 3 || cur.Description != "power supply config" {
|
||||
t.Errorf("after promote: want v3 original desc, got v%d %q", cur.Version, cur.Description)
|
||||
}
|
||||
}
|
||||
|
||||
func TestForkSet(t *testing.T) {
|
||||
s, _ := New(t.TempDir())
|
||||
created, _ := s.CreateSet(sampleSet(), "")
|
||||
_, _ = s.UpdateSet(created.ID, sampleSet(), "")
|
||||
|
||||
newID, err := s.Fork(KindSet, created.ID, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
forked, err := s.GetSet(newID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if forked.ID == created.ID {
|
||||
t.Error("fork should have a new ID")
|
||||
}
|
||||
if forked.Version != 1 {
|
||||
t.Errorf("fork version: want 1, got %d", forked.Version)
|
||||
}
|
||||
if forked.Tag != "" {
|
||||
t.Errorf("fork tag should be cleared, got %q", forked.Tag)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteSetSoftDeletes(t *testing.T) {
|
||||
s, _ := New(t.TempDir())
|
||||
created, _ := s.CreateSet(sampleSet(), "")
|
||||
if err := s.Delete(KindSet, created.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := s.GetSet(created.ID); err == nil {
|
||||
t.Error("expected set to be gone after delete")
|
||||
}
|
||||
// Deleting again is a not-found.
|
||||
if err := s.Delete(KindSet, created.ID); err != ErrNotFound {
|
||||
t.Errorf("want ErrNotFound, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetValidation(t *testing.T) {
|
||||
s, _ := New(t.TempDir())
|
||||
bad := ConfigSet{Name: "", Parameters: nil}
|
||||
if _, err := s.CreateSet(bad, ""); err == nil {
|
||||
t.Error("expected empty-name validation error")
|
||||
}
|
||||
dup := ConfigSet{Name: "x", Parameters: []Parameter{
|
||||
{Key: "a", DS: "d", Signal: "s", Type: TypeFloat},
|
||||
{Key: "a", DS: "d", Signal: "s2", Type: TypeFloat},
|
||||
}}
|
||||
if _, err := s.CreateSet(dup, ""); err == nil {
|
||||
t.Error("expected duplicate-key validation error")
|
||||
}
|
||||
defaultOOR := ConfigSet{Name: "x", Parameters: []Parameter{
|
||||
{Key: "a", DS: "d", Signal: "s", Type: TypeFloat, Default: 100.0, Max: fptr(10)},
|
||||
}}
|
||||
if _, err := s.CreateSet(defaultOOR, ""); err == nil {
|
||||
t.Error("expected out-of-range default validation error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstanceLifecycle(t *testing.T) {
|
||||
s, _ := New(t.TempDir())
|
||||
set, _ := s.CreateSet(sampleSet(), "")
|
||||
|
||||
inst := ConfigInstance{
|
||||
Name: "nominal",
|
||||
SetID: set.ID,
|
||||
Values: map[string]any{"voltage": 24.0, "enabled": true},
|
||||
}
|
||||
out, err := s.CreateInstance(inst, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if out.Version != 1 {
|
||||
t.Errorf("instance version: want 1, got %d", out.Version)
|
||||
}
|
||||
|
||||
// Update value -> v2.
|
||||
out.Values["voltage"] = 36.0
|
||||
v2, err := s.UpdateInstance(out.ID, out, "bump")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if v2.Version != 2 {
|
||||
t.Errorf("instance version after update: want 2, got %d", v2.Version)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstanceValidation(t *testing.T) {
|
||||
s, _ := New(t.TempDir())
|
||||
set, _ := s.CreateSet(sampleSet(), "")
|
||||
|
||||
// voltage is mandatory with a default, so omitting it is OK; but an
|
||||
// out-of-range value must fail.
|
||||
bad := ConfigInstance{Name: "x", SetID: set.ID, Values: map[string]any{"voltage": 999.0}}
|
||||
if _, err := s.CreateInstance(bad, ""); err == nil {
|
||||
t.Error("expected out-of-range value error")
|
||||
}
|
||||
|
||||
// Unknown parameter key must fail.
|
||||
unknown := ConfigInstance{Name: "x", SetID: set.ID, Values: map[string]any{"nope": 1.0}}
|
||||
if _, err := s.CreateInstance(unknown, ""); err == nil {
|
||||
t.Error("expected unknown-parameter error")
|
||||
}
|
||||
|
||||
// Unknown set must fail.
|
||||
missing := ConfigInstance{Name: "x", SetID: "does-not-exist", Values: map[string]any{}}
|
||||
if _, err := s.CreateInstance(missing, ""); err == nil {
|
||||
t.Error("expected missing-set error")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user