backup_wiring.go RawHistoryFolder

1package app
2
3import (
4	"context"
5	"fmt"
6	"path/filepath"
7	"strings"
8	"time"
9
10	"github.com/bnema/zerowrap"
11
12	"github.com/bnema/gordon/internal/adapters/out/filesystem"
13	s3storage "github.com/bnema/gordon/internal/adapters/out/s3"
14	"github.com/bnema/gordon/internal/boundaries/out"
15	"github.com/bnema/gordon/internal/domain"
16	"github.com/bnema/gordon/internal/usecase/backup"
17)
18
19type databaseBackupSettingsConfig struct {
20	Enabled    bool
21	Schedule   string
22	Time       string
23	StorageDir string
24	Retention  struct {
25		Hourly  int
26		Daily   int
27		Weekly  int
28		Monthly int
29	}
30}
31
32func databaseBackupSettings(cfg Config) databaseBackupSettingsConfig {
33	out := databaseBackupSettingsConfig{
34		Enabled:    cfg.Backups.Databases.Enabled,
35		Schedule:   cfg.Backups.Databases.Schedule,
36		Time:       cfg.Backups.Databases.Time,
37		StorageDir: cfg.Backups.Databases.StorageDir,
38	}
39	out.Retention.Hourly = cfg.Backups.Databases.Retention.Hourly
40	out.Retention.Daily = cfg.Backups.Databases.Retention.Daily
41	out.Retention.Weekly = cfg.Backups.Databases.Retention.Weekly
42	out.Retention.Monthly = cfg.Backups.Databases.Retention.Monthly
43	// Legacy [backups] keys intentionally override new database defaults when
44	// backups.enabled is true, preserving existing working pg_dump schedules.
45	// Otherwise, prefer the already-populated backups.databases.* values.
46	if cfg.Backups.Enabled {
47		out.Enabled = true
48		if cfg.Backups.Schedule != "" {
49			out.Schedule = cfg.Backups.Schedule
50		}
51		if cfg.Backups.StorageDir != "" {
52			out.StorageDir = cfg.Backups.StorageDir
53		}
54	} else {
55		if out.Schedule == "" {
56			out.Schedule = cfg.Backups.Schedule
57		}
58		if out.StorageDir == "" {
59			out.StorageDir = cfg.Backups.StorageDir
60		}
61	}
62	if out.Retention.Hourly == 0 {
63		out.Retention.Hourly = cfg.Backups.Retention.Hourly
64	}
65	if out.Retention.Daily == 0 {
66		out.Retention.Daily = cfg.Backups.Retention.Daily
67	}
68	if out.Retention.Weekly == 0 {
69		out.Retention.Weekly = cfg.Backups.Retention.Weekly
70	}
71	if out.Retention.Monthly == 0 {
72		out.Retention.Monthly = cfg.Backups.Retention.Monthly
73	}
74	return out
75}
76
77func createBackupService(cfg Config, svc *services, log zerowrap.Logger) (*filesystem.BackupStorage, *backup.Service, error) {
78	dbCfg := databaseBackupSettings(cfg)
79	if !dbCfg.Enabled {
80		return nil, nil, nil
81	}
82
83	storageDir := dbCfg.StorageDir
84	if storageDir == "" {
85		dataDir := resolveDataDir(cfg.Server.DataDir)
86		storageDir = filepath.Join(dataDir, "backups")
87	}
88
89	backupStorage, err := filesystem.NewBackupStorage(storageDir, log)
90	if err != nil {
91		return nil, nil, log.WrapErr(err, "failed to create backup storage")
92	}
93
94	retention, err := validateBackupRetention(cfg)
95	if err != nil {
96		return nil, nil, log.WrapErr(err, "invalid backup retention policy")
97	}
98
99	backupCfg := domain.BackupConfig{
100		Enabled:    dbCfg.Enabled,
101		StorageDir: storageDir,
102		Retention:  retention,
103	}
104
105	backupSvc := backup.NewService(svc.runtime, backupStorage, backupCfg, log)
106
107	log.Info().
108		Str("storage_dir", storageDir).
109		Msg("backup service initialized")
110
111	return backupStorage, backupSvc, nil
112}
113
114func createVolumeBackupService(ctx context.Context, cfg Config, svc *services, log zerowrap.Logger) (out.VolumeBackupStorage, *backup.VolumeService, domain.VolumeBackupConfig, error) {
115	if !cfg.Backups.Volumes.Enabled {
116		return nil, nil, domain.VolumeBackupConfig{}, nil
117	}
118	volumeCfg, err := validateVolumeBackupConfig(cfg)
119	if err != nil {
120		return nil, nil, domain.VolumeBackupConfig{}, log.WrapErr(err, "invalid volume backup configuration")
121	}
122
123	storage, err := s3storage.NewVolumeBackupStorage(ctx, volumeCfg)
124	if err != nil {
125		return nil, nil, domain.VolumeBackupConfig{}, log.WrapErr(err, "failed to create volume backup storage")
126	}
127
128	volumeSvc := backup.NewVolumeService(svc.runtime, storage, volumeCfg, log)
129	log.Info().
130		Str("bucket", volumeCfg.S3Bucket).
131		Str("prefix", volumeCfg.S3Prefix).
132		Msg("volume backup service initialized")
133
134	return storage, volumeSvc, volumeCfg, nil
135}
136
137func validateBackupRetention(cfg Config) (domain.RetentionPolicy, error) {
138	dbCfg := databaseBackupSettings(cfg)
139	if dbCfg.Retention.Hourly < 0 {
140		return domain.RetentionPolicy{}, fmt.Errorf("backups.databases.retention.hourly cannot be negative")
141	}
142	if dbCfg.Retention.Daily < 0 {
143		return domain.RetentionPolicy{}, fmt.Errorf("backups.databases.retention.daily cannot be negative")
144	}
145	if dbCfg.Retention.Weekly < 0 {
146		return domain.RetentionPolicy{}, fmt.Errorf("backups.databases.retention.weekly cannot be negative")
147	}
148	if dbCfg.Retention.Monthly < 0 {
149		return domain.RetentionPolicy{}, fmt.Errorf("backups.databases.retention.monthly cannot be negative")
150	}
151
152	return domain.RetentionPolicy{
153		Hourly:  dbCfg.Retention.Hourly,
154		Daily:   dbCfg.Retention.Daily,
155		Weekly:  dbCfg.Retention.Weekly,
156		Monthly: dbCfg.Retention.Monthly,
157	}, nil
158}
159
160func validateVolumeBackupConfig(cfg Config) (domain.VolumeBackupConfig, error) {
161	volumeCfg := cfg.Backups.Volumes
162	schedule, err := resolveCronSchedule(volumeCfg.Schedule, volumeCfg.Time, "backups.volumes")
163	if err != nil {
164		return domain.VolumeBackupConfig{}, err
165	}
166	timeout, err := parsePositiveDurationDefault(volumeCfg.Timeout, "2h", "backups.volumes.timeout")
167	if err != nil {
168		return domain.VolumeBackupConfig{}, err
169	}
170	compression, err := parseVolumeBackupCompression(volumeCfg.Compression)
171	if err != nil {
172		return domain.VolumeBackupConfig{}, err
173	}
174	maxConcurrency := volumeCfg.MaxConcurrency
175	if maxConcurrency == 0 {
176		maxConcurrency = 2
177	}
178	helperImage := strings.TrimSpace(volumeCfg.HelperImage)
179	if helperImage == "" {
180		helperImage = "alpine:3.20"
181	}
182	volumePrefix := strings.TrimSpace(cfg.Volumes.Prefix)
183	if volumePrefix == "" {
184		volumePrefix = "gordon"
185	}
186	if compression == domain.VolumeBackupCompressionZstd && helperImage == "alpine:3.20" {
187		return domain.VolumeBackupConfig{}, fmt.Errorf("backups.volumes.compression zstd requires a helper_image that provides zstd")
188	}
189	if err := validateVolumeBackupS3Settings(volumeCfg.Enabled, volumeCfg.Retention.Keep, maxConcurrency, volumeCfg.S3.Bucket, volumeCfg.S3.Region); err != nil {
190		return domain.VolumeBackupConfig{}, err
191	}
192	storageClass, err := parseS3StorageClass(volumeCfg.S3.StorageClass)
193	if err != nil {
194		return domain.VolumeBackupConfig{}, err
195	}
196	return domain.VolumeBackupConfig{
197		Enabled:        volumeCfg.Enabled,
198		Schedule:       schedule,
199		Compression:    compression,
200		Retention:      domain.VolumeBackupRetentionPolicy{Keep: volumeCfg.Retention.Keep},
201		Timeout:        timeout,
202		MaxConcurrency: maxConcurrency,
203		HelperImage:    helperImage,
204		VolumePrefix:   volumePrefix,
205		S3Bucket:       strings.TrimSpace(volumeCfg.S3.Bucket),
206		S3Region:       strings.TrimSpace(volumeCfg.S3.Region),
207		S3Prefix:       strings.TrimSpace(volumeCfg.S3.Prefix),
208		S3Endpoint:     strings.TrimSpace(volumeCfg.S3.Endpoint),
209		S3PathStyle:    volumeCfg.S3.PathStyle,
210		S3SSEAlgorithm: strings.TrimSpace(volumeCfg.S3.SSEAlgorithm),
211		S3SSEKMSKeyID:  strings.TrimSpace(volumeCfg.S3.SSEKMSKeyID),
212		S3StorageClass: storageClass,
213	}, nil
214}
215
216// parseS3StorageClass normalizes a storage class name. Providers define
217// their own classes (AWS GLACIER_IR, Scaleway ONEZONE_IA, ...), so any
218// S3-style token is accepted and the provider validates it on upload.
219func parseS3StorageClass(raw string) (string, error) {
220	class := strings.ToUpper(strings.TrimSpace(raw))
221	for _, r := range class {
222		if (r < 'A' || r > 'Z') && (r < '0' || r > '9') && r != '_' {
223			return "", fmt.Errorf("backups.volumes.s3.storage_class must contain only letters, digits, and underscores")
224		}
225	}
226	return class, nil
227}
228
229func parsePositiveDurationDefault(raw, defaultValue, field string) (time.Duration, error) {
230	if strings.TrimSpace(raw) == "" {
231		raw = defaultValue
232	}
233	d, err := time.ParseDuration(strings.TrimSpace(raw))
234	if err != nil || d <= 0 {
235		return 0, fmt.Errorf("%s must be a positive duration", field)
236	}
237	return d, nil
238}
239
240func parseVolumeBackupCompression(raw string) (domain.VolumeBackupCompression, error) {
241	if strings.TrimSpace(raw) == "" {
242		raw = string(domain.VolumeBackupCompressionGzip)
243	}
244	compression := domain.VolumeBackupCompression(strings.ToLower(strings.TrimSpace(raw)))
245	switch compression {
246	case domain.VolumeBackupCompressionGzip, domain.VolumeBackupCompressionZstd:
247		return compression, nil
248	default:
249		return "", fmt.Errorf("backups.volumes.compression must be one of: gzip, zstd")
250	}
251}
252
253func validateVolumeBackupS3Settings(enabled bool, keep, maxConcurrency int, bucket, region string) error {
254	if keep < 0 {
255		return fmt.Errorf("backups.volumes.retention.keep cannot be negative")
256	}
257	if enabled && keep == 0 {
258		return fmt.Errorf("backups.volumes.retention.keep must be positive when volume backups are enabled")
259	}
260	if maxConcurrency < 1 {
261		return fmt.Errorf("backups.volumes.max_concurrency must be at least 1")
262	}
263	if enabled && strings.TrimSpace(bucket) == "" {
264		return fmt.Errorf("backups.volumes.s3.bucket is required when volume backups are enabled")
265	}
266	if enabled && strings.TrimSpace(region) == "" {
267		return fmt.Errorf("backups.volumes.s3.region is required when volume backups are enabled")
268	}
269	return nil
270}

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 8

Tags 106

↑↓ moveenter openesc close