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}