detached 3b9844b

feat: rotate config backups with timestamped names, keep only 5 most recent

Replace single-file .bak overwrite with timestamped backups (.bak.<unix>)
and automatic cleanup to prevent config directory pollution. Restore
falls back to legacy .bak for backward compatibility.
commit
3b9844b02440afe56bc44f381c3d3e4fa11bc93e
author
brice · · 2026-03-14 05:16 UTC
parent
7d7aff9
signature
unsigned
2 files · +120 -4
M internal/usecase/config/service.go +72 -1
@@ -2,10 +2,13 @@
22 package config
33
44 import (
5+ "cmp"
56 "context"
67 "fmt"
78 "os"
9+ "path/filepath"
810 "reflect"
11+ "slices"
912 "strings"
1013 "sync"
1114 "sync/atomic"
@@ -572,16 +575,84 @@ func backupConfigFile(configFile string) error {
572575 return fmt.Errorf("failed to read config for backup: %w", err)
573576 }
574577
575- backupPath := configFile + ".bak"
578+ backupPath := fmt.Sprintf("%s.bak.%d", configFile, time.Now().Unix())
576579 if err := os.WriteFile(backupPath, src, 0600); err != nil {
577580 return fmt.Errorf("failed to write backup config: %w", err)
578581 }
582+ if err := cleanupOldBackups(configFile, 5); err != nil {
583+ return fmt.Errorf("failed to clean up old backups: %w", err)
584+ }
579585
580586 return nil
581587 }
582588
589+// cleanupOldBackups removes old backup files, keeping only the most recent `keep` backups.
590+// It only removes files matching the pattern `<configFile>.bak.<timestamp>`.
591+func cleanupOldBackups(configFile string, keep int) error {
592+ dir := filepath.Dir(configFile)
593+ base := filepath.Base(configFile)
594+ entries, err := os.ReadDir(dir)
595+ if err != nil {
596+ return fmt.Errorf("failed to read backup directory: %w", err)
597+ }
598+
599+ prefix := base + ".bak."
600+ backups := make([]string, 0)
601+ for _, entry := range entries {
602+ if entry.IsDir() {
603+ continue
604+ }
605+ if strings.HasPrefix(entry.Name(), prefix) {
606+ backups = append(backups, entry.Name())
607+ }
608+ }
609+
610+ slices.SortFunc(backups, func(a, b string) int {
611+ return cmp.Compare(b, a)
612+ })
613+ if len(backups) <= keep {
614+ return nil
615+ }
616+
617+ var firstErr error
618+ for _, backup := range backups[keep:] {
619+ backupPath := filepath.Join(dir, backup)
620+ if err := os.Remove(backupPath); err != nil && firstErr == nil {
621+ firstErr = fmt.Errorf("failed to remove old backup %s: %w", backupPath, err)
622+ }
623+ }
624+
625+ return firstErr
626+}
627+
583628 func restoreConfigBackup(configFile string) error {
629+ dir := filepath.Dir(configFile)
630+ base := filepath.Base(configFile)
631+ entries, err := os.ReadDir(dir)
632+ if err != nil {
633+ return fmt.Errorf("failed to read backup directory: %w", err)
634+ }
635+
636+ prefix := base + ".bak."
637+ backups := make([]string, 0)
638+ for _, entry := range entries {
639+ if entry.IsDir() {
640+ continue
641+ }
642+ if strings.HasPrefix(entry.Name(), prefix) {
643+ backups = append(backups, entry.Name())
644+ }
645+ }
646+
647+ slices.SortFunc(backups, func(a, b string) int {
648+ return cmp.Compare(b, a)
649+ })
650+
584651 backupPath := configFile + ".bak"
652+ if len(backups) > 0 {
653+ backupPath = filepath.Join(dir, backups[0])
654+ }
655+
585656 src, err := os.ReadFile(backupPath)
586657 if err != nil {
587658 return fmt.Errorf("failed to read backup: %w", err)
@@ -2,10 +2,13 @@
2 package config2 package config
3 3
4 import (4 import (
5+ "cmp"
5 "context"6 "context"
6 "fmt"7 "fmt"
7 "os"8 "os"
9+ "path/filepath"
8 "reflect"10 "reflect"
11+ "slices"
9 "strings"12 "strings"
10 "sync"13 "sync"
11 "sync/atomic"14 "sync/atomic"
@@ -572,16 +575,84 @@ func backupConfigFile(configFile string) error {
572 return fmt.Errorf("failed to read config for backup: %w", err)575 return fmt.Errorf("failed to read config for backup: %w", err)
573 }576 }
574 577
575- backupPath := configFile + ".bak"578+ backupPath := fmt.Sprintf("%s.bak.%d", configFile, time.Now().Unix())
576 if err := os.WriteFile(backupPath, src, 0600); err != nil {579 if err := os.WriteFile(backupPath, src, 0600); err != nil {
577 return fmt.Errorf("failed to write backup config: %w", err)580 return fmt.Errorf("failed to write backup config: %w", err)
578 }581 }
582+ if err := cleanupOldBackups(configFile, 5); err != nil {
583+ return fmt.Errorf("failed to clean up old backups: %w", err)
584+ }
579 585
580 return nil586 return nil
581 }587 }
582 588
589+// cleanupOldBackups removes old backup files, keeping only the most recent `keep` backups.
590+// It only removes files matching the pattern `<configFile>.bak.<timestamp>`.
591+func cleanupOldBackups(configFile string, keep int) error {
592+ dir := filepath.Dir(configFile)
593+ base := filepath.Base(configFile)
594+ entries, err := os.ReadDir(dir)
595+ if err != nil {
596+ return fmt.Errorf("failed to read backup directory: %w", err)
597+ }
598+
599+ prefix := base + ".bak."
600+ backups := make([]string, 0)
601+ for _, entry := range entries {
602+ if entry.IsDir() {
603+ continue
604+ }
605+ if strings.HasPrefix(entry.Name(), prefix) {
606+ backups = append(backups, entry.Name())
607+ }
608+ }
609+
610+ slices.SortFunc(backups, func(a, b string) int {
611+ return cmp.Compare(b, a)
612+ })
613+ if len(backups) <= keep {
614+ return nil
615+ }
616+
617+ var firstErr error
618+ for _, backup := range backups[keep:] {
619+ backupPath := filepath.Join(dir, backup)
620+ if err := os.Remove(backupPath); err != nil && firstErr == nil {
621+ firstErr = fmt.Errorf("failed to remove old backup %s: %w", backupPath, err)
622+ }
623+ }
624+
625+ return firstErr
626+}
627+
583 func restoreConfigBackup(configFile string) error {628 func restoreConfigBackup(configFile string) error {
629+ dir := filepath.Dir(configFile)
630+ base := filepath.Base(configFile)
631+ entries, err := os.ReadDir(dir)
632+ if err != nil {
633+ return fmt.Errorf("failed to read backup directory: %w", err)
634+ }
635+
636+ prefix := base + ".bak."
637+ backups := make([]string, 0)
638+ for _, entry := range entries {
639+ if entry.IsDir() {
640+ continue
641+ }
642+ if strings.HasPrefix(entry.Name(), prefix) {
643+ backups = append(backups, entry.Name())
644+ }
645+ }
646+
647+ slices.SortFunc(backups, func(a, b string) int {
648+ return cmp.Compare(b, a)
649+ })
650+
584 backupPath := configFile + ".bak"651 backupPath := configFile + ".bak"
652+ if len(backups) > 0 {
653+ backupPath = filepath.Join(dir, backups[0])
654+ }
655+
585 src, err := os.ReadFile(backupPath)656 src, err := os.ReadFile(backupPath)
586 if err != nil {657 if err != nil {
587 return fmt.Errorf("failed to read backup: %w", err)658 return fmt.Errorf("failed to read backup: %w", err)
M internal/usecase/config/service_test.go +48 -3
@@ -2,6 +2,7 @@ package config
22
33 import (
44 "context"
5+ "fmt"
56 "os"
67 "path/filepath"
78 "testing"
@@ -1248,8 +1249,9 @@ backend = ["app.example.com"]
12481249 assert.Equal(t, "newapp:latest", savedRoutes["new.example.com"])
12491250 assert.Equal(t, "reg.example.com/myapp:latest", savedRoutes["app.example.com"])
12501251
1251- _, err = os.Stat(configFile + ".bak")
1252- assert.NoError(t, err)
1252+ matches, err := filepath.Glob(configFile + ".bak.*")
1253+ require.NoError(t, err)
1254+ assert.NotEmpty(t, matches)
12531255 })
12541256
12551257 t.Run("backup created before write", func(t *testing.T) {
@@ -1279,12 +1281,55 @@ port = 9999
12791281 err = svc.AddRoute(ctx, domain.Route{Domain: "new.example.com", Image: "newapp:latest"})
12801282 require.NoError(t, err)
12811283
1282- backupData, err := os.ReadFile(configFile + ".bak")
1284+ matches, err := filepath.Glob(configFile + ".bak.*")
1285+ require.NoError(t, err)
1286+ require.NotEmpty(t, matches)
1287+
1288+ backupData, err := os.ReadFile(matches[0])
12831289 require.NoError(t, err)
12841290 assert.Contains(t, string(backupData), "port = 9999")
12851291 assert.NotContains(t, string(backupData), "new.example.com")
12861292 })
12871293
1294+ t.Run("old backups are cleaned up keeping 5", func(t *testing.T) {
1295+ tmpDir := t.TempDir()
1296+ configFile := filepath.Join(tmpDir, "gordon.toml")
1297+ initialConfig := `
1298+[server]
1299+port = 9999
1300+
1301+[routes]
1302+"app.example.com" = "myapp:latest"
1303+`
1304+ err := os.WriteFile(configFile, []byte(initialConfig), 0600)
1305+ require.NoError(t, err)
1306+
1307+ for i := 1000; i < 1007; i++ {
1308+ backupPath := fmt.Sprintf("%s.bak.%d", configFile, i)
1309+ err := os.WriteFile(backupPath, []byte("old backup"), 0600)
1310+ require.NoError(t, err)
1311+ }
1312+
1313+ v := viper.New()
1314+ v.SetConfigFile(configFile)
1315+ err = v.ReadInConfig()
1316+ require.NoError(t, err)
1317+
1318+ eventBus := mocks.NewMockEventPublisher(t)
1319+ svc := NewService(v, eventBus)
1320+ ctx := testContext()
1321+ err = svc.Load(ctx)
1322+ require.NoError(t, err)
1323+
1324+ err = svc.AddRoute(ctx, domain.Route{Domain: "new.example.com", Image: "newapp:latest"})
1325+ require.NoError(t, err)
1326+
1327+ matches, err := filepath.Glob(configFile + ".bak.*")
1328+ require.NoError(t, err)
1329+ assert.LessOrEqual(t, len(matches), 5)
1330+ assert.GreaterOrEqual(t, len(matches), 1)
1331+ })
1332+
12881333 t.Run("network_groups are persisted to disk", func(t *testing.T) {
12891334 // Setup: config file with auth, server, and network_groups
12901335 tmpDir := t.TempDir()
@@ -2,6 +2,7 @@ package config
2 2
3 import (3 import (
4 "context"4 "context"
5+ "fmt"
5 "os"6 "os"
6 "path/filepath"7 "path/filepath"
7 "testing"8 "testing"
@@ -1248,8 +1249,9 @@ backend = ["app.example.com"]
1248 assert.Equal(t, "newapp:latest", savedRoutes["new.example.com"])1249 assert.Equal(t, "newapp:latest", savedRoutes["new.example.com"])
1249 assert.Equal(t, "reg.example.com/myapp:latest", savedRoutes["app.example.com"])1250 assert.Equal(t, "reg.example.com/myapp:latest", savedRoutes["app.example.com"])
1250 1251
1251- _, err = os.Stat(configFile + ".bak")1252+ matches, err := filepath.Glob(configFile + ".bak.*")
1252- assert.NoError(t, err)1253+ require.NoError(t, err)
1254+ assert.NotEmpty(t, matches)
1253 })1255 })
1254 1256
1255 t.Run("backup created before write", func(t *testing.T) {1257 t.Run("backup created before write", func(t *testing.T) {
@@ -1279,12 +1281,55 @@ port = 9999
1279 err = svc.AddRoute(ctx, domain.Route{Domain: "new.example.com", Image: "newapp:latest"})1281 err = svc.AddRoute(ctx, domain.Route{Domain: "new.example.com", Image: "newapp:latest"})
1280 require.NoError(t, err)1282 require.NoError(t, err)
1281 1283
1282- backupData, err := os.ReadFile(configFile + ".bak")1284+ matches, err := filepath.Glob(configFile + ".bak.*")
1285+ require.NoError(t, err)
1286+ require.NotEmpty(t, matches)
1287+
1288+ backupData, err := os.ReadFile(matches[0])
1283 require.NoError(t, err)1289 require.NoError(t, err)
1284 assert.Contains(t, string(backupData), "port = 9999")1290 assert.Contains(t, string(backupData), "port = 9999")
1285 assert.NotContains(t, string(backupData), "new.example.com")1291 assert.NotContains(t, string(backupData), "new.example.com")
1286 })1292 })
1287 1293
1294+ t.Run("old backups are cleaned up keeping 5", func(t *testing.T) {
1295+ tmpDir := t.TempDir()
1296+ configFile := filepath.Join(tmpDir, "gordon.toml")
1297+ initialConfig := `
1298+[server]
1299+port = 9999
1300+
1301+[routes]
1302+"app.example.com" = "myapp:latest"
1303+`
1304+ err := os.WriteFile(configFile, []byte(initialConfig), 0600)
1305+ require.NoError(t, err)
1306+
1307+ for i := 1000; i < 1007; i++ {
1308+ backupPath := fmt.Sprintf("%s.bak.%d", configFile, i)
1309+ err := os.WriteFile(backupPath, []byte("old backup"), 0600)
1310+ require.NoError(t, err)
1311+ }
1312+
1313+ v := viper.New()
1314+ v.SetConfigFile(configFile)
1315+ err = v.ReadInConfig()
1316+ require.NoError(t, err)
1317+
1318+ eventBus := mocks.NewMockEventPublisher(t)
1319+ svc := NewService(v, eventBus)
1320+ ctx := testContext()
1321+ err = svc.Load(ctx)
1322+ require.NoError(t, err)
1323+
1324+ err = svc.AddRoute(ctx, domain.Route{Domain: "new.example.com", Image: "newapp:latest"})
1325+ require.NoError(t, err)
1326+
1327+ matches, err := filepath.Glob(configFile + ".bak.*")
1328+ require.NoError(t, err)
1329+ assert.LessOrEqual(t, len(matches), 5)
1330+ assert.GreaterOrEqual(t, len(matches), 1)
1331+ })
1332+
1288 t.Run("network_groups are persisted to disk", func(t *testing.T) {1333 t.Run("network_groups are persisted to disk", func(t *testing.T) {
1289 // Setup: config file with auth, server, and network_groups1334 // Setup: config file with auth, server, and network_groups
1290 tmpDir := t.TempDir()1335 tmpDir := t.TempDir()

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close