v2.30.0 3ee801c

backup_storage.go RawHistoryFolder

1package filesystem
2
3import (
4	"context"
5	"errors"
6	"fmt"
7	"io"
8	"os"
9	"path/filepath"
10	"sort"
11	"strings"
12	"syscall"
13	"time"
14
15	"github.com/bnema/zerowrap"
16
17	"github.com/bnema/gordon/internal/domain"
18)
19
20const backupTimestampLayout = "20060102T150405Z"
21
22// BackupStorage implements backup artifact persistence on local filesystem.
23type BackupStorage struct {
24	rootDir string
25	log     zerowrap.Logger
26}
27
28// NewBackupStorage creates a new filesystem backup storage.
29func NewBackupStorage(rootDir string, log zerowrap.Logger) (*BackupStorage, error) {
30	rootDir = expandTilde(rootDir)
31
32	if err := os.MkdirAll(rootDir, 0750); err != nil {
33		return nil, fmt.Errorf("failed to create backup directory: %w", err)
34	}
35
36	return &BackupStorage{rootDir: rootDir, log: log}, nil
37}
38
39// expandTilde replaces a leading "~/" with the user's home directory.
40func expandTilde(path string) string {
41	if !strings.HasPrefix(path, "~/") {
42		return path
43	}
44	if home, err := os.UserHomeDir(); err == nil {
45		return filepath.Join(home, path[2:])
46	}
47	return path
48}
49
50// Store saves backup data and returns the absolute storage path.
51func (s *BackupStorage) Store(_ context.Context, domainName, dbName string, schedule domain.BackupSchedule, timestamp time.Time, data io.Reader) (string, error) {
52	domainPart := sanitizeBackupPathComponent(domainName)
53	dbPart := sanitizeBackupPathComponent(dbName)
54	schedulePart := string(schedule)
55	if schedulePart == "" {
56		schedulePart = "manual"
57	}
58	schedulePart = sanitizeBackupPathComponent(schedulePart)
59
60	backupDir := filepath.Join(s.rootDir, domainPart, dbPart, schedulePart)
61	if err := os.MkdirAll(backupDir, 0750); err != nil {
62		return "", fmt.Errorf("failed to create backup path: %w", err)
63	}
64
65	fileName := timestamp.UTC().Format(backupTimestampLayout) + ".bak"
66	finalPath := filepath.Join(backupDir, fileName)
67	tmpPath := finalPath + ".tmp"
68
69	f, err := os.OpenFile(tmpPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
70	if err != nil {
71		return "", fmt.Errorf("failed to create temp backup file: %w", err)
72	}
73
74	if _, err := io.Copy(f, data); err != nil {
75		_ = f.Close()
76		_ = os.Remove(tmpPath)
77		return "", fmt.Errorf("failed to write backup data: %w", err)
78	}
79
80	if err := f.Close(); err != nil {
81		_ = os.Remove(tmpPath)
82		return "", fmt.Errorf("failed to close temp backup file: %w", err)
83	}
84
85	if err := os.Rename(tmpPath, finalPath); err != nil {
86		_ = os.Remove(tmpPath)
87		return "", fmt.Errorf("failed to finalize backup file: %w", err)
88	}
89
90	return finalPath, nil
91}
92
93// Get retrieves a backup file by path.
94func (s *BackupStorage) Get(_ context.Context, path string) (io.ReadCloser, error) {
95	if !pathWithinRoot(s.rootDir, path) {
96		return nil, fmt.Errorf("backup path escapes storage root")
97	}
98
99	f, err := os.Open(path)
100	if err != nil {
101		return nil, err
102	}
103	return f, nil
104}
105
106// List returns backups for a domain, optionally filtered by schedule.
107func (s *BackupStorage) List(_ context.Context, domainName string, schedule *domain.BackupSchedule) ([]domain.BackupJob, error) {
108	domainRoot := filepath.Join(s.rootDir, sanitizeBackupPathComponent(domainName))
109	if _, err := os.Stat(domainRoot); err != nil {
110		if os.IsNotExist(err) {
111			return []domain.BackupJob{}, nil
112		}
113		return nil, err
114	}
115
116	jobs := make([]domain.BackupJob, 0)
117	err := filepath.WalkDir(domainRoot, func(path string, d os.DirEntry, walkErr error) error {
118		if walkErr != nil {
119			return walkErr
120		}
121		if d.IsDir() {
122			return nil
123		}
124
125		rel, err := filepath.Rel(domainRoot, path)
126		if err != nil {
127			return nil
128		}
129		parts := strings.Split(rel, string(filepath.Separator))
130		if len(parts) != 3 {
131			return nil
132		}
133
134		dbName := parts[0]
135		sched := domain.BackupSchedule(parts[1])
136		if schedule != nil && *schedule != sched {
137			return nil
138		}
139
140		info, err := d.Info()
141		if err != nil {
142			return nil
143		}
144
145		startedAt := info.ModTime().UTC()
146		base := filepath.Base(path)
147		if ts, err := time.Parse(backupTimestampLayout+".bak", base); err == nil {
148			startedAt = ts.UTC()
149		}
150
151		jobs = append(jobs, domain.BackupJob{
152			ID:        base,
153			Domain:    domainName,
154			DBName:    dbName,
155			Schedule:  sched,
156			Type:      domain.BackupTypeLogical,
157			Status:    domain.BackupStatusCompleted,
158			StartedAt: startedAt,
159			SizeBytes: info.Size(),
160			FilePath:  path,
161		})
162
163		return nil
164	})
165	if err != nil {
166		return nil, err
167	}
168
169	sort.Slice(jobs, func(i, j int) bool {
170		return jobs[i].StartedAt.After(jobs[j].StartedAt)
171	})
172
173	return jobs, nil
174}
175
176// Delete removes a backup file.
177func (s *BackupStorage) Delete(_ context.Context, path string) error {
178	if !pathWithinRoot(s.rootDir, path) {
179		return fmt.Errorf("backup path escapes storage root")
180	}
181	return os.Remove(path)
182}
183
184// ApplyRetention removes old backups according to the schedule policy.
185func (s *BackupStorage) ApplyRetention(ctx context.Context, domainName string, policy domain.RetentionPolicy) (int, error) {
186	jobs, err := s.List(ctx, domainName, nil)
187	if err != nil {
188		return 0, err
189	}
190
191	groups := make(map[string][]domain.BackupJob)
192	for _, job := range jobs {
193		key := fmt.Sprintf("%s|%s", job.DBName, job.Schedule)
194		groups[key] = append(groups[key], job)
195	}
196
197	deleted := 0
198	for _, group := range groups {
199		sort.Slice(group, func(i, j int) bool {
200			return group[i].StartedAt.After(group[j].StartedAt)
201		})
202
203		keep := retentionKeepCount(policy, group[0].Schedule)
204		if keep < 0 {
205			continue
206		}
207
208		for idx := keep; idx < len(group); idx++ {
209			if err := s.Delete(ctx, group[idx].FilePath); err != nil {
210				if os.IsNotExist(err) {
211					continue
212				}
213				return deleted, err
214			}
215			if err := s.removeEmptyBackupDirs(group[idx].FilePath, domainName); err != nil {
216				return deleted, err
217			}
218			deleted++
219		}
220	}
221
222	return deleted, nil
223}
224
225func (s *BackupStorage) removeEmptyBackupDirs(filePath, domainName string) error {
226	backupRoot := s.rootDir
227	domainRoot := filepath.Join(backupRoot, sanitizeBackupPathComponent(domainName))
228
229	dir := filepath.Dir(filePath)
230	for {
231		if !pathWithinRoot(backupRoot, dir) {
232			return nil
233		}
234		if dir == domainRoot || dir == backupRoot {
235			break
236		}
237
238		err := os.Remove(dir)
239		if err == nil {
240			dir = filepath.Dir(dir)
241			continue
242		}
243		if os.IsNotExist(err) {
244			return nil
245		}
246		if errors.Is(err, syscall.ENOTEMPTY) || errors.Is(err, syscall.EEXIST) {
247			return nil
248		}
249		return err
250	}
251
252	return nil
253}
254
255func retentionKeepCount(policy domain.RetentionPolicy, schedule domain.BackupSchedule) int {
256	switch schedule {
257	case domain.ScheduleHourly:
258		return policy.Hourly
259	case domain.ScheduleDaily:
260		return policy.Daily
261	case domain.ScheduleWeekly:
262		return policy.Weekly
263	case domain.ScheduleMonthly:
264		return policy.Monthly
265	default:
266		return -1
267	}
268}
269
270func sanitizeBackupPathComponent(input string) string {
271	clean := strings.TrimSpace(input)
272	if clean == "" {
273		return "unknown"
274	}
275	if strings.Trim(clean, ".") == "" {
276		return "unknown"
277	}
278	clean = strings.Trim(clean, ".")
279	if clean == "" {
280		return "unknown"
281	}
282	replacer := strings.NewReplacer("/", "_", "\\", "_", ":", "_", " ", "_")
283	return replacer.Replace(clean)
284}
285
286func pathWithinRoot(root, path string) bool {
287	rootAbs, err := filepath.Abs(root)
288	if err != nil {
289		return false
290	}
291	pathAbs, err := filepath.Abs(path)
292	if err != nil {
293		return false
294	}
295
296	rootClean := filepath.Clean(rootAbs)
297	pathClean := filepath.Clean(pathAbs)
298	rel, err := filepath.Rel(rootClean, pathClean)
299	if err != nil {
300		return false
301	}
302	return rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))
303}

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