|
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
|
} |
|
121
|
|