v3.4.0 593c1c3

schedulers.go RawHistoryFolder

1package app
2
3import (
4	"context"
5	"fmt"
6	"strings"
7	"time"
8
9	"github.com/bnema/zerowrap"
10	"github.com/spf13/viper"
11
12	"github.com/bnema/gordon/internal/domain"
13	cronSvc "github.com/bnema/gordon/internal/usecase/cron"
14)
15
16func startOptionalSchedulers(ctx context.Context, cfg Config, svc *services, log zerowrap.Logger, v *viper.Viper) (func(), error) {
17	schedulers := make([]*cronSvc.Scheduler, 0, 3)
18	stopAll := func() {
19		for i := len(schedulers) - 1; i >= 0; i-- {
20			schedulers[i].Stop()
21		}
22	}
23
24	backupScheduler, err := startBackupScheduler(ctx, cfg, svc, log)
25	if err != nil {
26		return nil, err
27	}
28	if backupScheduler != nil {
29		schedulers = append(schedulers, backupScheduler)
30	}
31
32	volumeBackupScheduler, err := startVolumeBackupScheduler(ctx, cfg, svc, log)
33	if err != nil {
34		stopAll()
35		return nil, err
36	}
37	if volumeBackupScheduler != nil {
38		schedulers = append(schedulers, volumeBackupScheduler)
39	}
40
41	imageScheduler, err := startImagePruneScheduler(ctx, cfg, svc, log, func() int {
42		return v.GetInt("images.prune.keep_last")
43	})
44	if err != nil {
45		stopAll()
46		return nil, err
47	}
48	if imageScheduler != nil {
49		schedulers = append(schedulers, imageScheduler)
50	}
51
52	if len(schedulers) == 0 {
53		return nil, nil
54	}
55
56	return stopAll, nil
57}
58
59func startBackupScheduler(ctx context.Context, cfg Config, svc *services, log zerowrap.Logger) (*cronSvc.Scheduler, error) {
60	dbCfg := databaseBackupSettings(cfg)
61	if !dbCfg.Enabled || svc == nil || svc.backupSvc == nil {
62		return nil, nil
63	}
64
65	schedule, err := resolveCronSchedule(dbCfg.Schedule, dbCfg.Time, "backups.databases")
66	if err != nil {
67		return nil, err
68	}
69	preset := schedule.Preset
70
71	scheduler := cronSvc.NewScheduler(log)
72	err = scheduler.Add(
73		"backup-scheduler",
74		"Backups",
75		schedule,
76		func(jobCtx context.Context) error {
77			if err := svc.backupSvc.RunForSchedule(jobCtx, preset); err != nil {
78				return err
79			}
80			log.Info().
81				Str("schedule", describeSchedule(schedule)).
82				Msg("scheduled backup run complete")
83			return nil
84		},
85	)
86	if err != nil {
87		return nil, log.WrapErr(err, "failed to register backup schedule")
88	}
89
90	scheduler.Start(ctx)
91	log.Info().
92		Str("schedule", describeSchedule(schedule)).
93		Time("next_run", nextRunOf(scheduler, "backup-scheduler")).
94		Msg("backup scheduler enabled")
95
96	return scheduler, nil
97}
98
99// nextRunOf returns the next planned run of one registered job.
100func nextRunOf(scheduler *cronSvc.Scheduler, id string) time.Time {
101	for _, entry := range scheduler.List() {
102		if entry.ID == id {
103			return entry.NextRun
104		}
105	}
106	return time.Time{}
107}
108
109func startVolumeBackupScheduler(ctx context.Context, cfg Config, svc *services, log zerowrap.Logger) (*cronSvc.Scheduler, error) {
110	if !cfg.Backups.Volumes.Enabled || svc == nil || svc.volumeBackupSvc == nil {
111		return nil, nil
112	}
113
114	volumeCfg, err := validateVolumeBackupConfig(cfg)
115	if err != nil {
116		return nil, err
117	}
118
119	scheduler := cronSvc.NewScheduler(log)
120	err = scheduler.Add(
121		"volume-backup-scheduler",
122		"Volume Backups",
123		volumeCfg.Schedule,
124		func(jobCtx context.Context) error {
125			if err := svc.volumeBackupSvc.RunVolumeBackupsForSchedule(jobCtx, volumeCfg.Schedule.Preset); err != nil {
126				return err
127			}
128			log.Info().
129				Str("schedule", describeSchedule(volumeCfg.Schedule)).
130				Msg("scheduled volume backup run complete")
131			return nil
132		},
133	)
134	if err != nil {
135		return nil, log.WrapErr(err, "failed to register volume backup schedule")
136	}
137
138	scheduler.Start(ctx)
139	log.Info().
140		Str("schedule", describeSchedule(volumeCfg.Schedule)).
141		Time("next_run", nextRunOf(scheduler, "volume-backup-scheduler")).
142		Msg("volume backup scheduler enabled")
143
144	return scheduler, nil
145}
146
147func startImagePruneScheduler(ctx context.Context, cfg Config, svc *services, log zerowrap.Logger, keepLastGetter func() int) (*cronSvc.Scheduler, error) {
148	if !cfg.Images.Prune.Enabled || svc == nil || svc.imageSvc == nil {
149		return nil, nil
150	}
151	// The scheduler runs the same use case and planner as manual
152	// execution. App presence is normal: apps exist on every real
153	// server, and prune decides per candidate.
154	if keepLastGetter == nil {
155		keepLastGetter = func() int { return cfg.Images.Prune.KeepLast }
156	}
157	if keepLastGetter() < 0 {
158		return nil, fmt.Errorf("images.prune.keep_last must be >= 0")
159	}
160
161	preset, err := resolveImagePruneSchedule(cfg.Images.Prune.Schedule)
162	if err != nil {
163		return nil, err
164	}
165
166	scheduler := cronSvc.NewScheduler(log)
167	err = scheduler.Add(
168		"image-prune",
169		"Image prune",
170		domain.CronSchedule{Preset: preset},
171		func(jobCtx context.Context) error {
172			keepLast := keepLastGetter()
173			if keepLast < 0 {
174				log.Warn().
175					Int("configured_keep_last", keepLast).
176					Int("fallback_keep_last", domain.DefaultImagePruneKeepLast).
177					Msg("invalid images.prune.keep_last; using default")
178				keepLast = domain.DefaultImagePruneKeepLast
179			}
180
181			report, err := svc.imageSvc.Prune(jobCtx, domain.ImagePruneOptions{
182				KeepLast:      keepLast,
183				PruneDangling: true,
184				PruneRegistry: true,
185			})
186			if err != nil {
187				return err
188			}
189
190			log.Info().
191				Int("keep_last", keepLast).
192				Int("eligible", report.Plan.CountByVerdict(domain.PruneVerdictEligible)).
193				Int("protected", report.Plan.CountByVerdict(domain.PruneVerdictProtected)).
194				Int("unknown", report.Plan.CountByVerdict(domain.PruneVerdictUnknown)).
195				Int("deleted", len(report.Plan.Deleted)).
196				Int("failures", len(report.Plan.Failures)).
197				Int("inventory_gaps", len(report.Plan.Gaps)).
198				Int("runtime_deleted", report.Runtime.DeletedCount).
199				Int("registry_tags_removed", report.Registry.TagsRemoved).
200				Int("registry_blobs_removed", report.Registry.BlobsRemoved).
201				Msg("scheduled image prune complete")
202			return nil
203		},
204	)
205	if err != nil {
206		return nil, log.WrapErr(err, "failed to register image prune schedule")
207	}
208
209	scheduler.Start(ctx)
210	log.Info().
211		Str("schedule", string(preset)).
212		Int("keep_last", keepLastGetter()).
213		Msg("image prune scheduler enabled")
214
215	return scheduler, nil
216}
217
218func resolveImagePruneSchedule(raw string) (domain.BackupSchedule, error) {
219	return resolveSchedulePreset(raw, "images.prune.schedule", domain.ScheduleDaily)
220}
221
222// resolveCronSchedule builds a backup schedule from a preset and an
223// optional HH:MM time read in the server's local timezone. prefix names
224// the config section (e.g. "backups.volumes") for error messages.
225func resolveCronSchedule(rawSchedule, rawTime, prefix string) (domain.CronSchedule, error) {
226	preset, err := resolveSchedulePreset(rawSchedule, prefix+".schedule", domain.ScheduleDaily)
227	if err != nil {
228		return domain.CronSchedule{}, err
229	}
230	schedule := domain.CronSchedule{Preset: preset, Location: time.Local}
231	if strings.TrimSpace(rawTime) == "" {
232		return schedule, nil
233	}
234	at, err := domain.ParseTimeOfDay(rawTime)
235	if err != nil {
236		return domain.CronSchedule{}, fmt.Errorf("%s.time: %w", prefix, err)
237	}
238	schedule.At = &at
239	return schedule, nil
240}
241
242// describeSchedule renders a schedule for logs, e.g. "daily at 03:30 Europe/Paris".
243func describeSchedule(s domain.CronSchedule) string {
244	at := "default time"
245	if s.At != nil {
246		at = s.At.String()
247	}
248	loc := "UTC"
249	if s.Location != nil {
250		// time.Local reports "Local"; the current zone abbreviation is
251		// what an operator can read (e.g. CEST).
252		loc, _ = time.Now().In(s.Location).Zone()
253	}
254	return fmt.Sprintf("%s at %s %s", s.Preset, at, loc)
255}
256
257func resolveSchedulePreset(raw, name string, defaultVal domain.BackupSchedule) (domain.BackupSchedule, error) {
258	schedule := domain.BackupSchedule(strings.ToLower(strings.TrimSpace(raw)))
259	if schedule == "" {
260		schedule = defaultVal
261	}
262
263	switch schedule {
264	case domain.ScheduleHourly, domain.ScheduleDaily, domain.ScheduleWeekly, domain.ScheduleMonthly:
265		return schedule, nil
266	default:
267		return "", fmt.Errorf("%s must be one of: hourly, daily, weekly, monthly", name)
268	}
269}

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