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 @@ | |||
| 2 | 2 | package config | |
| 3 | 3 | ||
| 4 | 4 | import ( | |
| 5 | + | "cmp" | |
| 5 | 6 | "context" | |
| 6 | 7 | "fmt" | |
| 7 | 8 | "os" | |
| 9 | + | "path/filepath" | |
| 8 | 10 | "reflect" | |
| 11 | + | "slices" | |
| 9 | 12 | "strings" | |
| 10 | 13 | "sync" | |
| 11 | 14 | "sync/atomic" | |
| @@ -572,16 +575,84 @@ func backupConfigFile(configFile string) error { | |||
| 572 | 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 | 579 | if err := os.WriteFile(backupPath, src, 0600); err != nil { | |
| 577 | 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 | 586 | 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 | 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 | 651 | backupPath := configFile + ".bak" | |
| 652 | + | if len(backups) > 0 { | |
| 653 | + | backupPath = filepath.Join(dir, backups[0]) | |
| 654 | + | } | |
| 655 | + | ||
| 585 | 656 | src, err := os.ReadFile(backupPath) | |
| 586 | 657 | if err != nil { | |
| 587 | 658 | return fmt.Errorf("failed to read backup: %w", err) | |
| @@ -2,10 +2,13 @@ | |||||
| 2 | package config | 2 | 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 nil | 586 | 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 | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | + | "fmt" | |
| 5 | 6 | "os" | |
| 6 | 7 | "path/filepath" | |
| 7 | 8 | "testing" | |
| @@ -1248,8 +1249,9 @@ backend = ["app.example.com"] | |||
| 1248 | 1249 | assert.Equal(t, "newapp:latest", savedRoutes["new.example.com"]) | |
| 1249 | 1250 | assert.Equal(t, "reg.example.com/myapp:latest", savedRoutes["app.example.com"]) | |
| 1250 | 1251 | ||
| 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) | |
| 1253 | 1255 | }) | |
| 1254 | 1256 | ||
| 1255 | 1257 | t.Run("backup created before write", func(t *testing.T) { | |
| @@ -1279,12 +1281,55 @@ port = 9999 | |||
| 1279 | 1281 | err = svc.AddRoute(ctx, domain.Route{Domain: "new.example.com", Image: "newapp:latest"}) | |
| 1280 | 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 | 1289 | require.NoError(t, err) | |
| 1284 | 1290 | assert.Contains(t, string(backupData), "port = 9999") | |
| 1285 | 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 | 1333 | t.Run("network_groups are persisted to disk", func(t *testing.T) { | |
| 1289 | 1334 | // Setup: config file with auth, server, and network_groups | |
| 1290 | 1335 | 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_groups | 1334 | // Setup: config file with auth, server, and network_groups | ||
| 1290 | tmpDir := t.TempDir() | 1335 | tmpDir := t.TempDir() | ||