132 lines
3.2 KiB
Go
132 lines
3.2 KiB
Go
package controlplane
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"sync"
|
|
)
|
|
|
|
// Store is a small durable JSON store for the single-process control-plane MVP.
|
|
// The file is an implementation detail; callers only observe transactional methods.
|
|
type Store struct {
|
|
path string
|
|
mu sync.Mutex
|
|
state PersistedState
|
|
}
|
|
|
|
func OpenStore(path string) (*Store, error) {
|
|
if path == "" {
|
|
return nil, errors.New("data file is required")
|
|
}
|
|
store := &Store{path: filepath.Clean(path), state: PersistedState{Nodes: map[string]Node{}, Tasks: map[string]Task{}, Events: []StoredEvent{}, Audit: []AuditEntry{}}}
|
|
data, err := os.ReadFile(store.path)
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
return store, nil
|
|
}
|
|
if err != nil {
|
|
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 {
|
|
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()
|
|
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()
|
|
return fn(cloneState(s.state))
|
|
}
|
|
|
|
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{}
|
|
}
|
|
}
|
|
|
|
func (s *Store) saveLocked() error {
|
|
if err := os.MkdirAll(filepath.Dir(s.path), 0o700); err != nil {
|
|
return fmt.Errorf("create data directory: %w", 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 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
|
|
}
|