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}