367 lines
9.7 KiB
Go
367 lines
9.7 KiB
Go
package controlplane
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"syscall"
|
|
"time"
|
|
)
|
|
|
|
// StoreOptions controls durability and bounded retention for the JSON store.
|
|
// A Store still has one active writer; the lock file enables active/passive failover.
|
|
type StoreOptions struct {
|
|
BackupDir string
|
|
BackupCount int
|
|
BackupInterval time.Duration
|
|
TaskRetention time.Duration
|
|
EventRetention time.Duration
|
|
AuditRetention time.Duration
|
|
}
|
|
|
|
// Store is a small durable JSON store for the control-plane MVP.
|
|
// The file is an implementation detail; callers only observe transactional methods.
|
|
type Store struct {
|
|
path string
|
|
lockFile *os.File
|
|
options StoreOptions
|
|
lastBackup time.Time
|
|
mu sync.Mutex
|
|
state PersistedState
|
|
closed bool
|
|
}
|
|
|
|
func OpenStore(path string, options ...StoreOptions) (*Store, error) {
|
|
if path == "" {
|
|
return nil, errors.New("data file is required")
|
|
}
|
|
cleanPath := filepath.Clean(path)
|
|
storeOptions := StoreOptions{}
|
|
if len(options) > 0 {
|
|
storeOptions = options[0]
|
|
}
|
|
if storeOptions.BackupDir == "" {
|
|
storeOptions.BackupDir = cleanPath + ".backups"
|
|
}
|
|
if storeOptions.BackupCount == 0 {
|
|
storeOptions.BackupCount = 7
|
|
}
|
|
if storeOptions.BackupCount < -1 {
|
|
return nil, errors.New("backup count must be -1 or non-negative")
|
|
}
|
|
if err := os.MkdirAll(filepath.Dir(cleanPath), 0o700); err != nil {
|
|
return nil, fmt.Errorf("create data directory: %w", err)
|
|
}
|
|
lockFile, err := os.OpenFile(cleanPath+".lock", os.O_CREATE|os.O_RDWR, 0o600)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("open data lock: %w", err)
|
|
}
|
|
if err := syscall.Flock(int(lockFile.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil {
|
|
_ = lockFile.Close()
|
|
if errors.Is(err, syscall.EWOULDBLOCK) {
|
|
return nil, fmt.Errorf("data file is already in use: %s", cleanPath)
|
|
}
|
|
return nil, fmt.Errorf("lock data file: %w", err)
|
|
}
|
|
|
|
store := &Store{
|
|
path: cleanPath,
|
|
lockFile: lockFile,
|
|
options: storeOptions,
|
|
state: PersistedState{Nodes: map[string]Node{}, Tasks: map[string]Task{}, Events: []StoredEvent{}, Audit: []AuditEntry{}, AIFlows: map[string]AIFlow{}, AIRuns: map[string]AIRun{}, AIPullTasks: map[string]AIPullTask{}, AIMessageKeys: map[string]string{}},
|
|
}
|
|
data, err := os.ReadFile(store.path)
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
return store, nil
|
|
}
|
|
if err != nil {
|
|
_ = store.Close()
|
|
return nil, fmt.Errorf("read data file: %w", err)
|
|
}
|
|
if len(data) == 0 {
|
|
return store, nil
|
|
}
|
|
if err := json.Unmarshal(data, &store.state); err != nil {
|
|
_ = store.Close()
|
|
return nil, fmt.Errorf("decode data file: %w", err)
|
|
}
|
|
store.ensureMaps()
|
|
return store, nil
|
|
}
|
|
|
|
func (s *Store) Snapshot() PersistedState {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
return cloneState(s.state)
|
|
}
|
|
|
|
func (s *Store) Mutate(fn func(*PersistedState) error) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if s.closed {
|
|
return errors.New("store is closed")
|
|
}
|
|
s.ensureMaps()
|
|
if err := fn(&s.state); err != nil {
|
|
return err
|
|
}
|
|
return s.saveLocked()
|
|
}
|
|
|
|
func (s *Store) Read(fn func(PersistedState) error) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if s.closed {
|
|
return errors.New("store is closed")
|
|
}
|
|
return fn(cloneState(s.state))
|
|
}
|
|
|
|
// Close releases the single-writer lock. A second control-plane process can then
|
|
// be promoted by the supervisor using the same data directory.
|
|
func (s *Store) Close() error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if s.closed {
|
|
return nil
|
|
}
|
|
s.closed = true
|
|
if s.lockFile == nil {
|
|
return nil
|
|
}
|
|
unlockErr := syscall.Flock(int(s.lockFile.Fd()), syscall.LOCK_UN)
|
|
closeErr := s.lockFile.Close()
|
|
if unlockErr != nil {
|
|
return fmt.Errorf("unlock data file: %w", unlockErr)
|
|
}
|
|
if closeErr != nil {
|
|
return fmt.Errorf("close data lock: %w", closeErr)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Store) ensureMaps() {
|
|
if s.state.Nodes == nil {
|
|
s.state.Nodes = map[string]Node{}
|
|
}
|
|
if s.state.Tasks == nil {
|
|
s.state.Tasks = map[string]Task{}
|
|
}
|
|
if s.state.Events == nil {
|
|
s.state.Events = []StoredEvent{}
|
|
}
|
|
if s.state.Audit == nil {
|
|
s.state.Audit = []AuditEntry{}
|
|
}
|
|
if s.state.AIFlows == nil {
|
|
s.state.AIFlows = map[string]AIFlow{}
|
|
}
|
|
if s.state.AIRuns == nil {
|
|
s.state.AIRuns = map[string]AIRun{}
|
|
}
|
|
if s.state.AIPullTasks == nil {
|
|
s.state.AIPullTasks = map[string]AIPullTask{}
|
|
}
|
|
if s.state.AIMessageKeys == nil {
|
|
s.state.AIMessageKeys = map[string]string{}
|
|
}
|
|
}
|
|
|
|
func (s *Store) saveLocked() error {
|
|
if err := os.MkdirAll(filepath.Dir(s.path), 0o700); err != nil {
|
|
return fmt.Errorf("create data directory: %w", err)
|
|
}
|
|
s.pruneLocked(time.Now().UTC())
|
|
if err := s.backupLocked(); err != nil {
|
|
return err
|
|
}
|
|
data, err := json.MarshalIndent(s.state, "", " ")
|
|
if err != nil {
|
|
return fmt.Errorf("encode data file: %w", err)
|
|
}
|
|
temporary, err := os.CreateTemp(filepath.Dir(s.path), ".wxagent-control-plane-*")
|
|
if err != nil {
|
|
return fmt.Errorf("create temporary data file: %w", err)
|
|
}
|
|
temporaryName := temporary.Name()
|
|
defer os.Remove(temporaryName)
|
|
if err := temporary.Chmod(0o600); err != nil {
|
|
_ = temporary.Close()
|
|
return fmt.Errorf("protect temporary data file: %w", err)
|
|
}
|
|
if _, err := temporary.Write(data); err != nil {
|
|
_ = temporary.Close()
|
|
return fmt.Errorf("write data file: %w", err)
|
|
}
|
|
if err := temporary.Sync(); err != nil {
|
|
_ = temporary.Close()
|
|
return fmt.Errorf("sync data file: %w", err)
|
|
}
|
|
if err := temporary.Close(); err != nil {
|
|
return fmt.Errorf("close data file: %w", err)
|
|
}
|
|
if err := os.Rename(temporaryName, s.path); err != nil {
|
|
return fmt.Errorf("replace data file: %w", err)
|
|
}
|
|
return syncDirectory(filepath.Dir(s.path))
|
|
}
|
|
|
|
func (s *Store) pruneLocked(now time.Time) {
|
|
if s.options.TaskRetention > 0 {
|
|
cutoff := now.Add(-s.options.TaskRetention)
|
|
for id, task := range s.state.Tasks {
|
|
if terminal(task.Status) && !task.UpdatedAt.IsZero() && task.UpdatedAt.Before(cutoff) {
|
|
delete(s.state.Tasks, id)
|
|
}
|
|
}
|
|
}
|
|
if s.options.EventRetention > 0 {
|
|
cutoff := now.Add(-s.options.EventRetention)
|
|
kept := s.state.Events[:0]
|
|
for _, event := range s.state.Events {
|
|
if event.ReceivedAt.IsZero() || !event.ReceivedAt.Before(cutoff) {
|
|
kept = append(kept, event)
|
|
}
|
|
}
|
|
s.state.Events = kept
|
|
}
|
|
if s.options.AuditRetention > 0 {
|
|
cutoff := now.Add(-s.options.AuditRetention)
|
|
kept := s.state.Audit[:0]
|
|
for _, entry := range s.state.Audit {
|
|
if entry.At.IsZero() || !entry.At.Before(cutoff) {
|
|
kept = append(kept, entry)
|
|
}
|
|
}
|
|
s.state.Audit = kept
|
|
}
|
|
}
|
|
|
|
func (s *Store) backupLocked() error {
|
|
if s.options.BackupCount < 1 {
|
|
return nil
|
|
}
|
|
now := time.Now().UTC()
|
|
if s.options.BackupInterval > 0 && !s.lastBackup.IsZero() && now.Sub(s.lastBackup) < s.options.BackupInterval {
|
|
return nil
|
|
}
|
|
data, err := os.ReadFile(s.path)
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
return nil
|
|
}
|
|
if err != nil {
|
|
return fmt.Errorf("read current data for backup: %w", err)
|
|
}
|
|
if err := os.MkdirAll(s.options.BackupDir, 0o700); err != nil {
|
|
return fmt.Errorf("create backup directory: %w", err)
|
|
}
|
|
name := fmt.Sprintf("%s.%d.json", filepath.Base(s.path), time.Now().UTC().UnixNano())
|
|
backupPath := filepath.Join(s.options.BackupDir, name)
|
|
temporary, err := os.CreateTemp(s.options.BackupDir, ".wxagent-backup-*")
|
|
if err != nil {
|
|
return fmt.Errorf("create backup file: %w", err)
|
|
}
|
|
temporaryName := temporary.Name()
|
|
defer os.Remove(temporaryName)
|
|
if err := temporary.Chmod(0o600); err != nil {
|
|
_ = temporary.Close()
|
|
return fmt.Errorf("protect backup file: %w", err)
|
|
}
|
|
if _, err := temporary.Write(data); err != nil {
|
|
_ = temporary.Close()
|
|
return fmt.Errorf("write backup file: %w", err)
|
|
}
|
|
if err := temporary.Sync(); err != nil {
|
|
_ = temporary.Close()
|
|
return fmt.Errorf("sync backup file: %w", err)
|
|
}
|
|
if err := temporary.Close(); err != nil {
|
|
return fmt.Errorf("close backup file: %w", err)
|
|
}
|
|
if err := os.Rename(temporaryName, backupPath); err != nil {
|
|
return fmt.Errorf("publish backup file: %w", err)
|
|
}
|
|
if err := pruneBackups(s.options.BackupDir, filepath.Base(s.path), s.options.BackupCount); err != nil {
|
|
return fmt.Errorf("prune backups: %w", err)
|
|
}
|
|
if err := syncDirectory(s.options.BackupDir); err != nil {
|
|
return err
|
|
}
|
|
s.lastBackup = now
|
|
return nil
|
|
}
|
|
|
|
func pruneBackups(directory, base string, keep int) error {
|
|
entries, err := os.ReadDir(directory)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
type backup struct {
|
|
name string
|
|
when time.Time
|
|
}
|
|
backups := make([]backup, 0, len(entries))
|
|
prefix := base + "."
|
|
for _, entry := range entries {
|
|
if entry.IsDir() || !strings.HasPrefix(entry.Name(), prefix) || !strings.HasSuffix(entry.Name(), ".json") {
|
|
continue
|
|
}
|
|
info, err := entry.Info()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
backups = append(backups, backup{name: entry.Name(), when: info.ModTime()})
|
|
}
|
|
sort.Slice(backups, func(i, j int) bool {
|
|
if backups[i].when.Equal(backups[j].when) {
|
|
return backups[i].name > backups[j].name
|
|
}
|
|
return backups[i].when.After(backups[j].when)
|
|
})
|
|
if len(backups) <= keep {
|
|
return nil
|
|
}
|
|
for _, item := range backups[keep:] {
|
|
if err := os.Remove(filepath.Join(directory, item.name)); err != nil && !errors.Is(err, os.ErrNotExist) {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func syncDirectory(path string) error {
|
|
directory, err := os.Open(path)
|
|
if err != nil {
|
|
return fmt.Errorf("open directory for sync: %w", err)
|
|
}
|
|
defer directory.Close()
|
|
if err := directory.Sync(); err != nil && !errors.Is(err, syscall.EINVAL) {
|
|
return fmt.Errorf("sync directory: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func cloneState(source PersistedState) PersistedState {
|
|
data, _ := json.Marshal(source)
|
|
var copy PersistedState
|
|
_ = json.Unmarshal(data, ©)
|
|
if copy.Nodes == nil {
|
|
copy.Nodes = map[string]Node{}
|
|
}
|
|
if copy.Tasks == nil {
|
|
copy.Tasks = map[string]Task{}
|
|
}
|
|
if copy.Events == nil {
|
|
copy.Events = []StoredEvent{}
|
|
}
|
|
if copy.Audit == nil {
|
|
copy.Audit = []AuditEntry{}
|
|
}
|
|
return copy
|
|
}
|