1package app
2
3import (
4 "context"
5 "fmt"
6 "strings"
7
8 "github.com/bnema/zerowrap"
9 "github.com/spf13/viper"
10
11 "github.com/bnema/gordon/internal/domain"
12 cronSvc "github.com/bnema/gordon/internal/usecase/cron"
13)
14
15func startOptionalSchedulers(ctx context.Context, cfg Config, svc *services, log zerowrap.Logger, v *viper.Viper) (func(), error) {
16 schedulers := make([]*cronSvc.Scheduler, 0, 3)
17 stopAll := func() {
18 for i := len(schedulers) - 1; i >= 0; i-- {
19 schedulers[i].Stop()
20 }
21 }
22
23 backupScheduler, err := startBackupScheduler(ctx, cfg, svc, log)
24 if err != nil {
25 return nil, err
26 }
27 if backupScheduler != nil {
28 schedulers = append(schedulers, backupScheduler)
29 }
30
31 volumeBackupScheduler, err := startVolumeBackupScheduler(ctx, cfg, svc, log)
32 if err != nil {
33 stopAll()
34 return nil, err
35 }
36 if volumeBackupScheduler != nil {
37 schedulers = append(schedulers, volumeBackupScheduler)
38 }
39
40 imageScheduler, err := startImagePruneScheduler(ctx, cfg, svc, log, func() int {
41 return v.GetInt("images.prune.keep_last")
42 })
43 if err != nil {
44 stopAll()
45 return nil, err
46 }
47 if imageScheduler != nil {
48 schedulers = append(schedulers, imageScheduler)
49 }
50
51 if len(schedulers) == 0 {
52 return nil, nil
53 }
54
55 return stopAll, nil
56}
57
58func startBackupScheduler(ctx context.Context, cfg Config, svc *services, log zerowrap.Logger) (*cronSvc.Scheduler, error) {
59 dbCfg := databaseBackupSettings(cfg)
60 if !dbCfg.Enabled || svc == nil || svc.backupSvc == nil {
61 return nil, nil
62 }
63
64 preset, err := resolveBackupSchedule(dbCfg.Schedule)
65 if err != nil {
66 return nil, err
67 }
68
69 scheduler := cronSvc.NewScheduler(log)
70 err = scheduler.Add(
71 "backup-scheduler",
72 "Backups",
73 domain.CronSchedule{Preset: preset},
74 func(jobCtx context.Context) error {
75 if err := svc.backupSvc.RunForSchedule(jobCtx, preset); err != nil {
76 return err
77 }
78 log.Info().
79 Str("schedule", string(preset)).
80 Msg("scheduled backup run complete")
81 return nil
82 },
83 )
84 if err != nil {
85 return nil, log.WrapErr(err, "failed to register backup schedule")
86 }
87
88 scheduler.Start(ctx)
89 log.Info().
90 Str("schedule", string(preset)).
91 Msg("backup scheduler enabled")
92
93 return scheduler, nil
94}
95
96func resolveBackupSchedule(raw string) (domain.BackupSchedule, error) {
97 return resolveSchedulePreset(raw, "backups.databases.schedule", domain.ScheduleDaily)
98}
99
100func startVolumeBackupScheduler(ctx context.Context, cfg Config, svc *services, log zerowrap.Logger) (*cronSvc.Scheduler, error) {
101 if !cfg.Backups.Volumes.Enabled || svc == nil || svc.volumeBackupSvc == nil {
102 return nil, nil
103 }
104
105 volumeCfg, err := validateVolumeBackupConfig(cfg)
106 if err != nil {
107 return nil, err
108 }
109
110 scheduler := cronSvc.NewScheduler(log)
111 err = scheduler.Add(
112 "volume-backup-scheduler",
113 "Volume Backups",
114 domain.CronSchedule{Interval: volumeCfg.Interval},
115 func(jobCtx context.Context) error {
116 if err := svc.volumeBackupSvc.RunVolumeBackupsForSchedule(jobCtx, ""); err != nil {
117 return err
118 }
119 log.Info().
120 Dur("interval", volumeCfg.Interval).
121 Msg("scheduled volume backup run complete")
122 return nil
123 },
124 )
125 if err != nil {
126 return nil, log.WrapErr(err, "failed to register volume backup schedule")
127 }
128
129 scheduler.Start(ctx)
130 log.Info().
131 Dur("interval", volumeCfg.Interval).
132 Msg("volume backup scheduler enabled")
133
134 return scheduler, nil
135}
136
137func startImagePruneScheduler(ctx context.Context, cfg Config, svc *services, log zerowrap.Logger, keepLastGetter func() int) (*cronSvc.Scheduler, error) {
138 if !cfg.Images.Prune.Enabled || svc == nil || svc.imageSvc == nil {
139 return nil, nil
140 }
141 // The scheduler runs the same use case and planner as manual
142 // execution. App presence is normal: apps exist on every real
143 // server, and prune decides per candidate.
144 if keepLastGetter == nil {
145 keepLastGetter = func() int { return cfg.Images.Prune.KeepLast }
146 }
147 if keepLastGetter() < 0 {
148 return nil, fmt.Errorf("images.prune.keep_last must be >= 0")
149 }
150
151 preset, err := resolveImagePruneSchedule(cfg.Images.Prune.Schedule)
152 if err != nil {
153 return nil, err
154 }
155
156 scheduler := cronSvc.NewScheduler(log)
157 err = scheduler.Add(
158 "image-prune",
159 "Image prune",
160 domain.CronSchedule{Preset: preset},
161 func(jobCtx context.Context) error {
162 keepLast := keepLastGetter()
163 if keepLast < 0 {
164 log.Warn().
165 Int("configured_keep_last", keepLast).
166 Int("fallback_keep_last", domain.DefaultImagePruneKeepLast).
167 Msg("invalid images.prune.keep_last; using default")
168 keepLast = domain.DefaultImagePruneKeepLast
169 }
170
171 report, err := svc.imageSvc.Prune(jobCtx, domain.ImagePruneOptions{
172 KeepLast: keepLast,
173 PruneDangling: true,
174 PruneRegistry: true,
175 })
176 if err != nil {
177 return err
178 }
179
180 log.Info().
181 Int("keep_last", keepLast).
182 Int("eligible", report.Plan.CountByVerdict(domain.PruneVerdictEligible)).
183 Int("protected", report.Plan.CountByVerdict(domain.PruneVerdictProtected)).
184 Int("unknown", report.Plan.CountByVerdict(domain.PruneVerdictUnknown)).
185 Int("deleted", len(report.Plan.Deleted)).
186 Int("failures", len(report.Plan.Failures)).
187 Int("inventory_gaps", len(report.Plan.Gaps)).
188 Int("runtime_deleted", report.Runtime.DeletedCount).
189 Int("registry_tags_removed", report.Registry.TagsRemoved).
190 Int("registry_blobs_removed", report.Registry.BlobsRemoved).
191 Msg("scheduled image prune complete")
192 return nil
193 },
194 )
195 if err != nil {
196 return nil, log.WrapErr(err, "failed to register image prune schedule")
197 }
198
199 scheduler.Start(ctx)
200 log.Info().
201 Str("schedule", string(preset)).
202 Int("keep_last", keepLastGetter()).
203 Msg("image prune scheduler enabled")
204
205 return scheduler, nil
206}
207
208func resolveImagePruneSchedule(raw string) (domain.BackupSchedule, error) {
209 return resolveSchedulePreset(raw, "images.prune.schedule", domain.ScheduleDaily)
210}
211
212func resolveSchedulePreset(raw, name string, defaultVal domain.BackupSchedule) (domain.BackupSchedule, error) {
213 schedule := domain.BackupSchedule(strings.ToLower(strings.TrimSpace(raw)))
214 if schedule == "" {
215 schedule = defaultVal
216 }
217
218 switch schedule {
219 case domain.ScheduleHourly, domain.ScheduleDaily, domain.ScheduleWeekly, domain.ScheduleMonthly:
220 return schedule, nil
221 default:
222 return "", fmt.Errorf("%s must be one of: hourly, daily, weekly, monthly", name)
223 }
224}