detached b5442e4

feat(app): wire image prune config and scheduler job

commit
b5442e49e15ce4acc2ba09126f470bcf91fad34a
author
brice · · 2026-02-08 06:56 UTC
committer
brice · · 2026-02-08 09:40 UTC
parent
352a4ea
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +141 -3
M internal/app/run.go +84 -1
@@ -57,7 +57,9 @@ import (
5757 "github.com/bnema/gordon/internal/usecase/backup"
5858 "github.com/bnema/gordon/internal/usecase/config"
5959 "github.com/bnema/gordon/internal/usecase/container"
60+ cronSvc "github.com/bnema/gordon/internal/usecase/cron"
6061 "github.com/bnema/gordon/internal/usecase/health"
62+ "github.com/bnema/gordon/internal/usecase/images"
6163 "github.com/bnema/gordon/internal/usecase/logs"
6264 "github.com/bnema/gordon/internal/usecase/proxy"
6365 registrySvc "github.com/bnema/gordon/internal/usecase/registry"
@@ -141,6 +143,14 @@ type Config struct {
141143 Monthly int `mapstructure:"monthly"`
142144 } `mapstructure:"retention"`
143145 } `mapstructure:"backups"`
146+
147+ Images struct {
148+ Prune struct {
149+ Enabled bool `mapstructure:"enabled"`
150+ Schedule string `mapstructure:"schedule"`
151+ KeepLast int `mapstructure:"keep_last"`
152+ } `mapstructure:"prune"`
153+ } `mapstructure:"images"`
144154 }
145155
146156 // services holds all the services used by the application.
@@ -157,6 +167,7 @@ type services struct {
157167 containerSvc *container.Service
158168 backupSvc *backup.Service
159169 registrySvc *registrySvc.Service
170+ imageSvc *images.Service
160171 proxySvc *proxy.Service
161172 authSvc *auth.Service
162173 authHandler *authhandler.Handler
@@ -452,6 +463,7 @@ func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowra
452463 }
453464
454465 svc.registrySvc = registrySvc.NewService(svc.blobStorage, svc.manifestStorage, svc.eventBus)
466+ svc.imageSvc = images.NewService(svc.runtime, svc.manifestStorage, svc.blobStorage, log)
455467
456468 proxyCfg, err := buildProxyConfig(cfg, log)
457469 if err != nil {
@@ -482,7 +494,7 @@ func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowra
482494 logSvc := logs.NewService(resolveLogFilePath(cfg), svc.containerSvc, svc.runtime, log)
483495
484496 // Create admin handler for admin API
485- svc.adminHandler = admin.NewHandler(svc.configSvc, svc.authSvc, svc.containerSvc, healthSvc, secretSvc, logSvc, svc.registrySvc, svc.eventBus, log, svc.backupSvc)
497+ svc.adminHandler = admin.NewHandler(svc.configSvc, svc.authSvc, svc.containerSvc, healthSvc, secretSvc, logSvc, svc.registrySvc, svc.eventBus, log, svc.backupSvc, svc.imageSvc)
486498
487499 return svc, nil
488500 }
@@ -1434,6 +1446,14 @@ func runServers(ctx context.Context, cfg Config, registryHandler, proxyHandler h
14341446 }
14351447 logEvent.Msg("Gordon is running")
14361448
1449+ imageScheduler, err := startImagePruneScheduler(ctx, cfg, svc, log)
1450+ if err != nil {
1451+ return err
1452+ }
1453+ if imageScheduler != nil {
1454+ defer imageScheduler.Stop()
1455+ }
1456+
14371457 // Auto-start after servers are listening (registry port is now bound).
14381458 syncAndAutoStart(ctx, svc, log)
14391459
@@ -1442,6 +1462,66 @@ func runServers(ctx context.Context, cfg Config, registryHandler, proxyHandler h
14421462 return nil
14431463 }
14441464
1465+func startImagePruneScheduler(ctx context.Context, cfg Config, svc *services, log zerowrap.Logger) (*cronSvc.Scheduler, error) {
1466+ if !cfg.Images.Prune.Enabled || svc == nil || svc.imageSvc == nil {
1467+ return nil, nil
1468+ }
1469+ if cfg.Images.Prune.KeepLast < 0 {
1470+ return nil, fmt.Errorf("images.prune.keep_last must be >= 0")
1471+ }
1472+
1473+ preset, err := resolveImagePruneSchedule(cfg.Images.Prune.Schedule)
1474+ if err != nil {
1475+ return nil, err
1476+ }
1477+
1478+ scheduler := cronSvc.NewScheduler(log)
1479+ err = scheduler.Add(
1480+ "image-prune",
1481+ "Image prune",
1482+ domain.CronSchedule{Preset: preset},
1483+ func(jobCtx context.Context) error {
1484+ report, err := svc.imageSvc.Prune(jobCtx, cfg.Images.Prune.KeepLast)
1485+ if err != nil {
1486+ return err
1487+ }
1488+
1489+ log.Info().
1490+ Int("runtime_deleted", report.Runtime.DeletedCount).
1491+ Int64("runtime_reclaimed_bytes", report.Runtime.SpaceReclaimed).
1492+ Int("registry_tags_removed", report.Registry.TagsRemoved).
1493+ Int("registry_blobs_removed", report.Registry.BlobsRemoved).
1494+ Msg("scheduled image prune complete")
1495+ return nil
1496+ },
1497+ )
1498+ if err != nil {
1499+ return nil, log.WrapErr(err, "failed to register image prune schedule")
1500+ }
1501+
1502+ scheduler.Start(ctx)
1503+ log.Info().
1504+ Str("schedule", string(preset)).
1505+ Int("keep_last", cfg.Images.Prune.KeepLast).
1506+ Msg("image prune scheduler enabled")
1507+
1508+ return scheduler, nil
1509+}
1510+
1511+func resolveImagePruneSchedule(raw string) (domain.BackupSchedule, error) {
1512+ schedule := domain.BackupSchedule(strings.ToLower(strings.TrimSpace(raw)))
1513+ if schedule == "" {
1514+ schedule = domain.ScheduleDaily
1515+ }
1516+
1517+ switch schedule {
1518+ case domain.ScheduleHourly, domain.ScheduleDaily, domain.ScheduleWeekly, domain.ScheduleMonthly:
1519+ return schedule, nil
1520+ default:
1521+ return "", fmt.Errorf("images.prune.schedule must be one of: hourly, daily, weekly, monthly")
1522+ }
1523+}
1524+
14451525 // waitForShutdown blocks on the event loop, handling server errors and
14461526 // Unix signals (reload, deploy, shutdown) until the context is cancelled.
14471527 func waitForShutdown(ctx context.Context, errChan <-chan error, reloadChan, deployChan <-chan os.Signal, eventBus out.EventBus, log zerowrap.Logger) {
@@ -1861,6 +1941,9 @@ func loadConfig(v *viper.Viper, configPath string) error {
18611941 v.SetDefault("backups.retention.daily", 0)
18621942 v.SetDefault("backups.retention.weekly", 0)
18631943 v.SetDefault("backups.retention.monthly", 0)
1944+ v.SetDefault("images.prune.enabled", false)
1945+ v.SetDefault("images.prune.schedule", string(domain.ScheduleDaily))
1946+ v.SetDefault("images.prune.keep_last", 3)
18641947 v.SetDefault("server.max_concurrent_connections", -1) // -1 = use default (10000), 0 = no limit
18651948 v.SetDefault("server.registry_allowed_ips", []string{})
18661949 v.SetDefault("deploy.readiness_delay", "5s")
@@ -57,7 +57,9 @@ import (
57 "github.com/bnema/gordon/internal/usecase/backup"57 "github.com/bnema/gordon/internal/usecase/backup"
58 "github.com/bnema/gordon/internal/usecase/config"58 "github.com/bnema/gordon/internal/usecase/config"
59 "github.com/bnema/gordon/internal/usecase/container"59 "github.com/bnema/gordon/internal/usecase/container"
60+ cronSvc "github.com/bnema/gordon/internal/usecase/cron"
60 "github.com/bnema/gordon/internal/usecase/health"61 "github.com/bnema/gordon/internal/usecase/health"
62+ "github.com/bnema/gordon/internal/usecase/images"
61 "github.com/bnema/gordon/internal/usecase/logs"63 "github.com/bnema/gordon/internal/usecase/logs"
62 "github.com/bnema/gordon/internal/usecase/proxy"64 "github.com/bnema/gordon/internal/usecase/proxy"
63 registrySvc "github.com/bnema/gordon/internal/usecase/registry"65 registrySvc "github.com/bnema/gordon/internal/usecase/registry"
@@ -141,6 +143,14 @@ type Config struct {
141 Monthly int `mapstructure:"monthly"`143 Monthly int `mapstructure:"monthly"`
142 } `mapstructure:"retention"`144 } `mapstructure:"retention"`
143 } `mapstructure:"backups"`145 } `mapstructure:"backups"`
146+
147+ Images struct {
148+ Prune struct {
149+ Enabled bool `mapstructure:"enabled"`
150+ Schedule string `mapstructure:"schedule"`
151+ KeepLast int `mapstructure:"keep_last"`
152+ } `mapstructure:"prune"`
153+ } `mapstructure:"images"`
144 }154 }
145 155
146 // services holds all the services used by the application.156 // services holds all the services used by the application.
@@ -157,6 +167,7 @@ type services struct {
157 containerSvc *container.Service167 containerSvc *container.Service
158 backupSvc *backup.Service168 backupSvc *backup.Service
159 registrySvc *registrySvc.Service169 registrySvc *registrySvc.Service
170+ imageSvc *images.Service
160 proxySvc *proxy.Service171 proxySvc *proxy.Service
161 authSvc *auth.Service172 authSvc *auth.Service
162 authHandler *authhandler.Handler173 authHandler *authhandler.Handler
@@ -452,6 +463,7 @@ func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowra
452 }463 }
453 464
454 svc.registrySvc = registrySvc.NewService(svc.blobStorage, svc.manifestStorage, svc.eventBus)465 svc.registrySvc = registrySvc.NewService(svc.blobStorage, svc.manifestStorage, svc.eventBus)
466+ svc.imageSvc = images.NewService(svc.runtime, svc.manifestStorage, svc.blobStorage, log)
455 467
456 proxyCfg, err := buildProxyConfig(cfg, log)468 proxyCfg, err := buildProxyConfig(cfg, log)
457 if err != nil {469 if err != nil {
@@ -482,7 +494,7 @@ func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowra
482 logSvc := logs.NewService(resolveLogFilePath(cfg), svc.containerSvc, svc.runtime, log)494 logSvc := logs.NewService(resolveLogFilePath(cfg), svc.containerSvc, svc.runtime, log)
483 495
484 // Create admin handler for admin API496 // Create admin handler for admin API
485- svc.adminHandler = admin.NewHandler(svc.configSvc, svc.authSvc, svc.containerSvc, healthSvc, secretSvc, logSvc, svc.registrySvc, svc.eventBus, log, svc.backupSvc)497+ svc.adminHandler = admin.NewHandler(svc.configSvc, svc.authSvc, svc.containerSvc, healthSvc, secretSvc, logSvc, svc.registrySvc, svc.eventBus, log, svc.backupSvc, svc.imageSvc)
486 498
487 return svc, nil499 return svc, nil
488 }500 }
@@ -1434,6 +1446,14 @@ func runServers(ctx context.Context, cfg Config, registryHandler, proxyHandler h
1434 }1446 }
1435 logEvent.Msg("Gordon is running")1447 logEvent.Msg("Gordon is running")
1436 1448
1449+ imageScheduler, err := startImagePruneScheduler(ctx, cfg, svc, log)
1450+ if err != nil {
1451+ return err
1452+ }
1453+ if imageScheduler != nil {
1454+ defer imageScheduler.Stop()
1455+ }
1456+
1437 // Auto-start after servers are listening (registry port is now bound).1457 // Auto-start after servers are listening (registry port is now bound).
1438 syncAndAutoStart(ctx, svc, log)1458 syncAndAutoStart(ctx, svc, log)
1439 1459
@@ -1442,6 +1462,66 @@ func runServers(ctx context.Context, cfg Config, registryHandler, proxyHandler h
1442 return nil1462 return nil
1443 }1463 }
1444 1464
1465+func startImagePruneScheduler(ctx context.Context, cfg Config, svc *services, log zerowrap.Logger) (*cronSvc.Scheduler, error) {
1466+ if !cfg.Images.Prune.Enabled || svc == nil || svc.imageSvc == nil {
1467+ return nil, nil
1468+ }
1469+ if cfg.Images.Prune.KeepLast < 0 {
1470+ return nil, fmt.Errorf("images.prune.keep_last must be >= 0")
1471+ }
1472+
1473+ preset, err := resolveImagePruneSchedule(cfg.Images.Prune.Schedule)
1474+ if err != nil {
1475+ return nil, err
1476+ }
1477+
1478+ scheduler := cronSvc.NewScheduler(log)
1479+ err = scheduler.Add(
1480+ "image-prune",
1481+ "Image prune",
1482+ domain.CronSchedule{Preset: preset},
1483+ func(jobCtx context.Context) error {
1484+ report, err := svc.imageSvc.Prune(jobCtx, cfg.Images.Prune.KeepLast)
1485+ if err != nil {
1486+ return err
1487+ }
1488+
1489+ log.Info().
1490+ Int("runtime_deleted", report.Runtime.DeletedCount).
1491+ Int64("runtime_reclaimed_bytes", report.Runtime.SpaceReclaimed).
1492+ Int("registry_tags_removed", report.Registry.TagsRemoved).
1493+ Int("registry_blobs_removed", report.Registry.BlobsRemoved).
1494+ Msg("scheduled image prune complete")
1495+ return nil
1496+ },
1497+ )
1498+ if err != nil {
1499+ return nil, log.WrapErr(err, "failed to register image prune schedule")
1500+ }
1501+
1502+ scheduler.Start(ctx)
1503+ log.Info().
1504+ Str("schedule", string(preset)).
1505+ Int("keep_last", cfg.Images.Prune.KeepLast).
1506+ Msg("image prune scheduler enabled")
1507+
1508+ return scheduler, nil
1509+}
1510+
1511+func resolveImagePruneSchedule(raw string) (domain.BackupSchedule, error) {
1512+ schedule := domain.BackupSchedule(strings.ToLower(strings.TrimSpace(raw)))
1513+ if schedule == "" {
1514+ schedule = domain.ScheduleDaily
1515+ }
1516+
1517+ switch schedule {
1518+ case domain.ScheduleHourly, domain.ScheduleDaily, domain.ScheduleWeekly, domain.ScheduleMonthly:
1519+ return schedule, nil
1520+ default:
1521+ return "", fmt.Errorf("images.prune.schedule must be one of: hourly, daily, weekly, monthly")
1522+ }
1523+}
1524+
1445 // waitForShutdown blocks on the event loop, handling server errors and1525 // waitForShutdown blocks on the event loop, handling server errors and
1446 // Unix signals (reload, deploy, shutdown) until the context is cancelled.1526 // Unix signals (reload, deploy, shutdown) until the context is cancelled.
1447 func waitForShutdown(ctx context.Context, errChan <-chan error, reloadChan, deployChan <-chan os.Signal, eventBus out.EventBus, log zerowrap.Logger) {1527 func waitForShutdown(ctx context.Context, errChan <-chan error, reloadChan, deployChan <-chan os.Signal, eventBus out.EventBus, log zerowrap.Logger) {
@@ -1861,6 +1941,9 @@ func loadConfig(v *viper.Viper, configPath string) error {
1861 v.SetDefault("backups.retention.daily", 0)1941 v.SetDefault("backups.retention.daily", 0)
1862 v.SetDefault("backups.retention.weekly", 0)1942 v.SetDefault("backups.retention.weekly", 0)
1863 v.SetDefault("backups.retention.monthly", 0)1943 v.SetDefault("backups.retention.monthly", 0)
1944+ v.SetDefault("images.prune.enabled", false)
1945+ v.SetDefault("images.prune.schedule", string(domain.ScheduleDaily))
1946+ v.SetDefault("images.prune.keep_last", 3)
1864 v.SetDefault("server.max_concurrent_connections", -1) // -1 = use default (10000), 0 = no limit1947 v.SetDefault("server.max_concurrent_connections", -1) // -1 = use default (10000), 0 = no limit
1865 v.SetDefault("server.registry_allowed_ips", []string{})1948 v.SetDefault("server.registry_allowed_ips", []string{})
1866 v.SetDefault("deploy.readiness_delay", "5s")1949 v.SetDefault("deploy.readiness_delay", "5s")
A internal/app/run_images_test.go +50 -0
@@ -0,0 +1,50 @@
1+package app
2+
3+import (
4+ "testing"
5+
6+ "github.com/spf13/viper"
7+ "github.com/stretchr/testify/assert"
8+ "github.com/stretchr/testify/require"
9+
10+ "github.com/bnema/gordon/internal/domain"
11+)
12+
13+func TestLoadConfigSetsImagePruneDefaults(t *testing.T) {
14+ v := viper.New()
15+
16+ require.NoError(t, loadConfig(v, ""))
17+
18+ assert.False(t, v.GetBool("images.prune.enabled"))
19+ assert.Equal(t, string(domain.ScheduleDaily), v.GetString("images.prune.schedule"))
20+ assert.Equal(t, 3, v.GetInt("images.prune.keep_last"))
21+}
22+
23+func TestResolveImagePruneSchedule(t *testing.T) {
24+ t.Run("valid values", func(t *testing.T) {
25+ tests := []struct {
26+ input string
27+ want domain.BackupSchedule
28+ }{
29+ {input: "hourly", want: domain.ScheduleHourly},
30+ {input: "daily", want: domain.ScheduleDaily},
31+ {input: "weekly", want: domain.ScheduleWeekly},
32+ {input: "monthly", want: domain.ScheduleMonthly},
33+ {input: " DAILY ", want: domain.ScheduleDaily},
34+ }
35+
36+ for _, tt := range tests {
37+ t.Run(tt.input, func(t *testing.T) {
38+ got, err := resolveImagePruneSchedule(tt.input)
39+ require.NoError(t, err)
40+ assert.Equal(t, tt.want, got)
41+ })
42+ }
43+ })
44+
45+ t.Run("invalid value", func(t *testing.T) {
46+ _, err := resolveImagePruneSchedule("every-minute")
47+ require.Error(t, err)
48+ assert.Contains(t, err.Error(), "images.prune.schedule")
49+ })
50+}
@@ -0,0 +1,50 @@
1+package app
2+
3+import (
4+ "testing"
5+
6+ "github.com/spf13/viper"
7+ "github.com/stretchr/testify/assert"
8+ "github.com/stretchr/testify/require"
9+
10+ "github.com/bnema/gordon/internal/domain"
11+)
12+
13+func TestLoadConfigSetsImagePruneDefaults(t *testing.T) {
14+ v := viper.New()
15+
16+ require.NoError(t, loadConfig(v, ""))
17+
18+ assert.False(t, v.GetBool("images.prune.enabled"))
19+ assert.Equal(t, string(domain.ScheduleDaily), v.GetString("images.prune.schedule"))
20+ assert.Equal(t, 3, v.GetInt("images.prune.keep_last"))
21+}
22+
23+func TestResolveImagePruneSchedule(t *testing.T) {
24+ t.Run("valid values", func(t *testing.T) {
25+ tests := []struct {
26+ input string
27+ want domain.BackupSchedule
28+ }{
29+ {input: "hourly", want: domain.ScheduleHourly},
30+ {input: "daily", want: domain.ScheduleDaily},
31+ {input: "weekly", want: domain.ScheduleWeekly},
32+ {input: "monthly", want: domain.ScheduleMonthly},
33+ {input: " DAILY ", want: domain.ScheduleDaily},
34+ }
35+
36+ for _, tt := range tests {
37+ t.Run(tt.input, func(t *testing.T) {
38+ got, err := resolveImagePruneSchedule(tt.input)
39+ require.NoError(t, err)
40+ assert.Equal(t, tt.want, got)
41+ })
42+ }
43+ })
44+
45+ t.Run("invalid value", func(t *testing.T) {
46+ _, err := resolveImagePruneSchedule("every-minute")
47+ require.Error(t, err)
48+ assert.Contains(t, err.Error(), "images.prune.schedule")
49+ })
50+}
M internal/usecase/images/service.go +7 -2
@@ -17,15 +17,20 @@ import (
1717
1818 // Service implements image list and prune operations.
1919 type Service struct {
20- runtime runtime.Runtime
20+ runtime imageRuntime
2121 manifestStorage out.ManifestStorage
2222 blobStorage out.BlobStorage
2323 log zerowrap.Logger
2424 }
2525
26+type imageRuntime interface {
27+ ListImagesDetailed(ctx context.Context) ([]runtime.ImageDetail, error)
28+ PruneImages(ctx context.Context, danglingOnly bool) (runtime.PruneReport, error)
29+}
30+
2631 // NewService creates a new images service.
2732 func NewService(
28- rt runtime.Runtime,
33+ rt imageRuntime,
2934 manifestStorage out.ManifestStorage,
3035 blobStorage out.BlobStorage,
3136 log zerowrap.Logger,
@@ -17,15 +17,20 @@ import (
17 17
18 // Service implements image list and prune operations.18 // Service implements image list and prune operations.
19 type Service struct {19 type Service struct {
20- runtime runtime.Runtime20+ runtime imageRuntime
21 manifestStorage out.ManifestStorage21 manifestStorage out.ManifestStorage
22 blobStorage out.BlobStorage22 blobStorage out.BlobStorage
23 log zerowrap.Logger23 log zerowrap.Logger
24 }24 }
25 25
26+type imageRuntime interface {
27+ ListImagesDetailed(ctx context.Context) ([]runtime.ImageDetail, error)
28+ PruneImages(ctx context.Context, danglingOnly bool) (runtime.PruneReport, error)
29+}
30+
26 // NewService creates a new images service.31 // NewService creates a new images service.
27 func NewService(32 func NewService(
28- rt runtime.Runtime,33+ rt imageRuntime,
29 manifestStorage out.ManifestStorage,34 manifestStorage out.ManifestStorage,
30 blobStorage out.BlobStorage,35 blobStorage out.BlobStorage,
31 log zerowrap.Logger,36 log zerowrap.Logger,

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