v2.14.0 5e11a00

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

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