701 lines
19 KiB
Go
701 lines
19 KiB
Go
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
|
|
KindRule
|
|
)
|
|
|
|
func (k Kind) sub() string {
|
|
switch k {
|
|
case KindInstance:
|
|
return "instances"
|
|
case KindRule:
|
|
return "rules"
|
|
default:
|
|
return "sets"
|
|
}
|
|
}
|
|
|
|
// Meta is the lightweight listing representation.
|
|
type Meta struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Version int `json:"version"`
|
|
// SetID is only populated for instances (the schema they belong to); it is
|
|
// empty for sets. Lets clients group/filter instances by set without a GET
|
|
// per instance.
|
|
SetID string `json:"setId,omitempty"`
|
|
// Enabled is only populated for rules: nil for sets/instances and for legacy
|
|
// rules without the flag. Lets the rule list show enabled/disabled status
|
|
// without a GET per rule.
|
|
Enabled *bool `json:"enabled,omitempty"`
|
|
// Owner/Scope/Groups carry the visibility metadata so list handlers can filter
|
|
// by the caller without a GET per object. Empty scope = global (legacy-safe).
|
|
Owner string `json:"owner,omitempty"`
|
|
Scope string `json:"scope,omitempty"`
|
|
Groups []string `json:"groups,omitempty"`
|
|
}
|
|
|
|
// 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, KindRule} {
|
|
dir := filepath.Join(storageDir, "configs", k.sub())
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
return nil, fmt.Errorf("create %s dir: %w", k.sub(), err)
|
|
}
|
|
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"`
|
|
SetID string `json:"setId"`
|
|
Enabled *bool `json:"enabled"`
|
|
Owner string `json:"owner"`
|
|
Scope string `json:"scope"`
|
|
Groups []string `json:"groups"`
|
|
}
|
|
|
|
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, SetID: h.SetID, Enabled: h.Enabled, Owner: h.Owner, Scope: h.Scope, Groups: h.Groups})
|
|
}
|
|
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
|
|
}
|
|
if err := s.applyRules(&inst, 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
|
|
}
|
|
if err := s.applyRules(&inst, 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)
|
|
}
|
|
|
|
// ── rules ────────────────────────────────────────────────────────────────────
|
|
|
|
// GetRule returns the current revision of a CUE validation/transformation rule.
|
|
func (s *Store) GetRule(id string) (ConfigRule, error) {
|
|
data, err := s.get(KindRule, id)
|
|
if err != nil {
|
|
return ConfigRule{}, err
|
|
}
|
|
var rule ConfigRule
|
|
return rule, json.Unmarshal(data, &rule)
|
|
}
|
|
|
|
// GetRuleVersion returns a specific revision of a rule.
|
|
func (s *Store) GetRuleVersion(id string, version int) (ConfigRule, error) {
|
|
data, err := s.getVersion(KindRule, id, version)
|
|
if err != nil {
|
|
return ConfigRule{}, err
|
|
}
|
|
var rule ConfigRule
|
|
return rule, json.Unmarshal(data, &rule)
|
|
}
|
|
|
|
// CreateRule validates the rule's CUE source and stores it.
|
|
func (s *Store) CreateRule(rule ConfigRule, tag string) (ConfigRule, error) {
|
|
if err := rule.Validate(); err != nil {
|
|
return ConfigRule{}, err
|
|
}
|
|
obj, err := toMap(rule)
|
|
if err != nil {
|
|
return ConfigRule{}, err
|
|
}
|
|
_, data, err := s.create(KindRule, obj, tag)
|
|
if err != nil {
|
|
return ConfigRule{}, err
|
|
}
|
|
var out ConfigRule
|
|
return out, json.Unmarshal(data, &out)
|
|
}
|
|
|
|
// UpdateRule validates and stores a new revision of an existing rule.
|
|
func (s *Store) UpdateRule(id string, rule ConfigRule, tag string) (ConfigRule, error) {
|
|
if err := rule.Validate(); err != nil {
|
|
return ConfigRule{}, err
|
|
}
|
|
obj, err := toMap(rule)
|
|
if err != nil {
|
|
return ConfigRule{}, err
|
|
}
|
|
data, err := s.update(KindRule, id, obj, tag)
|
|
if err != nil {
|
|
return ConfigRule{}, err
|
|
}
|
|
var out ConfigRule
|
|
return out, json.Unmarshal(data, &out)
|
|
}
|
|
|
|
// rulesForSet loads every current-revision rule bound to the given set.
|
|
func (s *Store) rulesForSet(setID string) ([]ConfigRule, error) {
|
|
metas, err := s.List(KindRule)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var out []ConfigRule
|
|
for _, m := range metas {
|
|
if m.SetID != setID {
|
|
continue
|
|
}
|
|
rule, err := s.GetRule(m.ID)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
if !rule.IsEnabled() {
|
|
continue
|
|
}
|
|
out = append(out, rule)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// applyRules runs every rule bound to inst's set against its values. On
|
|
// success it merges rule-derived values back into inst (parameter keys only) so
|
|
// the stored instance reflects the canonical, transformed configuration. A
|
|
// violation is returned as *RuleError so the caller can surface the structured
|
|
// details.
|
|
func (s *Store) applyRules(inst *ConfigInstance, set ConfigSet) error {
|
|
rules, err := s.rulesForSet(inst.SetID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if len(rules) == 0 {
|
|
return nil
|
|
}
|
|
res := evaluateRules(rules, inst.Values)
|
|
if !res.OK {
|
|
return &RuleError{Result: res}
|
|
}
|
|
for k, v := range res.Transformed {
|
|
if _, ok := set.param(k); ok {
|
|
if inst.Values == nil {
|
|
inst.Values = map[string]any{}
|
|
}
|
|
inst.Values[k] = v
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ValidateInstanceRules evaluates the stored rules for an instance without
|
|
// persisting anything. It is the read-only counterpart used by the validate
|
|
// endpoint.
|
|
func (s *Store) ValidateInstanceRules(inst ConfigInstance) (RuleResult, error) {
|
|
rules, err := s.rulesForSet(inst.SetID)
|
|
if err != nil {
|
|
return RuleResult{}, err
|
|
}
|
|
if len(rules) == 0 {
|
|
return RuleResult{OK: true}, nil
|
|
}
|
|
return evaluateRules(rules, inst.Values), nil
|
|
}
|
|
|
|
// ── JSON helpers ────────────────────────────────────────────────────────────
|
|
|
|
func name(obj map[string]any) string {
|
|
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
|
|
}
|