v3.3.1 31f1eec

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

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