v3.0.0-alpha.1 6fdddb8

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

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