v2.31.0-dev18 7b9923f

backup_storage.go RawHistoryFolder

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

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