|
17e2c1d…
|
lmata
|
1 |
package config |
|
17e2c1d…
|
lmata
|
2 |
|
|
17e2c1d…
|
lmata
|
3 |
import ( |
|
17e2c1d…
|
lmata
|
4 |
"fmt" |
|
17e2c1d…
|
lmata
|
5 |
"io" |
|
17e2c1d…
|
lmata
|
6 |
"os" |
|
17e2c1d…
|
lmata
|
7 |
"path/filepath" |
|
17e2c1d…
|
lmata
|
8 |
"sort" |
|
17e2c1d…
|
lmata
|
9 |
"strings" |
|
17e2c1d…
|
lmata
|
10 |
"time" |
|
17e2c1d…
|
lmata
|
11 |
) |
|
17e2c1d…
|
lmata
|
12 |
|
|
17e2c1d…
|
lmata
|
13 |
// HistoryEntry describes a single config snapshot in the history directory. |
|
17e2c1d…
|
lmata
|
14 |
type HistoryEntry struct { |
|
17e2c1d…
|
lmata
|
15 |
// Filename is the base name of the snapshot file (e.g. "scuttlebot.yaml.20260402-143022"). |
|
17e2c1d…
|
lmata
|
16 |
Filename string `json:"filename"` |
|
17e2c1d…
|
lmata
|
17 |
|
|
17e2c1d…
|
lmata
|
18 |
// Timestamp is when the snapshot was taken, parsed from the filename. |
|
17e2c1d…
|
lmata
|
19 |
Timestamp time.Time `json:"timestamp"` |
|
17e2c1d…
|
lmata
|
20 |
|
|
17e2c1d…
|
lmata
|
21 |
// Size is the file size in bytes. |
|
17e2c1d…
|
lmata
|
22 |
Size int64 `json:"size"` |
|
17e2c1d…
|
lmata
|
23 |
} |
|
17e2c1d…
|
lmata
|
24 |
|
|
17e2c1d…
|
lmata
|
25 |
const historyTimestampFormat = "20060102-150405" |
|
17e2c1d…
|
lmata
|
26 |
|
|
17e2c1d…
|
lmata
|
27 |
// SnapshotConfig copies the file at configPath into historyDir, naming it |
|
17e2c1d…
|
lmata
|
28 |
// "<basename>.<timestamp>". It creates historyDir if it does not exist. |
|
17e2c1d…
|
lmata
|
29 |
// It is a no-op if configPath does not exist yet. |
|
17e2c1d…
|
lmata
|
30 |
func SnapshotConfig(historyDir, configPath string) error { |
|
17e2c1d…
|
lmata
|
31 |
src, err := os.Open(configPath) |
|
17e2c1d…
|
lmata
|
32 |
if os.IsNotExist(err) { |
|
17e2c1d…
|
lmata
|
33 |
return nil // nothing to snapshot |
|
17e2c1d…
|
lmata
|
34 |
} |
|
17e2c1d…
|
lmata
|
35 |
if err != nil { |
|
17e2c1d…
|
lmata
|
36 |
return fmt.Errorf("config history: open %s: %w", configPath, err) |
|
17e2c1d…
|
lmata
|
37 |
} |
|
17e2c1d…
|
lmata
|
38 |
defer src.Close() |
|
17e2c1d…
|
lmata
|
39 |
|
|
17e2c1d…
|
lmata
|
40 |
if err := os.MkdirAll(historyDir, 0o700); err != nil { |
|
17e2c1d…
|
lmata
|
41 |
return fmt.Errorf("config history: mkdir %s: %w", historyDir, err) |
|
17e2c1d…
|
lmata
|
42 |
} |
|
17e2c1d…
|
lmata
|
43 |
|
|
17e2c1d…
|
lmata
|
44 |
base := filepath.Base(configPath) |
|
17e2c1d…
|
lmata
|
45 |
stamp := time.Now().Format(historyTimestampFormat) |
|
17e2c1d…
|
lmata
|
46 |
dst := filepath.Join(historyDir, base+"."+stamp) |
|
17e2c1d…
|
lmata
|
47 |
|
|
17e2c1d…
|
lmata
|
48 |
out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600) |
|
17e2c1d…
|
lmata
|
49 |
if err != nil { |
|
17e2c1d…
|
lmata
|
50 |
return fmt.Errorf("config history: create snapshot %s: %w", dst, err) |
|
17e2c1d…
|
lmata
|
51 |
} |
|
17e2c1d…
|
lmata
|
52 |
defer out.Close() |
|
17e2c1d…
|
lmata
|
53 |
|
|
17e2c1d…
|
lmata
|
54 |
if _, err := io.Copy(out, src); err != nil { |
|
17e2c1d…
|
lmata
|
55 |
return fmt.Errorf("config history: write snapshot %s: %w", dst, err) |
|
17e2c1d…
|
lmata
|
56 |
} |
|
17e2c1d…
|
lmata
|
57 |
return nil |
|
17e2c1d…
|
lmata
|
58 |
} |
|
17e2c1d…
|
lmata
|
59 |
|
|
17e2c1d…
|
lmata
|
60 |
// PruneHistory removes the oldest snapshots in historyDir until at most keep |
|
17e2c1d…
|
lmata
|
61 |
// files remain. It only considers files whose names start with base (the |
|
17e2c1d…
|
lmata
|
62 |
// basename of the config file). keep ≤ 0 means unlimited (no pruning). |
|
17e2c1d…
|
lmata
|
63 |
func PruneHistory(historyDir, base string, keep int) error { |
|
17e2c1d…
|
lmata
|
64 |
if keep <= 0 { |
|
17e2c1d…
|
lmata
|
65 |
return nil |
|
17e2c1d…
|
lmata
|
66 |
} |
|
17e2c1d…
|
lmata
|
67 |
entries, err := listHistory(historyDir, base) |
|
17e2c1d…
|
lmata
|
68 |
if err != nil { |
|
17e2c1d…
|
lmata
|
69 |
return err |
|
17e2c1d…
|
lmata
|
70 |
} |
|
17e2c1d…
|
lmata
|
71 |
for len(entries) > keep { |
|
17e2c1d…
|
lmata
|
72 |
oldest := entries[0] |
|
17e2c1d…
|
lmata
|
73 |
if err := os.Remove(filepath.Join(historyDir, oldest.Filename)); err != nil && !os.IsNotExist(err) { |
|
17e2c1d…
|
lmata
|
74 |
return fmt.Errorf("config history: remove %s: %w", oldest.Filename, err) |
|
17e2c1d…
|
lmata
|
75 |
} |
|
17e2c1d…
|
lmata
|
76 |
entries = entries[1:] |
|
17e2c1d…
|
lmata
|
77 |
} |
|
17e2c1d…
|
lmata
|
78 |
return nil |
|
17e2c1d…
|
lmata
|
79 |
} |
|
17e2c1d…
|
lmata
|
80 |
|
|
17e2c1d…
|
lmata
|
81 |
// ListHistory returns all snapshots for base (the config file basename) in |
|
17e2c1d…
|
lmata
|
82 |
// historyDir, sorted oldest-first. |
|
17e2c1d…
|
lmata
|
83 |
func ListHistory(historyDir, base string) ([]HistoryEntry, error) { |
|
17e2c1d…
|
lmata
|
84 |
return listHistory(historyDir, base) |
|
17e2c1d…
|
lmata
|
85 |
} |
|
17e2c1d…
|
lmata
|
86 |
|
|
17e2c1d…
|
lmata
|
87 |
func listHistory(historyDir, base string) ([]HistoryEntry, error) { |
|
17e2c1d…
|
lmata
|
88 |
des, err := os.ReadDir(historyDir) |
|
17e2c1d…
|
lmata
|
89 |
if os.IsNotExist(err) { |
|
17e2c1d…
|
lmata
|
90 |
return nil, nil |
|
17e2c1d…
|
lmata
|
91 |
} |
|
17e2c1d…
|
lmata
|
92 |
if err != nil { |
|
17e2c1d…
|
lmata
|
93 |
return nil, fmt.Errorf("config history: readdir %s: %w", historyDir, err) |
|
17e2c1d…
|
lmata
|
94 |
} |
|
17e2c1d…
|
lmata
|
95 |
var out []HistoryEntry |
|
17e2c1d…
|
lmata
|
96 |
prefix := base + "." |
|
17e2c1d…
|
lmata
|
97 |
for _, de := range des { |
|
17e2c1d…
|
lmata
|
98 |
if de.IsDir() || !strings.HasPrefix(de.Name(), prefix) { |
|
17e2c1d…
|
lmata
|
99 |
continue |
|
17e2c1d…
|
lmata
|
100 |
} |
|
17e2c1d…
|
lmata
|
101 |
stamp := strings.TrimPrefix(de.Name(), prefix) |
|
17e2c1d…
|
lmata
|
102 |
t, err := time.ParseInLocation(historyTimestampFormat, stamp, time.Local) |
|
17e2c1d…
|
lmata
|
103 |
if err != nil { |
|
17e2c1d…
|
lmata
|
104 |
continue // skip files with non-matching suffix |
|
17e2c1d…
|
lmata
|
105 |
} |
|
17e2c1d…
|
lmata
|
106 |
info, err := de.Info() |
|
17e2c1d…
|
lmata
|
107 |
if err != nil { |
|
17e2c1d…
|
lmata
|
108 |
continue |
|
17e2c1d…
|
lmata
|
109 |
} |
|
17e2c1d…
|
lmata
|
110 |
out = append(out, HistoryEntry{ |
|
17e2c1d…
|
lmata
|
111 |
Filename: de.Name(), |
|
17e2c1d…
|
lmata
|
112 |
Timestamp: t, |
|
17e2c1d…
|
lmata
|
113 |
Size: info.Size(), |
|
17e2c1d…
|
lmata
|
114 |
}) |
|
17e2c1d…
|
lmata
|
115 |
} |
|
17e2c1d…
|
lmata
|
116 |
sort.Slice(out, func(i, j int) bool { |
|
17e2c1d…
|
lmata
|
117 |
return out[i].Timestamp.Before(out[j].Timestamp) |
|
17e2c1d…
|
lmata
|
118 |
}) |
|
17e2c1d…
|
lmata
|
119 |
return out, nil |
|
17e2c1d…
|
lmata
|
120 |
} |