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 .v.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 }