|
17e2c1d…
|
lmata
|
1 |
package api |
|
17e2c1d…
|
lmata
|
2 |
|
|
17e2c1d…
|
lmata
|
3 |
import ( |
|
17e2c1d…
|
lmata
|
4 |
"fmt" |
|
17e2c1d…
|
lmata
|
5 |
"os" |
|
17e2c1d…
|
lmata
|
6 |
"path/filepath" |
|
17e2c1d…
|
lmata
|
7 |
"sync" |
|
17e2c1d…
|
lmata
|
8 |
|
|
17e2c1d…
|
lmata
|
9 |
"github.com/conflicthq/scuttlebot/internal/config" |
|
17e2c1d…
|
lmata
|
10 |
) |
|
17e2c1d…
|
lmata
|
11 |
|
|
17e2c1d…
|
lmata
|
12 |
// ConfigStore holds the running config and knows how to write it back to disk |
|
17e2c1d…
|
lmata
|
13 |
// with history snapshots. It is the single write path for all config mutations. |
|
17e2c1d…
|
lmata
|
14 |
type ConfigStore struct { |
|
17e2c1d…
|
lmata
|
15 |
mu sync.RWMutex |
|
17e2c1d…
|
lmata
|
16 |
cfg config.Config |
|
17e2c1d…
|
lmata
|
17 |
path string // absolute path to scuttlebot.yaml |
|
17e2c1d…
|
lmata
|
18 |
historyDir string // where snapshots land |
|
17e2c1d…
|
lmata
|
19 |
onChange []func(config.Config) |
|
17e2c1d…
|
lmata
|
20 |
} |
|
17e2c1d…
|
lmata
|
21 |
|
|
17e2c1d…
|
lmata
|
22 |
// NewConfigStore creates a ConfigStore for the given config file path. |
|
17e2c1d…
|
lmata
|
23 |
// The initial config value is copied in. |
|
17e2c1d…
|
lmata
|
24 |
func NewConfigStore(path string, cfg config.Config) *ConfigStore { |
|
17e2c1d…
|
lmata
|
25 |
histDir := cfg.History.Dir |
|
17e2c1d…
|
lmata
|
26 |
if histDir == "" { |
|
17e2c1d…
|
lmata
|
27 |
histDir = filepath.Join(cfg.Ergo.DataDir, "config-history") |
|
17e2c1d…
|
lmata
|
28 |
} |
|
17e2c1d…
|
lmata
|
29 |
return &ConfigStore{ |
|
17e2c1d…
|
lmata
|
30 |
cfg: cfg, |
|
17e2c1d…
|
lmata
|
31 |
path: path, |
|
17e2c1d…
|
lmata
|
32 |
historyDir: histDir, |
|
17e2c1d…
|
lmata
|
33 |
} |
|
17e2c1d…
|
lmata
|
34 |
} |
|
17e2c1d…
|
lmata
|
35 |
|
|
17e2c1d…
|
lmata
|
36 |
// Get returns a copy of the current config. |
|
17e2c1d…
|
lmata
|
37 |
func (s *ConfigStore) Get() config.Config { |
|
17e2c1d…
|
lmata
|
38 |
s.mu.RLock() |
|
17e2c1d…
|
lmata
|
39 |
defer s.mu.RUnlock() |
|
17e2c1d…
|
lmata
|
40 |
return s.cfg |
|
17e2c1d…
|
lmata
|
41 |
} |
|
17e2c1d…
|
lmata
|
42 |
|
|
17e2c1d…
|
lmata
|
43 |
// OnChange registers a callback invoked (in a new goroutine) after every |
|
17e2c1d…
|
lmata
|
44 |
// successful Save. Multiple callbacks are called concurrently. |
|
17e2c1d…
|
lmata
|
45 |
func (s *ConfigStore) OnChange(fn func(config.Config)) { |
|
17e2c1d…
|
lmata
|
46 |
s.mu.Lock() |
|
17e2c1d…
|
lmata
|
47 |
defer s.mu.Unlock() |
|
17e2c1d…
|
lmata
|
48 |
s.onChange = append(s.onChange, fn) |
|
17e2c1d…
|
lmata
|
49 |
} |
|
17e2c1d…
|
lmata
|
50 |
|
|
17e2c1d…
|
lmata
|
51 |
// Save snapshots the current file, writes next to disk, updates the in-memory |
|
17e2c1d…
|
lmata
|
52 |
// copy, and fires all OnChange callbacks. |
|
17e2c1d…
|
lmata
|
53 |
func (s *ConfigStore) Save(next config.Config) error { |
|
17e2c1d…
|
lmata
|
54 |
s.mu.Lock() |
|
17e2c1d…
|
lmata
|
55 |
defer s.mu.Unlock() |
|
17e2c1d…
|
lmata
|
56 |
|
|
17e2c1d…
|
lmata
|
57 |
keep := next.History.Keep |
|
17e2c1d…
|
lmata
|
58 |
if keep == 0 { |
|
17e2c1d…
|
lmata
|
59 |
keep = 20 // safety fallback; keep=0 in config means "use default" |
|
17e2c1d…
|
lmata
|
60 |
} |
|
17e2c1d…
|
lmata
|
61 |
|
|
17e2c1d…
|
lmata
|
62 |
// Snapshot before overwrite (no-op if file doesn't exist yet). |
|
17e2c1d…
|
lmata
|
63 |
if err := config.SnapshotConfig(s.historyDir, s.path); err != nil { |
|
17e2c1d…
|
lmata
|
64 |
return fmt.Errorf("config store: snapshot: %w", err) |
|
17e2c1d…
|
lmata
|
65 |
} |
|
17e2c1d…
|
lmata
|
66 |
|
|
17e2c1d…
|
lmata
|
67 |
// Write the new config to disk. |
|
17e2c1d…
|
lmata
|
68 |
if err := next.Save(s.path); err != nil { |
|
17e2c1d…
|
lmata
|
69 |
return fmt.Errorf("config store: save: %w", err) |
|
17e2c1d…
|
lmata
|
70 |
} |
|
17e2c1d…
|
lmata
|
71 |
|
|
17e2c1d…
|
lmata
|
72 |
// Prune history to keep entries. |
|
17e2c1d…
|
lmata
|
73 |
base := filepath.Base(s.path) |
|
17e2c1d…
|
lmata
|
74 |
if err := config.PruneHistory(s.historyDir, base, keep); err != nil { |
|
17e2c1d…
|
lmata
|
75 |
// Non-fatal: log would be nice but we don't have a logger here. |
|
17e2c1d…
|
lmata
|
76 |
// Callers can surface this separately. |
|
17e2c1d…
|
lmata
|
77 |
_ = err |
|
17e2c1d…
|
lmata
|
78 |
} |
|
17e2c1d…
|
lmata
|
79 |
|
|
17e2c1d…
|
lmata
|
80 |
s.cfg = next |
|
17e2c1d…
|
lmata
|
81 |
|
|
17e2c1d…
|
lmata
|
82 |
// Fire callbacks outside the lock in fresh goroutines. |
|
17e2c1d…
|
lmata
|
83 |
cbs := make([]func(config.Config), len(s.onChange)) |
|
17e2c1d…
|
lmata
|
84 |
copy(cbs, s.onChange) |
|
17e2c1d…
|
lmata
|
85 |
for _, fn := range cbs { |
|
17e2c1d…
|
lmata
|
86 |
go fn(next) |
|
17e2c1d…
|
lmata
|
87 |
} |
|
17e2c1d…
|
lmata
|
88 |
return nil |
|
17e2c1d…
|
lmata
|
89 |
} |
|
17e2c1d…
|
lmata
|
90 |
|
|
17e2c1d…
|
lmata
|
91 |
// ListHistory returns the snapshots for the managed config file. |
|
17e2c1d…
|
lmata
|
92 |
func (s *ConfigStore) ListHistory() ([]config.HistoryEntry, error) { |
|
17e2c1d…
|
lmata
|
93 |
s.mu.RLock() |
|
17e2c1d…
|
lmata
|
94 |
histDir := s.historyDir |
|
17e2c1d…
|
lmata
|
95 |
path := s.path |
|
17e2c1d…
|
lmata
|
96 |
s.mu.RUnlock() |
|
17e2c1d…
|
lmata
|
97 |
base := filepath.Base(path) |
|
17e2c1d…
|
lmata
|
98 |
return config.ListHistory(histDir, base) |
|
17e2c1d…
|
lmata
|
99 |
} |
|
17e2c1d…
|
lmata
|
100 |
|
|
17e2c1d…
|
lmata
|
101 |
// ReadHistoryFile returns the raw bytes of a snapshot by filename. |
|
17e2c1d…
|
lmata
|
102 |
func (s *ConfigStore) ReadHistoryFile(filename string) ([]byte, error) { |
|
17e2c1d…
|
lmata
|
103 |
s.mu.RLock() |
|
17e2c1d…
|
lmata
|
104 |
histDir := s.historyDir |
|
17e2c1d…
|
lmata
|
105 |
s.mu.RUnlock() |
|
17e2c1d…
|
lmata
|
106 |
// Sanitize: only allow simple filenames (no path separators). |
|
17e2c1d…
|
lmata
|
107 |
if filepath.Base(filename) != filename { |
|
17e2c1d…
|
lmata
|
108 |
return nil, fmt.Errorf("config store: invalid history filename") |
|
17e2c1d…
|
lmata
|
109 |
} |
|
17e2c1d…
|
lmata
|
110 |
return os.ReadFile(filepath.Join(histDir, filename)) |
|
17e2c1d…
|
lmata
|
111 |
} |