ScuttleBot
feat(#43): unified config store — write-through to scuttlebot.yaml with history - Config.Save(path): marshal+atomic-write via tmp rename - Duration.MarshalYAML: round-trips "72h" format so topology TTLs survive save/load - Config.LoadFromBytes: extracted from LoadFile for reuse in history endpoint - ConfigHistoryConfig{Keep, Dir}: new config section (default keep=20) - internal/config/history.go: SnapshotConfig, PruneHistory, ListHistory — timestamped snapshots in data/config-history/, oldest pruned to keep limit - internal/api/ConfigStore: thread-safe store holding running config + write path — Save() snapshots, writes file, prunes history, fires onChange callbacks - GET /v1/config: masked JSON view of running config (secrets omitted) - PUT /v1/config: partial update → snapshot → save → hot-reload callbacks — topology and bridge.web_user_ttl hot-reload; restart-required sections flagged - GET /v1/config/history: list snapshots - GET /v1/config/history/{filename}: fetch snapshot as masked JSON - Wire ConfigStore + topology/bridge hot-reload in cmd/scuttlebot/main.go - Tests: round-trip, snapshot/prune, GET/PUT /v1/config, history list
17e2c1d1e649742ac0d54db85ef4d3db84500899e2335f72c3457de1261919c8
| --- cmd/scuttlebot/main.go | ||
| +++ cmd/scuttlebot/main.go | ||
| @@ -259,17 +259,39 @@ | ||
| 259 | 259 | Config: b.Config, |
| 260 | 260 | } |
| 261 | 261 | } |
| 262 | 262 | botMgr.Sync(ctx, specs) |
| 263 | 263 | } |
| 264 | + | |
| 265 | + // Config store — owns write-back to scuttlebot.yaml with history snapshots. | |
| 266 | + cfgStore := api.NewConfigStore(*configPath, *cfg) | |
| 267 | + cfgStore.OnChange(func(updated config.Config) { | |
| 268 | + // Hot-reload topology on config change. | |
| 269 | + if topoMgr != nil { | |
| 270 | + staticChannels := make([]topology.ChannelConfig, 0, len(updated.Topology.Channels)) | |
| 271 | + for _, sc := range updated.Topology.Channels { | |
| 272 | + staticChannels = append(staticChannels, topology.ChannelConfig{ | |
| 273 | + Name: sc.Name, Topic: sc.Topic, | |
| 274 | + Ops: sc.Ops, Voice: sc.Voice, Autojoin: sc.Autojoin, | |
| 275 | + }) | |
| 276 | + } | |
| 277 | + if err := topoMgr.Provision(staticChannels); err != nil { | |
| 278 | + log.Error("topology hot-reload failed", "err", err) | |
| 279 | + } | |
| 280 | + } | |
| 281 | + // Hot-reload bridge web TTL. | |
| 282 | + if bridgeBot != nil { | |
| 283 | + bridgeBot.SetWebUserTTL(time.Duration(updated.Bridge.WebUserTTLMinutes) * time.Minute) | |
| 284 | + } | |
| 285 | + }) | |
| 264 | 286 | |
| 265 | 287 | // Start HTTP REST API server. |
| 266 | 288 | var llmCfg *config.LLMConfig |
| 267 | 289 | if len(cfg.LLM.Backends) > 0 { |
| 268 | 290 | llmCfg = &cfg.LLM |
| 269 | 291 | } |
| 270 | - apiSrv := api.New(reg, tokens, bridgeBot, policyStore, adminStore, llmCfg, topoMgr, cfg.TLS.Domain, log) | |
| 292 | + apiSrv := api.New(reg, tokens, bridgeBot, policyStore, adminStore, llmCfg, topoMgr, cfgStore, cfg.TLS.Domain, log) | |
| 271 | 293 | handler := apiSrv.Handler() |
| 272 | 294 | |
| 273 | 295 | var httpServer, tlsServer *http.Server |
| 274 | 296 | |
| 275 | 297 | if cfg.TLS.Domain != "" { |
| 276 | 298 |
| --- cmd/scuttlebot/main.go | |
| +++ cmd/scuttlebot/main.go | |
| @@ -259,17 +259,39 @@ | |
| 259 | Config: b.Config, |
| 260 | } |
| 261 | } |
| 262 | botMgr.Sync(ctx, specs) |
| 263 | } |
| 264 | |
| 265 | // Start HTTP REST API server. |
| 266 | var llmCfg *config.LLMConfig |
| 267 | if len(cfg.LLM.Backends) > 0 { |
| 268 | llmCfg = &cfg.LLM |
| 269 | } |
| 270 | apiSrv := api.New(reg, tokens, bridgeBot, policyStore, adminStore, llmCfg, topoMgr, cfg.TLS.Domain, log) |
| 271 | handler := apiSrv.Handler() |
| 272 | |
| 273 | var httpServer, tlsServer *http.Server |
| 274 | |
| 275 | if cfg.TLS.Domain != "" { |
| 276 |
| --- cmd/scuttlebot/main.go | |
| +++ cmd/scuttlebot/main.go | |
| @@ -259,17 +259,39 @@ | |
| 259 | Config: b.Config, |
| 260 | } |
| 261 | } |
| 262 | botMgr.Sync(ctx, specs) |
| 263 | } |
| 264 | |
| 265 | // Config store — owns write-back to scuttlebot.yaml with history snapshots. |
| 266 | cfgStore := api.NewConfigStore(*configPath, *cfg) |
| 267 | cfgStore.OnChange(func(updated config.Config) { |
| 268 | // Hot-reload topology on config change. |
| 269 | if topoMgr != nil { |
| 270 | staticChannels := make([]topology.ChannelConfig, 0, len(updated.Topology.Channels)) |
| 271 | for _, sc := range updated.Topology.Channels { |
| 272 | staticChannels = append(staticChannels, topology.ChannelConfig{ |
| 273 | Name: sc.Name, Topic: sc.Topic, |
| 274 | Ops: sc.Ops, Voice: sc.Voice, Autojoin: sc.Autojoin, |
| 275 | }) |
| 276 | } |
| 277 | if err := topoMgr.Provision(staticChannels); err != nil { |
| 278 | log.Error("topology hot-reload failed", "err", err) |
| 279 | } |
| 280 | } |
| 281 | // Hot-reload bridge web TTL. |
| 282 | if bridgeBot != nil { |
| 283 | bridgeBot.SetWebUserTTL(time.Duration(updated.Bridge.WebUserTTLMinutes) * time.Minute) |
| 284 | } |
| 285 | }) |
| 286 | |
| 287 | // Start HTTP REST API server. |
| 288 | var llmCfg *config.LLMConfig |
| 289 | if len(cfg.LLM.Backends) > 0 { |
| 290 | llmCfg = &cfg.LLM |
| 291 | } |
| 292 | apiSrv := api.New(reg, tokens, bridgeBot, policyStore, adminStore, llmCfg, topoMgr, cfgStore, cfg.TLS.Domain, log) |
| 293 | handler := apiSrv.Handler() |
| 294 | |
| 295 | var httpServer, tlsServer *http.Server |
| 296 | |
| 297 | if cfg.TLS.Domain != "" { |
| 298 |
| --- internal/api/api_test.go | ||
| +++ internal/api/api_test.go | ||
| @@ -50,11 +50,11 @@ | ||
| 50 | 50 | const testToken = "test-api-token-abc123" |
| 51 | 51 | |
| 52 | 52 | func newTestServer(t *testing.T) *httptest.Server { |
| 53 | 53 | t.Helper() |
| 54 | 54 | reg := registry.New(newMock(), []byte("test-signing-key")) |
| 55 | - srv := api.New(reg, []string{testToken}, nil, nil, nil, nil, nil, "", testLog) | |
| 55 | + srv := api.New(reg, []string{testToken}, nil, nil, nil, nil, nil, nil, "", testLog) | |
| 56 | 56 | return httptest.NewServer(srv.Handler()) |
| 57 | 57 | } |
| 58 | 58 | |
| 59 | 59 | func authHeader() http.Header { |
| 60 | 60 | h := http.Header{} |
| 61 | 61 |
| --- internal/api/api_test.go | |
| +++ internal/api/api_test.go | |
| @@ -50,11 +50,11 @@ | |
| 50 | const testToken = "test-api-token-abc123" |
| 51 | |
| 52 | func newTestServer(t *testing.T) *httptest.Server { |
| 53 | t.Helper() |
| 54 | reg := registry.New(newMock(), []byte("test-signing-key")) |
| 55 | srv := api.New(reg, []string{testToken}, nil, nil, nil, nil, nil, "", testLog) |
| 56 | return httptest.NewServer(srv.Handler()) |
| 57 | } |
| 58 | |
| 59 | func authHeader() http.Header { |
| 60 | h := http.Header{} |
| 61 |
| --- internal/api/api_test.go | |
| +++ internal/api/api_test.go | |
| @@ -50,11 +50,11 @@ | |
| 50 | const testToken = "test-api-token-abc123" |
| 51 | |
| 52 | func newTestServer(t *testing.T) *httptest.Server { |
| 53 | t.Helper() |
| 54 | reg := registry.New(newMock(), []byte("test-signing-key")) |
| 55 | srv := api.New(reg, []string{testToken}, nil, nil, nil, nil, nil, nil, "", testLog) |
| 56 | return httptest.NewServer(srv.Handler()) |
| 57 | } |
| 58 | |
| 59 | func authHeader() http.Header { |
| 60 | h := http.Header{} |
| 61 |
| --- internal/api/channels_topology_test.go | ||
| +++ internal/api/channels_topology_test.go | ||
| @@ -34,11 +34,11 @@ | ||
| 34 | 34 | |
| 35 | 35 | func newTopoTestServer(t *testing.T, topo *stubTopologyManager) (*httptest.Server, string) { |
| 36 | 36 | t.Helper() |
| 37 | 37 | reg := registry.New(nil, []byte("key")) |
| 38 | 38 | log := slog.New(slog.NewTextHandler(io.Discard, nil)) |
| 39 | - srv := httptest.NewServer(New(reg, []string{"tok"}, nil, nil, nil, nil, topo, "", log).Handler()) | |
| 39 | + srv := httptest.NewServer(New(reg, []string{"tok"}, nil, nil, nil, nil, topo, nil, "", log).Handler()) | |
| 40 | 40 | t.Cleanup(srv.Close) |
| 41 | 41 | return srv, "tok" |
| 42 | 42 | } |
| 43 | 43 | |
| 44 | 44 | func TestHandleProvisionChannel(t *testing.T) { |
| 45 | 45 |
| --- internal/api/channels_topology_test.go | |
| +++ internal/api/channels_topology_test.go | |
| @@ -34,11 +34,11 @@ | |
| 34 | |
| 35 | func newTopoTestServer(t *testing.T, topo *stubTopologyManager) (*httptest.Server, string) { |
| 36 | t.Helper() |
| 37 | reg := registry.New(nil, []byte("key")) |
| 38 | log := slog.New(slog.NewTextHandler(io.Discard, nil)) |
| 39 | srv := httptest.NewServer(New(reg, []string{"tok"}, nil, nil, nil, nil, topo, "", log).Handler()) |
| 40 | t.Cleanup(srv.Close) |
| 41 | return srv, "tok" |
| 42 | } |
| 43 | |
| 44 | func TestHandleProvisionChannel(t *testing.T) { |
| 45 |
| --- internal/api/channels_topology_test.go | |
| +++ internal/api/channels_topology_test.go | |
| @@ -34,11 +34,11 @@ | |
| 34 | |
| 35 | func newTopoTestServer(t *testing.T, topo *stubTopologyManager) (*httptest.Server, string) { |
| 36 | t.Helper() |
| 37 | reg := registry.New(nil, []byte("key")) |
| 38 | log := slog.New(slog.NewTextHandler(io.Discard, nil)) |
| 39 | srv := httptest.NewServer(New(reg, []string{"tok"}, nil, nil, nil, nil, topo, nil, "", log).Handler()) |
| 40 | t.Cleanup(srv.Close) |
| 41 | return srv, "tok" |
| 42 | } |
| 43 | |
| 44 | func TestHandleProvisionChannel(t *testing.T) { |
| 45 |
| --- internal/api/chat_test.go | ||
| +++ internal/api/chat_test.go | ||
| @@ -39,11 +39,11 @@ | ||
| 39 | 39 | t.Helper() |
| 40 | 40 | |
| 41 | 41 | bridgeStub := &stubChatBridge{} |
| 42 | 42 | reg := registry.New(nil, []byte("test-signing-key")) |
| 43 | 43 | logger := slog.New(slog.NewTextHandler(io.Discard, nil)) |
| 44 | - srv := httptest.NewServer(New(reg, []string{"token"}, bridgeStub, nil, nil, nil, nil, "", logger).Handler()) | |
| 44 | + srv := httptest.NewServer(New(reg, []string{"token"}, bridgeStub, nil, nil, nil, nil, nil, "", logger).Handler()) | |
| 45 | 45 | defer srv.Close() |
| 46 | 46 | |
| 47 | 47 | body, _ := json.Marshal(map[string]string{"nick": "codex-test"}) |
| 48 | 48 | req, err := http.NewRequest(http.MethodPost, srv.URL+"/v1/channels/general/presence", bytes.NewReader(body)) |
| 49 | 49 | if err != nil { |
| @@ -72,11 +72,11 @@ | ||
| 72 | 72 | t.Helper() |
| 73 | 73 | |
| 74 | 74 | bridgeStub := &stubChatBridge{} |
| 75 | 75 | reg := registry.New(nil, []byte("test-signing-key")) |
| 76 | 76 | logger := slog.New(slog.NewTextHandler(io.Discard, nil)) |
| 77 | - srv := httptest.NewServer(New(reg, []string{"token"}, bridgeStub, nil, nil, nil, nil, "", logger).Handler()) | |
| 77 | + srv := httptest.NewServer(New(reg, []string{"token"}, bridgeStub, nil, nil, nil, nil, nil, "", logger).Handler()) | |
| 78 | 78 | defer srv.Close() |
| 79 | 79 | |
| 80 | 80 | body, _ := json.Marshal(map[string]string{}) |
| 81 | 81 | req, err := http.NewRequest(http.MethodPost, srv.URL+"/v1/channels/general/presence", bytes.NewReader(body)) |
| 82 | 82 | if err != nil { |
| 83 | 83 | |
| 84 | 84 | ADDED internal/api/config_handlers.go |
| 85 | 85 | ADDED internal/api/config_handlers_test.go |
| 86 | 86 | ADDED internal/api/config_store.go |
| --- internal/api/chat_test.go | |
| +++ internal/api/chat_test.go | |
| @@ -39,11 +39,11 @@ | |
| 39 | t.Helper() |
| 40 | |
| 41 | bridgeStub := &stubChatBridge{} |
| 42 | reg := registry.New(nil, []byte("test-signing-key")) |
| 43 | logger := slog.New(slog.NewTextHandler(io.Discard, nil)) |
| 44 | srv := httptest.NewServer(New(reg, []string{"token"}, bridgeStub, nil, nil, nil, nil, "", logger).Handler()) |
| 45 | defer srv.Close() |
| 46 | |
| 47 | body, _ := json.Marshal(map[string]string{"nick": "codex-test"}) |
| 48 | req, err := http.NewRequest(http.MethodPost, srv.URL+"/v1/channels/general/presence", bytes.NewReader(body)) |
| 49 | if err != nil { |
| @@ -72,11 +72,11 @@ | |
| 72 | t.Helper() |
| 73 | |
| 74 | bridgeStub := &stubChatBridge{} |
| 75 | reg := registry.New(nil, []byte("test-signing-key")) |
| 76 | logger := slog.New(slog.NewTextHandler(io.Discard, nil)) |
| 77 | srv := httptest.NewServer(New(reg, []string{"token"}, bridgeStub, nil, nil, nil, nil, "", logger).Handler()) |
| 78 | defer srv.Close() |
| 79 | |
| 80 | body, _ := json.Marshal(map[string]string{}) |
| 81 | req, err := http.NewRequest(http.MethodPost, srv.URL+"/v1/channels/general/presence", bytes.NewReader(body)) |
| 82 | if err != nil { |
| 83 | |
| 84 | DDED internal/api/config_handlers.go |
| 85 | DDED internal/api/config_handlers_test.go |
| 86 | DDED internal/api/config_store.go |
| --- internal/api/chat_test.go | |
| +++ internal/api/chat_test.go | |
| @@ -39,11 +39,11 @@ | |
| 39 | t.Helper() |
| 40 | |
| 41 | bridgeStub := &stubChatBridge{} |
| 42 | reg := registry.New(nil, []byte("test-signing-key")) |
| 43 | logger := slog.New(slog.NewTextHandler(io.Discard, nil)) |
| 44 | srv := httptest.NewServer(New(reg, []string{"token"}, bridgeStub, nil, nil, nil, nil, nil, "", logger).Handler()) |
| 45 | defer srv.Close() |
| 46 | |
| 47 | body, _ := json.Marshal(map[string]string{"nick": "codex-test"}) |
| 48 | req, err := http.NewRequest(http.MethodPost, srv.URL+"/v1/channels/general/presence", bytes.NewReader(body)) |
| 49 | if err != nil { |
| @@ -72,11 +72,11 @@ | |
| 72 | t.Helper() |
| 73 | |
| 74 | bridgeStub := &stubChatBridge{} |
| 75 | reg := registry.New(nil, []byte("test-signing-key")) |
| 76 | logger := slog.New(slog.NewTextHandler(io.Discard, nil)) |
| 77 | srv := httptest.NewServer(New(reg, []string{"token"}, bridgeStub, nil, nil, nil, nil, nil, "", logger).Handler()) |
| 78 | defer srv.Close() |
| 79 | |
| 80 | body, _ := json.Marshal(map[string]string{}) |
| 81 | req, err := http.NewRequest(http.MethodPost, srv.URL+"/v1/channels/general/presence", bytes.NewReader(body)) |
| 82 | if err != nil { |
| 83 | |
| 84 | DDED internal/api/config_handlers.go |
| 85 | DDED internal/api/config_handlers_test.go |
| 86 | DDED internal/api/config_store.go |
| --- a/internal/api/config_handlers.go | ||
| +++ b/internal/api/config_handlers.go | ||
| @@ -0,0 +1,28 @@ | ||
| 1 | +package api | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "encoding/json" | |
| 5 | + "net/http" | |
| 6 | + "path/filepath" | |
| 7 | + | |
| 8 | + "github.com/conflicthq/scuttlebot/internal/config" | |
| 9 | +) | |
| 10 | + | |
| 11 | +// configView is the JSON shape returned by GET /v1/config. | |
| 12 | +// Secrets are masked — zero values mean "no change" on PUT. | |
| 13 | +type configView struct { | |
| 14 | + APIAddr string string string `json:"mcp_addr"` | |
| 15 | + Bridge bridgeConfigView bridgeConfigView ergoConfigView `json:"ergo"` | |
| 16 | + TLS `json:"ergo"` | |
| 17 | + TLS `json:"tls"` | |
| 18 | + LLM llmConfigView `json:"llm"` | |
| 19 | + Topology UT | |
| 20 | +} | |
| 21 | + | |
| 22 | +func configToView(`json:"topology"` | |
| 23 | + History make([]llmBackendView"` | |
| 24 | +} | |
| 25 | + | |
| 26 | +type bridgeConfigView struct APIAddr and APITokecfg.Topology, | |
| 27 | + cfg.History, | |
| 28 | + }M@23G,6p@pW,S@wN,b@wm,S@xQ,d@xp,S@yV,1: M@Ay,N@zG,S@zf,C: `json:"llm2w@13U,6:bridgeN@16T,I@Dl,Sr@1Dl,Rh@1vO,H20W9; |
| --- a/internal/api/config_handlers.go | |
| +++ b/internal/api/config_handlers.go | |
| @@ -0,0 +1,28 @@ | |
| --- a/internal/api/config_handlers.go | |
| +++ b/internal/api/config_handlers.go | |
| @@ -0,0 +1,28 @@ | |
| 1 | package api |
| 2 | |
| 3 | import ( |
| 4 | "encoding/json" |
| 5 | "net/http" |
| 6 | "path/filepath" |
| 7 | |
| 8 | "github.com/conflicthq/scuttlebot/internal/config" |
| 9 | ) |
| 10 | |
| 11 | // configView is the JSON shape returned by GET /v1/config. |
| 12 | // Secrets are masked — zero values mean "no change" on PUT. |
| 13 | type configView struct { |
| 14 | APIAddr string string string `json:"mcp_addr"` |
| 15 | Bridge bridgeConfigView bridgeConfigView ergoConfigView `json:"ergo"` |
| 16 | TLS `json:"ergo"` |
| 17 | TLS `json:"tls"` |
| 18 | LLM llmConfigView `json:"llm"` |
| 19 | Topology UT |
| 20 | } |
| 21 | |
| 22 | func configToView(`json:"topology"` |
| 23 | History make([]llmBackendView"` |
| 24 | } |
| 25 | |
| 26 | type bridgeConfigView struct APIAddr and APITokecfg.Topology, |
| 27 | cfg.History, |
| 28 | }M@23G,6p@pW,S@wN,b@wm,S@xQ,d@xp,S@yV,1: M@Ay,N@zG,S@zf,C: `json:"llm2w@13U,6:bridgeN@16T,I@Dl,Sr@1Dl,Rh@1vO,H20W9; |
| --- a/internal/api/config_handlers_test.go | ||
| +++ b/internal/api/config_handlers_test.go | ||
| @@ -0,0 +1,27 @@ | ||
| 1 | +:= json.Marshal(upody, _ := json.Marshal(update) | |
| 2 | + req, _ := http.NewRequest(http.MethodPut, srv.URL+"/v1/config", bytes.NewReader(body)) | |
| 3 | + req.Header.Set("Authorization", "Bearer tok") | |
| 4 | + req.Header.Set("Content-Type", "application/json") | |
| 5 | + resp, err := http.DefaultClient.Do(req) | |
| 6 | + if err != nil { | |
| 7 | + t.Fatal(err) | |
| 8 | + } | |
| 9 | + defer resp.Body.Close() | |
| 10 | + if resp.StatusCode != http.StatusOK { | |
| 11 | + t.Fatalf("want 200, got %d", resp.StatusCode) | |
| 12 | + } | |
| 13 | + | |
| 14 | + got := store.Get() | |
| 15 | + if !got.Logging.Enabled { | |
| 16 | + t.Error("logging.enabled should be true") | |
| 17 | + } | |
| 18 | + if got.Logging.Dir != "./data/logs" { | |
| 19 | + t.Errorf("logging.dir = %q, want ./data/logs", got.Logging.Dir) | |
| 20 | + } | |
| 21 | + if got.Logging.Format != "jsonl" { | |
| 22 | + t.Errorf("logging.format = %q, want jsonl", got.Logging.Format) | |
| 23 | + } | |
| 24 | + if got.Logging.Rotation != "daily" { | |
| 25 | + t.Errorf("logging.rotation = %q, want daily", got.Logging.Rotation) | |
| 26 | + } | |
| 27 | + if !got.Logging.PerCh |
| --- a/internal/api/config_handlers_test.go | |
| +++ b/internal/api/config_handlers_test.go | |
| @@ -0,0 +1,27 @@ | |
| --- a/internal/api/config_handlers_test.go | |
| +++ b/internal/api/config_handlers_test.go | |
| @@ -0,0 +1,27 @@ | |
| 1 | := json.Marshal(upody, _ := json.Marshal(update) |
| 2 | req, _ := http.NewRequest(http.MethodPut, srv.URL+"/v1/config", bytes.NewReader(body)) |
| 3 | req.Header.Set("Authorization", "Bearer tok") |
| 4 | req.Header.Set("Content-Type", "application/json") |
| 5 | resp, err := http.DefaultClient.Do(req) |
| 6 | if err != nil { |
| 7 | t.Fatal(err) |
| 8 | } |
| 9 | defer resp.Body.Close() |
| 10 | if resp.StatusCode != http.StatusOK { |
| 11 | t.Fatalf("want 200, got %d", resp.StatusCode) |
| 12 | } |
| 13 | |
| 14 | got := store.Get() |
| 15 | if !got.Logging.Enabled { |
| 16 | t.Error("logging.enabled should be true") |
| 17 | } |
| 18 | if got.Logging.Dir != "./data/logs" { |
| 19 | t.Errorf("logging.dir = %q, want ./data/logs", got.Logging.Dir) |
| 20 | } |
| 21 | if got.Logging.Format != "jsonl" { |
| 22 | t.Errorf("logging.format = %q, want jsonl", got.Logging.Format) |
| 23 | } |
| 24 | if got.Logging.Rotation != "daily" { |
| 25 | t.Errorf("logging.rotation = %q, want daily", got.Logging.Rotation) |
| 26 | } |
| 27 | if !got.Logging.PerCh |
| --- a/internal/api/config_store.go | ||
| +++ b/internal/api/config_store.go | ||
| @@ -0,0 +1,111 @@ | ||
| 1 | +package api | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "fmt" | |
| 5 | + "os" | |
| 6 | + "path/filepath" | |
| 7 | + "sync" | |
| 8 | + | |
| 9 | + "github.com/conflicthq/scuttlebot/internal/config" | |
| 10 | +) | |
| 11 | + | |
| 12 | +// ConfigStore holds the running config and knows how to write it back to disk | |
| 13 | +// with history snapshots. It is the single write path for all config mutations. | |
| 14 | +type ConfigStore struct { | |
| 15 | + mu sync.RWMutex | |
| 16 | + cfg config.Config | |
| 17 | + path string // absolute path to scuttlebot.yaml | |
| 18 | + historyDir string // where snapshots land | |
| 19 | + onChange []func(config.Config) | |
| 20 | +} | |
| 21 | + | |
| 22 | +// NewConfigStore creates a ConfigStore for the given config file path. | |
| 23 | +// The initial config value is copied in. | |
| 24 | +func NewConfigStore(path string, cfg config.Config) *ConfigStore { | |
| 25 | + histDir := cfg.History.Dir | |
| 26 | + if histDir == "" { | |
| 27 | + histDir = filepath.Join(cfg.Ergo.DataDir, "config-history") | |
| 28 | + } | |
| 29 | + return &ConfigStore{ | |
| 30 | + cfg: cfg, | |
| 31 | + path: path, | |
| 32 | + historyDir: histDir, | |
| 33 | + } | |
| 34 | +} | |
| 35 | + | |
| 36 | +// Get returns a copy of the current config. | |
| 37 | +func (s *ConfigStore) Get() config.Config { | |
| 38 | + s.mu.RLock() | |
| 39 | + defer s.mu.RUnlock() | |
| 40 | + return s.cfg | |
| 41 | +} | |
| 42 | + | |
| 43 | +// OnChange registers a callback invoked (in a new goroutine) after every | |
| 44 | +// successful Save. Multiple callbacks are called concurrently. | |
| 45 | +func (s *ConfigStore) OnChange(fn func(config.Config)) { | |
| 46 | + s.mu.Lock() | |
| 47 | + defer s.mu.Unlock() | |
| 48 | + s.onChange = append(s.onChange, fn) | |
| 49 | +} | |
| 50 | + | |
| 51 | +// Save snapshots the current file, writes next to disk, updates the in-memory | |
| 52 | +// copy, and fires all OnChange callbacks. | |
| 53 | +func (s *ConfigStore) Save(next config.Config) error { | |
| 54 | + s.mu.Lock() | |
| 55 | + defer s.mu.Unlock() | |
| 56 | + | |
| 57 | + keep := next.History.Keep | |
| 58 | + if keep == 0 { | |
| 59 | + keep = 20 // safety fallback; keep=0 in config means "use default" | |
| 60 | + } | |
| 61 | + | |
| 62 | + // Snapshot before overwrite (no-op if file doesn't exist yet). | |
| 63 | + if err := config.SnapshotConfig(s.historyDir, s.path); err != nil { | |
| 64 | + return fmt.Errorf("config store: snapshot: %w", err) | |
| 65 | + } | |
| 66 | + | |
| 67 | + // Write the new config to disk. | |
| 68 | + if err := next.Save(s.path); err != nil { | |
| 69 | + return fmt.Errorf("config store: save: %w", err) | |
| 70 | + } | |
| 71 | + | |
| 72 | + // Prune history to keep entries. | |
| 73 | + base := filepath.Base(s.path) | |
| 74 | + if err := config.PruneHistory(s.historyDir, base, keep); err != nil { | |
| 75 | + // Non-fatal: log would be nice but we don't have a logger here. | |
| 76 | + // Callers can surface this separately. | |
| 77 | + _ = err | |
| 78 | + } | |
| 79 | + | |
| 80 | + s.cfg = next | |
| 81 | + | |
| 82 | + // Fire callbacks outside the lock in fresh goroutines. | |
| 83 | + cbs := make([]func(config.Config), len(s.onChange)) | |
| 84 | + copy(cbs, s.onChange) | |
| 85 | + for _, fn := range cbs { | |
| 86 | + go fn(next) | |
| 87 | + } | |
| 88 | + return nil | |
| 89 | +} | |
| 90 | + | |
| 91 | +// ListHistory returns the snapshots for the managed config file. | |
| 92 | +func (s *ConfigStore) ListHistory() ([]config.HistoryEntry, error) { | |
| 93 | + s.mu.RLock() | |
| 94 | + histDir := s.historyDir | |
| 95 | + path := s.path | |
| 96 | + s.mu.RUnlock() | |
| 97 | + base := filepath.Base(path) | |
| 98 | + return config.ListHistory(histDir, base) | |
| 99 | +} | |
| 100 | + | |
| 101 | +// ReadHistoryFile returns the raw bytes of a snapshot by filename. | |
| 102 | +func (s *ConfigStore) ReadHistoryFile(filename string) ([]byte, error) { | |
| 103 | + s.mu.RLock() | |
| 104 | + histDir := s.historyDir | |
| 105 | + s.mu.RUnlock() | |
| 106 | + // Sanitize: only allow simple filenames (no path separators). | |
| 107 | + if filepath.Base(filename) != filename { | |
| 108 | + return nil, fmt.Errorf("config store: invalid history filename") | |
| 109 | + } | |
| 110 | + return os.ReadFile(filepath.Join(histDir, filename)) | |
| 111 | +} |
| --- a/internal/api/config_store.go | |
| +++ b/internal/api/config_store.go | |
| @@ -0,0 +1,111 @@ | |
| --- a/internal/api/config_store.go | |
| +++ b/internal/api/config_store.go | |
| @@ -0,0 +1,111 @@ | |
| 1 | package api |
| 2 | |
| 3 | import ( |
| 4 | "fmt" |
| 5 | "os" |
| 6 | "path/filepath" |
| 7 | "sync" |
| 8 | |
| 9 | "github.com/conflicthq/scuttlebot/internal/config" |
| 10 | ) |
| 11 | |
| 12 | // ConfigStore holds the running config and knows how to write it back to disk |
| 13 | // with history snapshots. It is the single write path for all config mutations. |
| 14 | type ConfigStore struct { |
| 15 | mu sync.RWMutex |
| 16 | cfg config.Config |
| 17 | path string // absolute path to scuttlebot.yaml |
| 18 | historyDir string // where snapshots land |
| 19 | onChange []func(config.Config) |
| 20 | } |
| 21 | |
| 22 | // NewConfigStore creates a ConfigStore for the given config file path. |
| 23 | // The initial config value is copied in. |
| 24 | func NewConfigStore(path string, cfg config.Config) *ConfigStore { |
| 25 | histDir := cfg.History.Dir |
| 26 | if histDir == "" { |
| 27 | histDir = filepath.Join(cfg.Ergo.DataDir, "config-history") |
| 28 | } |
| 29 | return &ConfigStore{ |
| 30 | cfg: cfg, |
| 31 | path: path, |
| 32 | historyDir: histDir, |
| 33 | } |
| 34 | } |
| 35 | |
| 36 | // Get returns a copy of the current config. |
| 37 | func (s *ConfigStore) Get() config.Config { |
| 38 | s.mu.RLock() |
| 39 | defer s.mu.RUnlock() |
| 40 | return s.cfg |
| 41 | } |
| 42 | |
| 43 | // OnChange registers a callback invoked (in a new goroutine) after every |
| 44 | // successful Save. Multiple callbacks are called concurrently. |
| 45 | func (s *ConfigStore) OnChange(fn func(config.Config)) { |
| 46 | s.mu.Lock() |
| 47 | defer s.mu.Unlock() |
| 48 | s.onChange = append(s.onChange, fn) |
| 49 | } |
| 50 | |
| 51 | // Save snapshots the current file, writes next to disk, updates the in-memory |
| 52 | // copy, and fires all OnChange callbacks. |
| 53 | func (s *ConfigStore) Save(next config.Config) error { |
| 54 | s.mu.Lock() |
| 55 | defer s.mu.Unlock() |
| 56 | |
| 57 | keep := next.History.Keep |
| 58 | if keep == 0 { |
| 59 | keep = 20 // safety fallback; keep=0 in config means "use default" |
| 60 | } |
| 61 | |
| 62 | // Snapshot before overwrite (no-op if file doesn't exist yet). |
| 63 | if err := config.SnapshotConfig(s.historyDir, s.path); err != nil { |
| 64 | return fmt.Errorf("config store: snapshot: %w", err) |
| 65 | } |
| 66 | |
| 67 | // Write the new config to disk. |
| 68 | if err := next.Save(s.path); err != nil { |
| 69 | return fmt.Errorf("config store: save: %w", err) |
| 70 | } |
| 71 | |
| 72 | // Prune history to keep entries. |
| 73 | base := filepath.Base(s.path) |
| 74 | if err := config.PruneHistory(s.historyDir, base, keep); err != nil { |
| 75 | // Non-fatal: log would be nice but we don't have a logger here. |
| 76 | // Callers can surface this separately. |
| 77 | _ = err |
| 78 | } |
| 79 | |
| 80 | s.cfg = next |
| 81 | |
| 82 | // Fire callbacks outside the lock in fresh goroutines. |
| 83 | cbs := make([]func(config.Config), len(s.onChange)) |
| 84 | copy(cbs, s.onChange) |
| 85 | for _, fn := range cbs { |
| 86 | go fn(next) |
| 87 | } |
| 88 | return nil |
| 89 | } |
| 90 | |
| 91 | // ListHistory returns the snapshots for the managed config file. |
| 92 | func (s *ConfigStore) ListHistory() ([]config.HistoryEntry, error) { |
| 93 | s.mu.RLock() |
| 94 | histDir := s.historyDir |
| 95 | path := s.path |
| 96 | s.mu.RUnlock() |
| 97 | base := filepath.Base(path) |
| 98 | return config.ListHistory(histDir, base) |
| 99 | } |
| 100 | |
| 101 | // ReadHistoryFile returns the raw bytes of a snapshot by filename. |
| 102 | func (s *ConfigStore) ReadHistoryFile(filename string) ([]byte, error) { |
| 103 | s.mu.RLock() |
| 104 | histDir := s.historyDir |
| 105 | s.mu.RUnlock() |
| 106 | // Sanitize: only allow simple filenames (no path separators). |
| 107 | if filepath.Base(filename) != filename { |
| 108 | return nil, fmt.Errorf("config store: invalid history filename") |
| 109 | } |
| 110 | return os.ReadFile(filepath.Join(histDir, filename)) |
| 111 | } |
| --- internal/api/login_test.go | ||
| +++ internal/api/login_test.go | ||
| @@ -28,18 +28,18 @@ | ||
| 28 | 28 | admins := newAdminStore(t) |
| 29 | 29 | if err := admins.Add("admin", "hunter2"); err != nil { |
| 30 | 30 | t.Fatalf("Add admin: %v", err) |
| 31 | 31 | } |
| 32 | 32 | reg := registry.New(newMock(), []byte("test-signing-key")) |
| 33 | - srv := api.New(reg, []string{testToken}, nil, nil, admins, nil, nil, "", testLog) | |
| 33 | + srv := api.New(reg, []string{testToken}, nil, nil, admins, nil, nil, nil, "", testLog) | |
| 34 | 34 | return httptest.NewServer(srv.Handler()), admins |
| 35 | 35 | } |
| 36 | 36 | |
| 37 | 37 | func TestLoginNoAdmins(t *testing.T) { |
| 38 | 38 | // When admins is nil, login returns 404. |
| 39 | 39 | reg := registry.New(newMock(), []byte("test-signing-key")) |
| 40 | - srv := api.New(reg, []string{testToken}, nil, nil, nil, nil, nil, "", testLog) | |
| 40 | + srv := api.New(reg, []string{testToken}, nil, nil, nil, nil, nil, nil, "", testLog) | |
| 41 | 41 | ts := httptest.NewServer(srv.Handler()) |
| 42 | 42 | defer ts.Close() |
| 43 | 43 | |
| 44 | 44 | resp := do(t, ts, "POST", "/login", map[string]any{"username": "admin", "password": "pw"}, nil) |
| 45 | 45 | defer resp.Body.Close() |
| 46 | 46 |
| --- internal/api/login_test.go | |
| +++ internal/api/login_test.go | |
| @@ -28,18 +28,18 @@ | |
| 28 | admins := newAdminStore(t) |
| 29 | if err := admins.Add("admin", "hunter2"); err != nil { |
| 30 | t.Fatalf("Add admin: %v", err) |
| 31 | } |
| 32 | reg := registry.New(newMock(), []byte("test-signing-key")) |
| 33 | srv := api.New(reg, []string{testToken}, nil, nil, admins, nil, nil, "", testLog) |
| 34 | return httptest.NewServer(srv.Handler()), admins |
| 35 | } |
| 36 | |
| 37 | func TestLoginNoAdmins(t *testing.T) { |
| 38 | // When admins is nil, login returns 404. |
| 39 | reg := registry.New(newMock(), []byte("test-signing-key")) |
| 40 | srv := api.New(reg, []string{testToken}, nil, nil, nil, nil, nil, "", testLog) |
| 41 | ts := httptest.NewServer(srv.Handler()) |
| 42 | defer ts.Close() |
| 43 | |
| 44 | resp := do(t, ts, "POST", "/login", map[string]any{"username": "admin", "password": "pw"}, nil) |
| 45 | defer resp.Body.Close() |
| 46 |
| --- internal/api/login_test.go | |
| +++ internal/api/login_test.go | |
| @@ -28,18 +28,18 @@ | |
| 28 | admins := newAdminStore(t) |
| 29 | if err := admins.Add("admin", "hunter2"); err != nil { |
| 30 | t.Fatalf("Add admin: %v", err) |
| 31 | } |
| 32 | reg := registry.New(newMock(), []byte("test-signing-key")) |
| 33 | srv := api.New(reg, []string{testToken}, nil, nil, admins, nil, nil, nil, "", testLog) |
| 34 | return httptest.NewServer(srv.Handler()), admins |
| 35 | } |
| 36 | |
| 37 | func TestLoginNoAdmins(t *testing.T) { |
| 38 | // When admins is nil, login returns 404. |
| 39 | reg := registry.New(newMock(), []byte("test-signing-key")) |
| 40 | srv := api.New(reg, []string{testToken}, nil, nil, nil, nil, nil, nil, "", testLog) |
| 41 | ts := httptest.NewServer(srv.Handler()) |
| 42 | defer ts.Close() |
| 43 | |
| 44 | resp := do(t, ts, "POST", "/login", map[string]any{"username": "admin", "password": "pw"}, nil) |
| 45 | defer resp.Body.Close() |
| 46 |
| --- internal/api/server.go | ||
| +++ internal/api/server.go | ||
| @@ -21,19 +21,21 @@ | ||
| 21 | 21 | bridge chatBridge // nil if bridge is disabled |
| 22 | 22 | policies *PolicyStore // nil if not configured |
| 23 | 23 | admins adminStore // nil if not configured |
| 24 | 24 | llmCfg *config.LLMConfig // nil if no LLM backends configured |
| 25 | 25 | topoMgr topologyManager // nil if topology not configured |
| 26 | + cfgStore *ConfigStore // nil if config write-back not configured | |
| 26 | 27 | loginRL *loginRateLimiter |
| 27 | 28 | tlsDomain string // empty if no TLS |
| 28 | 29 | } |
| 29 | 30 | |
| 30 | 31 | // New creates a new API Server. Pass nil for b to disable the chat bridge. |
| 31 | 32 | // Pass nil for admins to disable admin authentication endpoints. |
| 32 | 33 | // Pass nil for llmCfg to disable AI/LLM management endpoints. |
| 33 | 34 | // Pass nil for topo to disable topology provisioning endpoints. |
| 34 | -func New(reg *registry.Registry, tokens []string, b chatBridge, ps *PolicyStore, admins adminStore, llmCfg *config.LLMConfig, topo topologyManager, tlsDomain string, log *slog.Logger) *Server { | |
| 35 | +// Pass nil for cfgStore to disable config read/write endpoints. | |
| 36 | +func New(reg *registry.Registry, tokens []string, b chatBridge, ps *PolicyStore, admins adminStore, llmCfg *config.LLMConfig, topo topologyManager, cfgStore *ConfigStore, tlsDomain string, log *slog.Logger) *Server { | |
| 35 | 37 | tokenSet := make(map[string]struct{}, len(tokens)) |
| 36 | 38 | for _, t := range tokens { |
| 37 | 39 | tokenSet[t] = struct{}{} |
| 38 | 40 | } |
| 39 | 41 | return &Server{ |
| @@ -43,10 +45,11 @@ | ||
| 43 | 45 | bridge: b, |
| 44 | 46 | policies: ps, |
| 45 | 47 | admins: admins, |
| 46 | 48 | llmCfg: llmCfg, |
| 47 | 49 | topoMgr: topo, |
| 50 | + cfgStore: cfgStore, | |
| 48 | 51 | loginRL: newLoginRateLimiter(), |
| 49 | 52 | tlsDomain: tlsDomain, |
| 50 | 53 | } |
| 51 | 54 | } |
| 52 | 55 | |
| @@ -81,10 +84,16 @@ | ||
| 81 | 84 | if s.topoMgr != nil { |
| 82 | 85 | apiMux.HandleFunc("POST /v1/channels", s.handleProvisionChannel) |
| 83 | 86 | apiMux.HandleFunc("DELETE /v1/topology/channels/{channel}", s.handleDropChannel) |
| 84 | 87 | apiMux.HandleFunc("GET /v1/topology", s.handleGetTopology) |
| 85 | 88 | } |
| 89 | + if s.cfgStore != nil { | |
| 90 | + apiMux.HandleFunc("GET /v1/config", s.handleGetConfig) | |
| 91 | + apiMux.HandleFunc("PUT /v1/config", s.handlePutConfig) | |
| 92 | + apiMux.HandleFunc("GET /v1/config/history", s.handleGetConfigHistory) | |
| 93 | + apiMux.HandleFunc("GET /v1/config/history/{filename}", s.handleGetConfigHistoryEntry) | |
| 94 | + } | |
| 86 | 95 | |
| 87 | 96 | if s.admins != nil { |
| 88 | 97 | apiMux.HandleFunc("GET /v1/admins", s.handleAdminList) |
| 89 | 98 | apiMux.HandleFunc("POST /v1/admins", s.handleAdminAdd) |
| 90 | 99 | apiMux.HandleFunc("DELETE /v1/admins/{username}", s.handleAdminRemove) |
| 91 | 100 |
| --- internal/api/server.go | |
| +++ internal/api/server.go | |
| @@ -21,19 +21,21 @@ | |
| 21 | bridge chatBridge // nil if bridge is disabled |
| 22 | policies *PolicyStore // nil if not configured |
| 23 | admins adminStore // nil if not configured |
| 24 | llmCfg *config.LLMConfig // nil if no LLM backends configured |
| 25 | topoMgr topologyManager // nil if topology not configured |
| 26 | loginRL *loginRateLimiter |
| 27 | tlsDomain string // empty if no TLS |
| 28 | } |
| 29 | |
| 30 | // New creates a new API Server. Pass nil for b to disable the chat bridge. |
| 31 | // Pass nil for admins to disable admin authentication endpoints. |
| 32 | // Pass nil for llmCfg to disable AI/LLM management endpoints. |
| 33 | // Pass nil for topo to disable topology provisioning endpoints. |
| 34 | func New(reg *registry.Registry, tokens []string, b chatBridge, ps *PolicyStore, admins adminStore, llmCfg *config.LLMConfig, topo topologyManager, tlsDomain string, log *slog.Logger) *Server { |
| 35 | tokenSet := make(map[string]struct{}, len(tokens)) |
| 36 | for _, t := range tokens { |
| 37 | tokenSet[t] = struct{}{} |
| 38 | } |
| 39 | return &Server{ |
| @@ -43,10 +45,11 @@ | |
| 43 | bridge: b, |
| 44 | policies: ps, |
| 45 | admins: admins, |
| 46 | llmCfg: llmCfg, |
| 47 | topoMgr: topo, |
| 48 | loginRL: newLoginRateLimiter(), |
| 49 | tlsDomain: tlsDomain, |
| 50 | } |
| 51 | } |
| 52 | |
| @@ -81,10 +84,16 @@ | |
| 81 | if s.topoMgr != nil { |
| 82 | apiMux.HandleFunc("POST /v1/channels", s.handleProvisionChannel) |
| 83 | apiMux.HandleFunc("DELETE /v1/topology/channels/{channel}", s.handleDropChannel) |
| 84 | apiMux.HandleFunc("GET /v1/topology", s.handleGetTopology) |
| 85 | } |
| 86 | |
| 87 | if s.admins != nil { |
| 88 | apiMux.HandleFunc("GET /v1/admins", s.handleAdminList) |
| 89 | apiMux.HandleFunc("POST /v1/admins", s.handleAdminAdd) |
| 90 | apiMux.HandleFunc("DELETE /v1/admins/{username}", s.handleAdminRemove) |
| 91 |
| --- internal/api/server.go | |
| +++ internal/api/server.go | |
| @@ -21,19 +21,21 @@ | |
| 21 | bridge chatBridge // nil if bridge is disabled |
| 22 | policies *PolicyStore // nil if not configured |
| 23 | admins adminStore // nil if not configured |
| 24 | llmCfg *config.LLMConfig // nil if no LLM backends configured |
| 25 | topoMgr topologyManager // nil if topology not configured |
| 26 | cfgStore *ConfigStore // nil if config write-back not configured |
| 27 | loginRL *loginRateLimiter |
| 28 | tlsDomain string // empty if no TLS |
| 29 | } |
| 30 | |
| 31 | // New creates a new API Server. Pass nil for b to disable the chat bridge. |
| 32 | // Pass nil for admins to disable admin authentication endpoints. |
| 33 | // Pass nil for llmCfg to disable AI/LLM management endpoints. |
| 34 | // Pass nil for topo to disable topology provisioning endpoints. |
| 35 | // Pass nil for cfgStore to disable config read/write endpoints. |
| 36 | func New(reg *registry.Registry, tokens []string, b chatBridge, ps *PolicyStore, admins adminStore, llmCfg *config.LLMConfig, topo topologyManager, cfgStore *ConfigStore, tlsDomain string, log *slog.Logger) *Server { |
| 37 | tokenSet := make(map[string]struct{}, len(tokens)) |
| 38 | for _, t := range tokens { |
| 39 | tokenSet[t] = struct{}{} |
| 40 | } |
| 41 | return &Server{ |
| @@ -43,10 +45,11 @@ | |
| 45 | bridge: b, |
| 46 | policies: ps, |
| 47 | admins: admins, |
| 48 | llmCfg: llmCfg, |
| 49 | topoMgr: topo, |
| 50 | cfgStore: cfgStore, |
| 51 | loginRL: newLoginRateLimiter(), |
| 52 | tlsDomain: tlsDomain, |
| 53 | } |
| 54 | } |
| 55 | |
| @@ -81,10 +84,16 @@ | |
| 84 | if s.topoMgr != nil { |
| 85 | apiMux.HandleFunc("POST /v1/channels", s.handleProvisionChannel) |
| 86 | apiMux.HandleFunc("DELETE /v1/topology/channels/{channel}", s.handleDropChannel) |
| 87 | apiMux.HandleFunc("GET /v1/topology", s.handleGetTopology) |
| 88 | } |
| 89 | if s.cfgStore != nil { |
| 90 | apiMux.HandleFunc("GET /v1/config", s.handleGetConfig) |
| 91 | apiMux.HandleFunc("PUT /v1/config", s.handlePutConfig) |
| 92 | apiMux.HandleFunc("GET /v1/config/history", s.handleGetConfigHistory) |
| 93 | apiMux.HandleFunc("GET /v1/config/history/{filename}", s.handleGetConfigHistoryEntry) |
| 94 | } |
| 95 | |
| 96 | if s.admins != nil { |
| 97 | apiMux.HandleFunc("GET /v1/admins", s.handleAdminList) |
| 98 | apiMux.HandleFunc("POST /v1/admins", s.handleAdminAdd) |
| 99 | apiMux.HandleFunc("DELETE /v1/admins/{username}", s.handleAdminRemove) |
| 100 |
| --- internal/config/config.go | ||
| +++ internal/config/config.go | ||
| @@ -9,16 +9,17 @@ | ||
| 9 | 9 | "gopkg.in/yaml.v3" |
| 10 | 10 | ) |
| 11 | 11 | |
| 12 | 12 | // Config is the top-level scuttlebot configuration. |
| 13 | 13 | type Config struct { |
| 14 | - Ergo ErgoConfig `yaml:"ergo"` | |
| 15 | - Datastore DatastoreConfig `yaml:"datastore"` | |
| 16 | - Bridge BridgeConfig `yaml:"bridge"` | |
| 17 | - TLS TLSConfig `yaml:"tls"` | |
| 18 | - LLM LLMConfig `yaml:"llm"` | |
| 19 | - Topology TopologyConfig `yaml:"topology"` | |
| 14 | + Ergo ErgoConfig `yaml:"ergo"` | |
| 15 | + Datastore DatastoreConfig `yaml:"datastore"` | |
| 16 | + Bridge BridgeConfig `yaml:"bridge"` | |
| 17 | + TLS TLSConfig `yaml:"tls"` | |
| 18 | + LLM LLMConfig `yaml:"llm"` | |
| 19 | + Topology TopologyConfig `yaml:"topology"` | |
| 20 | + History ConfigHistoryConfig `yaml:"config_history"` | |
| 20 | 21 | |
| 21 | 22 | // APIAddr is the address for scuttlebot's own HTTP management API. |
| 22 | 23 | // Ignored when TLS.Domain is set (HTTPS runs on :443, HTTP on :80). |
| 23 | 24 | // Default: ":8080" |
| 24 | 25 | APIAddr string `yaml:"api_addr"` |
| @@ -25,10 +26,21 @@ | ||
| 25 | 26 | |
| 26 | 27 | // MCPAddr is the address for the MCP server. |
| 27 | 28 | // Default: ":8081" |
| 28 | 29 | MCPAddr string `yaml:"mcp_addr"` |
| 29 | 30 | } |
| 31 | + | |
| 32 | +// ConfigHistoryConfig controls config write-back history retention. | |
| 33 | +type ConfigHistoryConfig struct { | |
| 34 | + // Keep is the number of config snapshots to retain in Dir. | |
| 35 | + // 0 disables history. Default: 20. | |
| 36 | + Keep int `yaml:"keep"` | |
| 37 | + | |
| 38 | + // Dir is the directory for config snapshots. | |
| 39 | + // Default: {ergo.data_dir}/config-history | |
| 40 | + Dir string `yaml:"dir"` | |
| 41 | +} | |
| 30 | 42 | |
| 31 | 43 | // LLMConfig configures the omnibus LLM gateway used by oracle and any other |
| 32 | 44 | // bot or service that needs language model access. |
| 33 | 45 | type LLMConfig struct { |
| 34 | 46 | // Backends is the list of configured LLM backends. |
| @@ -270,10 +282,38 @@ | ||
| 270 | 282 | return fmt.Errorf("config: invalid duration %q: %w", s, err) |
| 271 | 283 | } |
| 272 | 284 | d.Duration = dur |
| 273 | 285 | return nil |
| 274 | 286 | } |
| 287 | + | |
| 288 | +// MarshalYAML encodes Duration as a human-readable string ("72h", "30m"). | |
| 289 | +func (d Duration) MarshalYAML() (any, error) { | |
| 290 | + if d.Duration == 0 { | |
| 291 | + return "0s", nil | |
| 292 | + } | |
| 293 | + return d.Duration.String(), nil | |
| 294 | +} | |
| 295 | + | |
| 296 | +// Save marshals c to YAML and writes it to path atomically (write to a temp | |
| 297 | +// file in the same directory, then rename). Comments in the original file are | |
| 298 | +// not preserved after the first save. | |
| 299 | +func (c *Config) Save(path string) error { | |
| 300 | + data, err := yaml.Marshal(c) | |
| 301 | + if err != nil { | |
| 302 | + return fmt.Errorf("config: marshal: %w", err) | |
| 303 | + } | |
| 304 | + // Write to a sibling temp file then rename for atomic replacement. | |
| 305 | + tmp := path + ".tmp" | |
| 306 | + if err := os.WriteFile(tmp, data, 0o600); err != nil { | |
| 307 | + return fmt.Errorf("config: write %s: %w", tmp, err) | |
| 308 | + } | |
| 309 | + if err := os.Rename(tmp, path); err != nil { | |
| 310 | + _ = os.Remove(tmp) | |
| 311 | + return fmt.Errorf("config: rename %s → %s: %w", tmp, path, err) | |
| 312 | + } | |
| 313 | + return nil | |
| 314 | +} | |
| 275 | 315 | |
| 276 | 316 | // Defaults fills in zero values with sensible defaults. |
| 277 | 317 | func (c *Config) Defaults() { |
| 278 | 318 | if c.Ergo.BinaryPath == "" { |
| 279 | 319 | c.Ergo.BinaryPath = "ergo" |
| @@ -321,10 +361,13 @@ | ||
| 321 | 361 | c.Bridge.WebUserTTLMinutes = 5 |
| 322 | 362 | } |
| 323 | 363 | if c.Topology.Nick == "" { |
| 324 | 364 | c.Topology.Nick = "topology" |
| 325 | 365 | } |
| 366 | + if c.History.Keep == 0 { | |
| 367 | + c.History.Keep = 20 | |
| 368 | + } | |
| 326 | 369 | } |
| 327 | 370 | |
| 328 | 371 | func envStr(key string) string { return os.Getenv(key) } |
| 329 | 372 | |
| 330 | 373 | // LoadFile reads a YAML config file into c. Missing file is not an error — |
| @@ -337,12 +380,17 @@ | ||
| 337 | 380 | return nil |
| 338 | 381 | } |
| 339 | 382 | if err != nil { |
| 340 | 383 | return fmt.Errorf("config: read %s: %w", path, err) |
| 341 | 384 | } |
| 385 | + return c.LoadFromBytes(data) | |
| 386 | +} | |
| 387 | + | |
| 388 | +// LoadFromBytes parses YAML config bytes into c. | |
| 389 | +func (c *Config) LoadFromBytes(data []byte) error { | |
| 342 | 390 | if err := yaml.Unmarshal(data, c); err != nil { |
| 343 | - return fmt.Errorf("config: parse %s: %w", path, err) | |
| 391 | + return fmt.Errorf("config: parse: %w", err) | |
| 344 | 392 | } |
| 345 | 393 | return nil |
| 346 | 394 | } |
| 347 | 395 | |
| 348 | 396 | // ApplyEnv overrides config values with SCUTTLEBOT_* environment variables. |
| 349 | 397 | |
| 350 | 398 | ADDED internal/config/history.go |
| 351 | 399 | ADDED internal/config/history_test.go |
| --- internal/config/config.go | |
| +++ internal/config/config.go | |
| @@ -9,16 +9,17 @@ | |
| 9 | "gopkg.in/yaml.v3" |
| 10 | ) |
| 11 | |
| 12 | // Config is the top-level scuttlebot configuration. |
| 13 | type Config struct { |
| 14 | Ergo ErgoConfig `yaml:"ergo"` |
| 15 | Datastore DatastoreConfig `yaml:"datastore"` |
| 16 | Bridge BridgeConfig `yaml:"bridge"` |
| 17 | TLS TLSConfig `yaml:"tls"` |
| 18 | LLM LLMConfig `yaml:"llm"` |
| 19 | Topology TopologyConfig `yaml:"topology"` |
| 20 | |
| 21 | // APIAddr is the address for scuttlebot's own HTTP management API. |
| 22 | // Ignored when TLS.Domain is set (HTTPS runs on :443, HTTP on :80). |
| 23 | // Default: ":8080" |
| 24 | APIAddr string `yaml:"api_addr"` |
| @@ -25,10 +26,21 @@ | |
| 25 | |
| 26 | // MCPAddr is the address for the MCP server. |
| 27 | // Default: ":8081" |
| 28 | MCPAddr string `yaml:"mcp_addr"` |
| 29 | } |
| 30 | |
| 31 | // LLMConfig configures the omnibus LLM gateway used by oracle and any other |
| 32 | // bot or service that needs language model access. |
| 33 | type LLMConfig struct { |
| 34 | // Backends is the list of configured LLM backends. |
| @@ -270,10 +282,38 @@ | |
| 270 | return fmt.Errorf("config: invalid duration %q: %w", s, err) |
| 271 | } |
| 272 | d.Duration = dur |
| 273 | return nil |
| 274 | } |
| 275 | |
| 276 | // Defaults fills in zero values with sensible defaults. |
| 277 | func (c *Config) Defaults() { |
| 278 | if c.Ergo.BinaryPath == "" { |
| 279 | c.Ergo.BinaryPath = "ergo" |
| @@ -321,10 +361,13 @@ | |
| 321 | c.Bridge.WebUserTTLMinutes = 5 |
| 322 | } |
| 323 | if c.Topology.Nick == "" { |
| 324 | c.Topology.Nick = "topology" |
| 325 | } |
| 326 | } |
| 327 | |
| 328 | func envStr(key string) string { return os.Getenv(key) } |
| 329 | |
| 330 | // LoadFile reads a YAML config file into c. Missing file is not an error — |
| @@ -337,12 +380,17 @@ | |
| 337 | return nil |
| 338 | } |
| 339 | if err != nil { |
| 340 | return fmt.Errorf("config: read %s: %w", path, err) |
| 341 | } |
| 342 | if err := yaml.Unmarshal(data, c); err != nil { |
| 343 | return fmt.Errorf("config: parse %s: %w", path, err) |
| 344 | } |
| 345 | return nil |
| 346 | } |
| 347 | |
| 348 | // ApplyEnv overrides config values with SCUTTLEBOT_* environment variables. |
| 349 | |
| 350 | DDED internal/config/history.go |
| 351 | DDED internal/config/history_test.go |
| --- internal/config/config.go | |
| +++ internal/config/config.go | |
| @@ -9,16 +9,17 @@ | |
| 9 | "gopkg.in/yaml.v3" |
| 10 | ) |
| 11 | |
| 12 | // Config is the top-level scuttlebot configuration. |
| 13 | type Config struct { |
| 14 | Ergo ErgoConfig `yaml:"ergo"` |
| 15 | Datastore DatastoreConfig `yaml:"datastore"` |
| 16 | Bridge BridgeConfig `yaml:"bridge"` |
| 17 | TLS TLSConfig `yaml:"tls"` |
| 18 | LLM LLMConfig `yaml:"llm"` |
| 19 | Topology TopologyConfig `yaml:"topology"` |
| 20 | History ConfigHistoryConfig `yaml:"config_history"` |
| 21 | |
| 22 | // APIAddr is the address for scuttlebot's own HTTP management API. |
| 23 | // Ignored when TLS.Domain is set (HTTPS runs on :443, HTTP on :80). |
| 24 | // Default: ":8080" |
| 25 | APIAddr string `yaml:"api_addr"` |
| @@ -25,10 +26,21 @@ | |
| 26 | |
| 27 | // MCPAddr is the address for the MCP server. |
| 28 | // Default: ":8081" |
| 29 | MCPAddr string `yaml:"mcp_addr"` |
| 30 | } |
| 31 | |
| 32 | // ConfigHistoryConfig controls config write-back history retention. |
| 33 | type ConfigHistoryConfig struct { |
| 34 | // Keep is the number of config snapshots to retain in Dir. |
| 35 | // 0 disables history. Default: 20. |
| 36 | Keep int `yaml:"keep"` |
| 37 | |
| 38 | // Dir is the directory for config snapshots. |
| 39 | // Default: {ergo.data_dir}/config-history |
| 40 | Dir string `yaml:"dir"` |
| 41 | } |
| 42 | |
| 43 | // LLMConfig configures the omnibus LLM gateway used by oracle and any other |
| 44 | // bot or service that needs language model access. |
| 45 | type LLMConfig struct { |
| 46 | // Backends is the list of configured LLM backends. |
| @@ -270,10 +282,38 @@ | |
| 282 | return fmt.Errorf("config: invalid duration %q: %w", s, err) |
| 283 | } |
| 284 | d.Duration = dur |
| 285 | return nil |
| 286 | } |
| 287 | |
| 288 | // MarshalYAML encodes Duration as a human-readable string ("72h", "30m"). |
| 289 | func (d Duration) MarshalYAML() (any, error) { |
| 290 | if d.Duration == 0 { |
| 291 | return "0s", nil |
| 292 | } |
| 293 | return d.Duration.String(), nil |
| 294 | } |
| 295 | |
| 296 | // Save marshals c to YAML and writes it to path atomically (write to a temp |
| 297 | // file in the same directory, then rename). Comments in the original file are |
| 298 | // not preserved after the first save. |
| 299 | func (c *Config) Save(path string) error { |
| 300 | data, err := yaml.Marshal(c) |
| 301 | if err != nil { |
| 302 | return fmt.Errorf("config: marshal: %w", err) |
| 303 | } |
| 304 | // Write to a sibling temp file then rename for atomic replacement. |
| 305 | tmp := path + ".tmp" |
| 306 | if err := os.WriteFile(tmp, data, 0o600); err != nil { |
| 307 | return fmt.Errorf("config: write %s: %w", tmp, err) |
| 308 | } |
| 309 | if err := os.Rename(tmp, path); err != nil { |
| 310 | _ = os.Remove(tmp) |
| 311 | return fmt.Errorf("config: rename %s → %s: %w", tmp, path, err) |
| 312 | } |
| 313 | return nil |
| 314 | } |
| 315 | |
| 316 | // Defaults fills in zero values with sensible defaults. |
| 317 | func (c *Config) Defaults() { |
| 318 | if c.Ergo.BinaryPath == "" { |
| 319 | c.Ergo.BinaryPath = "ergo" |
| @@ -321,10 +361,13 @@ | |
| 361 | c.Bridge.WebUserTTLMinutes = 5 |
| 362 | } |
| 363 | if c.Topology.Nick == "" { |
| 364 | c.Topology.Nick = "topology" |
| 365 | } |
| 366 | if c.History.Keep == 0 { |
| 367 | c.History.Keep = 20 |
| 368 | } |
| 369 | } |
| 370 | |
| 371 | func envStr(key string) string { return os.Getenv(key) } |
| 372 | |
| 373 | // LoadFile reads a YAML config file into c. Missing file is not an error — |
| @@ -337,12 +380,17 @@ | |
| 380 | return nil |
| 381 | } |
| 382 | if err != nil { |
| 383 | return fmt.Errorf("config: read %s: %w", path, err) |
| 384 | } |
| 385 | return c.LoadFromBytes(data) |
| 386 | } |
| 387 | |
| 388 | // LoadFromBytes parses YAML config bytes into c. |
| 389 | func (c *Config) LoadFromBytes(data []byte) error { |
| 390 | if err := yaml.Unmarshal(data, c); err != nil { |
| 391 | return fmt.Errorf("config: parse: %w", err) |
| 392 | } |
| 393 | return nil |
| 394 | } |
| 395 | |
| 396 | // ApplyEnv overrides config values with SCUTTLEBOT_* environment variables. |
| 397 | |
| 398 | DDED internal/config/history.go |
| 399 | DDED internal/config/history_test.go |
| --- a/internal/config/history.go | ||
| +++ b/internal/config/history.go | ||
| @@ -0,0 +1,120 @@ | ||
| 1 | +package config | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "fmt" | |
| 5 | + "io" | |
| 6 | + "os" | |
| 7 | + "path/filepath" | |
| 8 | + "sort" | |
| 9 | + "strings" | |
| 10 | + "time" | |
| 11 | +) | |
| 12 | + | |
| 13 | +// HistoryEntry describes a single config snapshot in the history directory. | |
| 14 | +type HistoryEntry struct { | |
| 15 | + // Filename is the base name of the snapshot file (e.g. "scuttlebot.yaml.20260402-143022"). | |
| 16 | + Filename string `json:"filename"` | |
| 17 | + | |
| 18 | + // Timestamp is when the snapshot was taken, parsed from the filename. | |
| 19 | + Timestamp time.Time `json:"timestamp"` | |
| 20 | + | |
| 21 | + // Size is the file size in bytes. | |
| 22 | + Size int64 `json:"size"` | |
| 23 | +} | |
| 24 | + | |
| 25 | +const historyTimestampFormat = "20060102-150405" | |
| 26 | + | |
| 27 | +// SnapshotConfig copies the file at configPath into historyDir, naming it | |
| 28 | +// "<basename>.<timestamp>". It creates historyDir if it does not exist. | |
| 29 | +// It is a no-op if configPath does not exist yet. | |
| 30 | +func SnapshotConfig(historyDir, configPath string) error { | |
| 31 | + src, err := os.Open(configPath) | |
| 32 | + if os.IsNotExist(err) { | |
| 33 | + return nil // nothing to snapshot | |
| 34 | + } | |
| 35 | + if err != nil { | |
| 36 | + return fmt.Errorf("config history: open %s: %w", configPath, err) | |
| 37 | + } | |
| 38 | + defer src.Close() | |
| 39 | + | |
| 40 | + if err := os.MkdirAll(historyDir, 0o700); err != nil { | |
| 41 | + return fmt.Errorf("config history: mkdir %s: %w", historyDir, err) | |
| 42 | + } | |
| 43 | + | |
| 44 | + base := filepath.Base(configPath) | |
| 45 | + stamp := time.Now().Format(historyTimestampFormat) | |
| 46 | + dst := filepath.Join(historyDir, base+"."+stamp) | |
| 47 | + | |
| 48 | + out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600) | |
| 49 | + if err != nil { | |
| 50 | + return fmt.Errorf("config history: create snapshot %s: %w", dst, err) | |
| 51 | + } | |
| 52 | + defer out.Close() | |
| 53 | + | |
| 54 | + if _, err := io.Copy(out, src); err != nil { | |
| 55 | + return fmt.Errorf("config history: write snapshot %s: %w", dst, err) | |
| 56 | + } | |
| 57 | + return nil | |
| 58 | +} | |
| 59 | + | |
| 60 | +// PruneHistory removes the oldest snapshots in historyDir until at most keep | |
| 61 | +// files remain. It only considers files whose names start with base (the | |
| 62 | +// basename of the config file). keep ≤ 0 means unlimited (no pruning). | |
| 63 | +func PruneHistory(historyDir, base string, keep int) error { | |
| 64 | + if keep <= 0 { | |
| 65 | + return nil | |
| 66 | + } | |
| 67 | + entries, err := listHistory(historyDir, base) | |
| 68 | + if err != nil { | |
| 69 | + return err | |
| 70 | + } | |
| 71 | + for len(entries) > keep { | |
| 72 | + oldest := entries[0] | |
| 73 | + if err := os.Remove(filepath.Join(historyDir, oldest.Filename)); err != nil && !os.IsNotExist(err) { | |
| 74 | + return fmt.Errorf("config history: remove %s: %w", oldest.Filename, err) | |
| 75 | + } | |
| 76 | + entries = entries[1:] | |
| 77 | + } | |
| 78 | + return nil | |
| 79 | +} | |
| 80 | + | |
| 81 | +// ListHistory returns all snapshots for base (the config file basename) in | |
| 82 | +// historyDir, sorted oldest-first. | |
| 83 | +func ListHistory(historyDir, base string) ([]HistoryEntry, error) { | |
| 84 | + return listHistory(historyDir, base) | |
| 85 | +} | |
| 86 | + | |
| 87 | +func listHistory(historyDir, base string) ([]HistoryEntry, error) { | |
| 88 | + des, err := os.ReadDir(historyDir) | |
| 89 | + if os.IsNotExist(err) { | |
| 90 | + return nil, nil | |
| 91 | + } | |
| 92 | + if err != nil { | |
| 93 | + return nil, fmt.Errorf("config history: readdir %s: %w", historyDir, err) | |
| 94 | + } | |
| 95 | + var out []HistoryEntry | |
| 96 | + prefix := base + "." | |
| 97 | + for _, de := range des { | |
| 98 | + if de.IsDir() || !strings.HasPrefix(de.Name(), prefix) { | |
| 99 | + continue | |
| 100 | + } | |
| 101 | + stamp := strings.TrimPrefix(de.Name(), prefix) | |
| 102 | + t, err := time.ParseInLocation(historyTimestampFormat, stamp, time.Local) | |
| 103 | + if err != nil { | |
| 104 | + continue // skip files with non-matching suffix | |
| 105 | + } | |
| 106 | + info, err := de.Info() | |
| 107 | + if err != nil { | |
| 108 | + continue | |
| 109 | + } | |
| 110 | + out = append(out, HistoryEntry{ | |
| 111 | + Filename: de.Name(), | |
| 112 | + Timestamp: t, | |
| 113 | + Size: info.Size(), | |
| 114 | + }) | |
| 115 | + } | |
| 116 | + sort.Slice(out, func(i, j int) bool { | |
| 117 | + return out[i].Timestamp.Before(out[j].Timestamp) | |
| 118 | + }) | |
| 119 | + return out, nil | |
| 120 | +} |
| --- a/internal/config/history.go | |
| +++ b/internal/config/history.go | |
| @@ -0,0 +1,120 @@ | |
| --- a/internal/config/history.go | |
| +++ b/internal/config/history.go | |
| @@ -0,0 +1,120 @@ | |
| 1 | package config |
| 2 | |
| 3 | import ( |
| 4 | "fmt" |
| 5 | "io" |
| 6 | "os" |
| 7 | "path/filepath" |
| 8 | "sort" |
| 9 | "strings" |
| 10 | "time" |
| 11 | ) |
| 12 | |
| 13 | // HistoryEntry describes a single config snapshot in the history directory. |
| 14 | type HistoryEntry struct { |
| 15 | // Filename is the base name of the snapshot file (e.g. "scuttlebot.yaml.20260402-143022"). |
| 16 | Filename string `json:"filename"` |
| 17 | |
| 18 | // Timestamp is when the snapshot was taken, parsed from the filename. |
| 19 | Timestamp time.Time `json:"timestamp"` |
| 20 | |
| 21 | // Size is the file size in bytes. |
| 22 | Size int64 `json:"size"` |
| 23 | } |
| 24 | |
| 25 | const historyTimestampFormat = "20060102-150405" |
| 26 | |
| 27 | // SnapshotConfig copies the file at configPath into historyDir, naming it |
| 28 | // "<basename>.<timestamp>". It creates historyDir if it does not exist. |
| 29 | // It is a no-op if configPath does not exist yet. |
| 30 | func SnapshotConfig(historyDir, configPath string) error { |
| 31 | src, err := os.Open(configPath) |
| 32 | if os.IsNotExist(err) { |
| 33 | return nil // nothing to snapshot |
| 34 | } |
| 35 | if err != nil { |
| 36 | return fmt.Errorf("config history: open %s: %w", configPath, err) |
| 37 | } |
| 38 | defer src.Close() |
| 39 | |
| 40 | if err := os.MkdirAll(historyDir, 0o700); err != nil { |
| 41 | return fmt.Errorf("config history: mkdir %s: %w", historyDir, err) |
| 42 | } |
| 43 | |
| 44 | base := filepath.Base(configPath) |
| 45 | stamp := time.Now().Format(historyTimestampFormat) |
| 46 | dst := filepath.Join(historyDir, base+"."+stamp) |
| 47 | |
| 48 | out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600) |
| 49 | if err != nil { |
| 50 | return fmt.Errorf("config history: create snapshot %s: %w", dst, err) |
| 51 | } |
| 52 | defer out.Close() |
| 53 | |
| 54 | if _, err := io.Copy(out, src); err != nil { |
| 55 | return fmt.Errorf("config history: write snapshot %s: %w", dst, err) |
| 56 | } |
| 57 | return nil |
| 58 | } |
| 59 | |
| 60 | // PruneHistory removes the oldest snapshots in historyDir until at most keep |
| 61 | // files remain. It only considers files whose names start with base (the |
| 62 | // basename of the config file). keep ≤ 0 means unlimited (no pruning). |
| 63 | func PruneHistory(historyDir, base string, keep int) error { |
| 64 | if keep <= 0 { |
| 65 | return nil |
| 66 | } |
| 67 | entries, err := listHistory(historyDir, base) |
| 68 | if err != nil { |
| 69 | return err |
| 70 | } |
| 71 | for len(entries) > keep { |
| 72 | oldest := entries[0] |
| 73 | if err := os.Remove(filepath.Join(historyDir, oldest.Filename)); err != nil && !os.IsNotExist(err) { |
| 74 | return fmt.Errorf("config history: remove %s: %w", oldest.Filename, err) |
| 75 | } |
| 76 | entries = entries[1:] |
| 77 | } |
| 78 | return nil |
| 79 | } |
| 80 | |
| 81 | // ListHistory returns all snapshots for base (the config file basename) in |
| 82 | // historyDir, sorted oldest-first. |
| 83 | func ListHistory(historyDir, base string) ([]HistoryEntry, error) { |
| 84 | return listHistory(historyDir, base) |
| 85 | } |
| 86 | |
| 87 | func listHistory(historyDir, base string) ([]HistoryEntry, error) { |
| 88 | des, err := os.ReadDir(historyDir) |
| 89 | if os.IsNotExist(err) { |
| 90 | return nil, nil |
| 91 | } |
| 92 | if err != nil { |
| 93 | return nil, fmt.Errorf("config history: readdir %s: %w", historyDir, err) |
| 94 | } |
| 95 | var out []HistoryEntry |
| 96 | prefix := base + "." |
| 97 | for _, de := range des { |
| 98 | if de.IsDir() || !strings.HasPrefix(de.Name(), prefix) { |
| 99 | continue |
| 100 | } |
| 101 | stamp := strings.TrimPrefix(de.Name(), prefix) |
| 102 | t, err := time.ParseInLocation(historyTimestampFormat, stamp, time.Local) |
| 103 | if err != nil { |
| 104 | continue // skip files with non-matching suffix |
| 105 | } |
| 106 | info, err := de.Info() |
| 107 | if err != nil { |
| 108 | continue |
| 109 | } |
| 110 | out = append(out, HistoryEntry{ |
| 111 | Filename: de.Name(), |
| 112 | Timestamp: t, |
| 113 | Size: info.Size(), |
| 114 | }) |
| 115 | } |
| 116 | sort.Slice(out, func(i, j int) bool { |
| 117 | return out[i].Timestamp.Before(out[j].Timestamp) |
| 118 | }) |
| 119 | return out, nil |
| 120 | } |
| --- a/internal/config/history_test.go | ||
| +++ b/internal/config/history_test.go | ||
| @@ -0,0 +1,104 @@ | ||
| 1 | +package config | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "os" | |
| 5 | + "path/filepath" | |
| 6 | + "testing" | |
| 7 | + "time" | |
| 8 | +) | |
| 9 | + | |
| 10 | +func TestSnapshotAndPrune(t *testing.T) { | |
| 11 | + dir := t.TempDir() | |
| 12 | + histDir := filepath.Join(dir, "history") | |
| 13 | + configPath := filepath.Join(dir, "scuttlebot.yaml") | |
| 14 | + | |
| 15 | + // No-op when config file doesn't exist yet. | |
| 16 | + if err := SnapshotConfig(histDir, configPath); err != nil { | |
| 17 | + t.Fatalf("SnapshotConfig (no file): %v", err) | |
| 18 | + } | |
| 19 | + | |
| 20 | + // Write a config file and snapshot it. | |
| 21 | + if err := os.WriteFile(configPath, []byte("bridge:\n enabled: true\n"), 0o600); err != nil { | |
| 22 | + t.Fatal(err) | |
| 23 | + } | |
| 24 | + if err := SnapshotConfig(histDir, configPath); err != nil { | |
| 25 | + t.Fatalf("SnapshotConfig: %v", err) | |
| 26 | + } | |
| 27 | + | |
| 28 | + entries, err := ListHistory(histDir, "scuttlebot.yaml") | |
| 29 | + if err != nil { | |
| 30 | + t.Fatal(err) | |
| 31 | + } | |
| 32 | + if len(entries) != 1 { | |
| 33 | + t.Fatalf("want 1 snapshot, got %d", len(entries)) | |
| 34 | + } | |
| 35 | + if entries[0].Size == 0 { | |
| 36 | + t.Error("snapshot size should be non-zero") | |
| 37 | + } | |
| 38 | + if entries[0].Timestamp.IsZero() { | |
| 39 | + t.Error("snapshot timestamp should be set") | |
| 40 | + } | |
| 41 | +} | |
| 42 | + | |
| 43 | +func TestPruneHistory(t *testing.T) { | |
| 44 | + dir := t.TempDir() | |
| 45 | + base := "scuttlebot.yaml" | |
| 46 | + keep := 3 | |
| 47 | + | |
| 48 | + // Write 5 snapshot files with distinct timestamps. | |
| 49 | + for i := range 5 { | |
| 50 | + stamp := time.Now().Add(time.Duration(i) * time.Second).Format(historyTimestampFormat) | |
| 51 | + name := filepath.Join(dir, base+"."+stamp) | |
| 52 | + if err := os.WriteFile(name, []byte("v"), 0o600); err != nil { | |
| 53 | + t.Fatal(err) | |
| 54 | + } | |
| 55 | + // Ensure distinct mtime ordering. | |
| 56 | + time.Sleep(2 * time.Millisecond) | |
| 57 | + } | |
| 58 | + | |
| 59 | + if err := PruneHistory(dir, base, keep); err != nil { | |
| 60 | + t.Fatal(err) | |
| 61 | + } | |
| 62 | + | |
| 63 | + entries, err := ListHistory(dir, base) | |
| 64 | + if err != nil { | |
| 65 | + t.Fatal(err) | |
| 66 | + } | |
| 67 | + if len(entries) != keep { | |
| 68 | + t.Errorf("want %d entries after prune, got %d", keep, len(entries)) | |
| 69 | + } | |
| 70 | +} | |
| 71 | + | |
| 72 | +func TestConfigSaveRoundTrip(t *testing.T) { | |
| 73 | + dir := t.TempDir() | |
| 74 | + path := filepath.Join(dir, "scuttlebot.yaml") | |
| 75 | + | |
| 76 | + var cfg Config | |
| 77 | + cfg.Defaults() | |
| 78 | + cfg.Topology.Channels = []StaticChannelConfig{ | |
| 79 | + {Name: "#general", Topic: "Fleet coordination", Autojoin: []string{"bridge"}}, | |
| 80 | + } | |
| 81 | + cfg.Topology.Types = []ChannelTypeConfig{ | |
| 82 | + {Name: "task", Prefix: "task.", Ephemeral: true, TTL: Duration{72 * time.Hour}}, | |
| 83 | + } | |
| 84 | + | |
| 85 | + if err := cfg.Save(path); err != nil { | |
| 86 | + t.Fatalf("Save: %v", err) | |
| 87 | + } | |
| 88 | + | |
| 89 | + var loaded Config | |
| 90 | + loaded.Defaults() | |
| 91 | + if err := loaded.LoadFile(path); err != nil { | |
| 92 | + t.Fatalf("LoadFile: %v", err) | |
| 93 | + } | |
| 94 | + | |
| 95 | + if len(loaded.Topology.Channels) != 1 || loaded.Topology.Channels[0].Name != "#general" { | |
| 96 | + t.Errorf("topology channels not round-tripped: %+v", loaded.Topology.Channels) | |
| 97 | + } | |
| 98 | + if len(loaded.Topology.Types) != 1 || loaded.Topology.Types[0].Name != "task" { | |
| 99 | + t.Errorf("topology types not round-tripped: %+v", loaded.Topology.Types) | |
| 100 | + } | |
| 101 | + if loaded.Topology.Types[0].TTL.Duration != 72*time.Hour { | |
| 102 | + t.Errorf("TTL = %v, want 72h", loaded.Topology.Types[0].TTL.Duration) | |
| 103 | + } | |
| 104 | +} |
| --- a/internal/config/history_test.go | |
| +++ b/internal/config/history_test.go | |
| @@ -0,0 +1,104 @@ | |
| --- a/internal/config/history_test.go | |
| +++ b/internal/config/history_test.go | |
| @@ -0,0 +1,104 @@ | |
| 1 | package config |
| 2 | |
| 3 | import ( |
| 4 | "os" |
| 5 | "path/filepath" |
| 6 | "testing" |
| 7 | "time" |
| 8 | ) |
| 9 | |
| 10 | func TestSnapshotAndPrune(t *testing.T) { |
| 11 | dir := t.TempDir() |
| 12 | histDir := filepath.Join(dir, "history") |
| 13 | configPath := filepath.Join(dir, "scuttlebot.yaml") |
| 14 | |
| 15 | // No-op when config file doesn't exist yet. |
| 16 | if err := SnapshotConfig(histDir, configPath); err != nil { |
| 17 | t.Fatalf("SnapshotConfig (no file): %v", err) |
| 18 | } |
| 19 | |
| 20 | // Write a config file and snapshot it. |
| 21 | if err := os.WriteFile(configPath, []byte("bridge:\n enabled: true\n"), 0o600); err != nil { |
| 22 | t.Fatal(err) |
| 23 | } |
| 24 | if err := SnapshotConfig(histDir, configPath); err != nil { |
| 25 | t.Fatalf("SnapshotConfig: %v", err) |
| 26 | } |
| 27 | |
| 28 | entries, err := ListHistory(histDir, "scuttlebot.yaml") |
| 29 | if err != nil { |
| 30 | t.Fatal(err) |
| 31 | } |
| 32 | if len(entries) != 1 { |
| 33 | t.Fatalf("want 1 snapshot, got %d", len(entries)) |
| 34 | } |
| 35 | if entries[0].Size == 0 { |
| 36 | t.Error("snapshot size should be non-zero") |
| 37 | } |
| 38 | if entries[0].Timestamp.IsZero() { |
| 39 | t.Error("snapshot timestamp should be set") |
| 40 | } |
| 41 | } |
| 42 | |
| 43 | func TestPruneHistory(t *testing.T) { |
| 44 | dir := t.TempDir() |
| 45 | base := "scuttlebot.yaml" |
| 46 | keep := 3 |
| 47 | |
| 48 | // Write 5 snapshot files with distinct timestamps. |
| 49 | for i := range 5 { |
| 50 | stamp := time.Now().Add(time.Duration(i) * time.Second).Format(historyTimestampFormat) |
| 51 | name := filepath.Join(dir, base+"."+stamp) |
| 52 | if err := os.WriteFile(name, []byte("v"), 0o600); err != nil { |
| 53 | t.Fatal(err) |
| 54 | } |
| 55 | // Ensure distinct mtime ordering. |
| 56 | time.Sleep(2 * time.Millisecond) |
| 57 | } |
| 58 | |
| 59 | if err := PruneHistory(dir, base, keep); err != nil { |
| 60 | t.Fatal(err) |
| 61 | } |
| 62 | |
| 63 | entries, err := ListHistory(dir, base) |
| 64 | if err != nil { |
| 65 | t.Fatal(err) |
| 66 | } |
| 67 | if len(entries) != keep { |
| 68 | t.Errorf("want %d entries after prune, got %d", keep, len(entries)) |
| 69 | } |
| 70 | } |
| 71 | |
| 72 | func TestConfigSaveRoundTrip(t *testing.T) { |
| 73 | dir := t.TempDir() |
| 74 | path := filepath.Join(dir, "scuttlebot.yaml") |
| 75 | |
| 76 | var cfg Config |
| 77 | cfg.Defaults() |
| 78 | cfg.Topology.Channels = []StaticChannelConfig{ |
| 79 | {Name: "#general", Topic: "Fleet coordination", Autojoin: []string{"bridge"}}, |
| 80 | } |
| 81 | cfg.Topology.Types = []ChannelTypeConfig{ |
| 82 | {Name: "task", Prefix: "task.", Ephemeral: true, TTL: Duration{72 * time.Hour}}, |
| 83 | } |
| 84 | |
| 85 | if err := cfg.Save(path); err != nil { |
| 86 | t.Fatalf("Save: %v", err) |
| 87 | } |
| 88 | |
| 89 | var loaded Config |
| 90 | loaded.Defaults() |
| 91 | if err := loaded.LoadFile(path); err != nil { |
| 92 | t.Fatalf("LoadFile: %v", err) |
| 93 | } |
| 94 | |
| 95 | if len(loaded.Topology.Channels) != 1 || loaded.Topology.Channels[0].Name != "#general" { |
| 96 | t.Errorf("topology channels not round-tripped: %+v", loaded.Topology.Channels) |
| 97 | } |
| 98 | if len(loaded.Topology.Types) != 1 || loaded.Topology.Types[0].Name != "task" { |
| 99 | t.Errorf("topology types not round-tripped: %+v", loaded.Topology.Types) |
| 100 | } |
| 101 | if loaded.Topology.Types[0].TTL.Duration != 72*time.Hour { |
| 102 | t.Errorf("TTL = %v, want 72h", loaded.Topology.Types[0].TTL.Duration) |
| 103 | } |
| 104 | } |