main bac21c6

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

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