detached a67455d

feat(preview): wire orphan GC into ticker, change interval to 1h

Add orphan GC callback to StartTicker. The ticker now runs every hour
(was 60s) and performs two passes: tracked preview cleanup followed by
orphan container detection and full teardown (container, volumes,
network, route, proxy cache).

Also adds network removal to tracked preview teardown for consistency.
commit
a67455df44f342007d4f2e456c3b598241c9beff
author
brice · · 2026-03-24 08:36 UTC
committer
brice · · 2026-03-25 05:38 UTC
parent
fd61806
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +68 -5
M internal/app/run.go +60 -2
@@ -587,7 +587,8 @@ func initPreviewService(ctx context.Context, cfg Config, svc *services, log zero
587587 log.Warn().Err(err).Msg("failed to load previews")
588588 }
589589
590- svc.previewService.StartTicker(ctx, 60*time.Second, func(ctx context.Context, p domain.PreviewRoute) {
590+ svc.previewService.StartTicker(ctx, 1*time.Hour, func(ctx context.Context, p domain.PreviewRoute) {
591+ // Tracked preview teardown.
591592 log := zerowrap.FromCtx(ctx)
592593 for _, containerName := range p.Containers {
593594 if err := svc.runtime.StopContainer(ctx, containerName); err != nil {
@@ -602,7 +603,13 @@ func initPreviewService(ctx context.Context, cfg Config, svc *services, log zero
602603 log.Warn().Err(err).Str("volume", volName).Str("preview", p.Domain).Msg("failed to remove preview volume")
603604 }
604605 }
605- // Remove route from config so proxy stops routing to this domain
606+ // Remove network (naming convention: {networkPrefix}-{domain-sanitized}).
607+ networkPrefix := svc.configSvc.GetNetworkPrefix()
608+ networkName := networkPrefix + "-" + strings.ReplaceAll(p.Domain, ".", "-")
609+ if err := svc.runtime.RemoveNetwork(ctx, networkName); err != nil {
610+ log.Warn().Err(err).Str("network", networkName).Str("preview", p.Domain).Msg("failed to remove preview network")
611+ }
612+ // Remove route from config so proxy stops routing to this domain.
606613 if err := svc.configSvc.RemoveRoute(ctx, p.Domain); err != nil {
607614 if errors.Is(err, domain.ErrRouteNotFound) {
608615 log.Debug().Str("domain", p.Domain).Msg("preview route already removed from config")
@@ -611,6 +618,57 @@ func initPreviewService(ctx context.Context, cfg Config, svc *services, log zero
611618 }
612619 }
613620 svc.proxySvc.InvalidateTarget(ctx, p.Domain)
621+ }, func(ctx context.Context) {
622+ // Orphan GC: find and tear down untracked preview containers.
623+ log := zerowrap.FromCtx(ctx)
624+ orphans := svc.previewService.CollectOrphans(ctx, svc.runtime, previewConfig.TagPatterns, previewConfig.Separator)
625+ for _, c := range orphans {
626+ orphanDomain := c.Labels[domain.LabelDomain]
627+ log.Warn().Str("container", c.Name).Str("image", c.Image).Str("domain", orphanDomain).
628+ Time("created", c.Created).Msg("orphaned preview container detected, cleaning up")
629+
630+ // Stop and remove container.
631+ if err := svc.runtime.StopContainer(ctx, c.Name); err != nil {
632+ log.Warn().Err(err).Str("container", c.Name).Msg("failed to stop orphan container")
633+ }
634+ if err := svc.runtime.RemoveContainer(ctx, c.Name, true); err != nil {
635+ log.Warn().Err(err).Str("container", c.Name).Msg("failed to remove orphan container")
636+ }
637+
638+ if orphanDomain != "" {
639+ // Remove volumes matching domain prefix (convention: {volPrefix}-{domain-sanitized}-).
640+ _, volPrefix, _ := svc.configSvc.GetVolumeConfig()
641+ domainSanitized := strings.ReplaceAll(orphanDomain, ".", "-")
642+ fullVolumePrefix := volPrefix + "-" + domainSanitized + "-"
643+ volumes, err := svc.runtime.ListVolumes(ctx)
644+ if err == nil {
645+ for _, v := range volumes {
646+ if strings.HasPrefix(v.Name, fullVolumePrefix) {
647+ if err := svc.runtime.RemoveVolume(ctx, v.Name, true); err != nil {
648+ log.Warn().Err(err).Str("volume", v.Name).Msg("failed to remove orphan volume")
649+ }
650+ }
651+ }
652+ }
653+
654+ // Remove network (convention: {networkPrefix}-{domain-sanitized}).
655+ networkPrefix := svc.configSvc.GetNetworkPrefix()
656+ networkName := networkPrefix + "-" + domainSanitized
657+ if err := svc.runtime.RemoveNetwork(ctx, networkName); err != nil {
658+ log.Warn().Err(err).Str("network", networkName).Msg("failed to remove orphan network")
659+ }
660+
661+ // Remove route and invalidate proxy.
662+ if err := svc.configSvc.RemoveRoute(ctx, orphanDomain); err != nil {
663+ if !errors.Is(err, domain.ErrRouteNotFound) {
664+ log.Warn().Err(err).Str("domain", orphanDomain).Msg("failed to remove orphan route")
665+ }
666+ }
667+ svc.proxySvc.InvalidateTarget(ctx, orphanDomain)
668+ }
669+
670+ log.Info().Str("container", c.Name).Str("domain", orphanDomain).Msg("orphaned preview container cleaned up")
671+ }
614672 })
615673 }
616674
@@ -587,7 +587,8 @@ func initPreviewService(ctx context.Context, cfg Config, svc *services, log zero
587 log.Warn().Err(err).Msg("failed to load previews")587 log.Warn().Err(err).Msg("failed to load previews")
588 }588 }
589 589
590- svc.previewService.StartTicker(ctx, 60*time.Second, func(ctx context.Context, p domain.PreviewRoute) {590+ svc.previewService.StartTicker(ctx, 1*time.Hour, func(ctx context.Context, p domain.PreviewRoute) {
591+ // Tracked preview teardown.
591 log := zerowrap.FromCtx(ctx)592 log := zerowrap.FromCtx(ctx)
592 for _, containerName := range p.Containers {593 for _, containerName := range p.Containers {
593 if err := svc.runtime.StopContainer(ctx, containerName); err != nil {594 if err := svc.runtime.StopContainer(ctx, containerName); err != nil {
@@ -602,7 +603,13 @@ func initPreviewService(ctx context.Context, cfg Config, svc *services, log zero
602 log.Warn().Err(err).Str("volume", volName).Str("preview", p.Domain).Msg("failed to remove preview volume")603 log.Warn().Err(err).Str("volume", volName).Str("preview", p.Domain).Msg("failed to remove preview volume")
603 }604 }
604 }605 }
605- // Remove route from config so proxy stops routing to this domain606+ // Remove network (naming convention: {networkPrefix}-{domain-sanitized}).
607+ networkPrefix := svc.configSvc.GetNetworkPrefix()
608+ networkName := networkPrefix + "-" + strings.ReplaceAll(p.Domain, ".", "-")
609+ if err := svc.runtime.RemoveNetwork(ctx, networkName); err != nil {
610+ log.Warn().Err(err).Str("network", networkName).Str("preview", p.Domain).Msg("failed to remove preview network")
611+ }
612+ // Remove route from config so proxy stops routing to this domain.
606 if err := svc.configSvc.RemoveRoute(ctx, p.Domain); err != nil {613 if err := svc.configSvc.RemoveRoute(ctx, p.Domain); err != nil {
607 if errors.Is(err, domain.ErrRouteNotFound) {614 if errors.Is(err, domain.ErrRouteNotFound) {
608 log.Debug().Str("domain", p.Domain).Msg("preview route already removed from config")615 log.Debug().Str("domain", p.Domain).Msg("preview route already removed from config")
@@ -611,6 +618,57 @@ func initPreviewService(ctx context.Context, cfg Config, svc *services, log zero
611 }618 }
612 }619 }
613 svc.proxySvc.InvalidateTarget(ctx, p.Domain)620 svc.proxySvc.InvalidateTarget(ctx, p.Domain)
621+ }, func(ctx context.Context) {
622+ // Orphan GC: find and tear down untracked preview containers.
623+ log := zerowrap.FromCtx(ctx)
624+ orphans := svc.previewService.CollectOrphans(ctx, svc.runtime, previewConfig.TagPatterns, previewConfig.Separator)
625+ for _, c := range orphans {
626+ orphanDomain := c.Labels[domain.LabelDomain]
627+ log.Warn().Str("container", c.Name).Str("image", c.Image).Str("domain", orphanDomain).
628+ Time("created", c.Created).Msg("orphaned preview container detected, cleaning up")
629+
630+ // Stop and remove container.
631+ if err := svc.runtime.StopContainer(ctx, c.Name); err != nil {
632+ log.Warn().Err(err).Str("container", c.Name).Msg("failed to stop orphan container")
633+ }
634+ if err := svc.runtime.RemoveContainer(ctx, c.Name, true); err != nil {
635+ log.Warn().Err(err).Str("container", c.Name).Msg("failed to remove orphan container")
636+ }
637+
638+ if orphanDomain != "" {
639+ // Remove volumes matching domain prefix (convention: {volPrefix}-{domain-sanitized}-).
640+ _, volPrefix, _ := svc.configSvc.GetVolumeConfig()
641+ domainSanitized := strings.ReplaceAll(orphanDomain, ".", "-")
642+ fullVolumePrefix := volPrefix + "-" + domainSanitized + "-"
643+ volumes, err := svc.runtime.ListVolumes(ctx)
644+ if err == nil {
645+ for _, v := range volumes {
646+ if strings.HasPrefix(v.Name, fullVolumePrefix) {
647+ if err := svc.runtime.RemoveVolume(ctx, v.Name, true); err != nil {
648+ log.Warn().Err(err).Str("volume", v.Name).Msg("failed to remove orphan volume")
649+ }
650+ }
651+ }
652+ }
653+
654+ // Remove network (convention: {networkPrefix}-{domain-sanitized}).
655+ networkPrefix := svc.configSvc.GetNetworkPrefix()
656+ networkName := networkPrefix + "-" + domainSanitized
657+ if err := svc.runtime.RemoveNetwork(ctx, networkName); err != nil {
658+ log.Warn().Err(err).Str("network", networkName).Msg("failed to remove orphan network")
659+ }
660+
661+ // Remove route and invalidate proxy.
662+ if err := svc.configSvc.RemoveRoute(ctx, orphanDomain); err != nil {
663+ if !errors.Is(err, domain.ErrRouteNotFound) {
664+ log.Warn().Err(err).Str("domain", orphanDomain).Msg("failed to remove orphan route")
665+ }
666+ }
667+ svc.proxySvc.InvalidateTarget(ctx, orphanDomain)
668+ }
669+
670+ log.Info().Str("container", c.Name).Str("domain", orphanDomain).Msg("orphaned preview container cleaned up")
671+ }
614 })672 })
615 }673 }
616 674
M internal/usecase/auto/preview/service.go +8 -3
@@ -248,9 +248,11 @@ func (s *Service) CollectOrphans(ctx context.Context, lister ContainerLister, ta
248248 return orphans
249249 }
250250
251-// StartTicker starts a background goroutine that checks for expired previews.
252-// The teardownFn is called for each expired preview to clean up containers/volumes.
253-func (s *Service) StartTicker(ctx context.Context, interval time.Duration, teardownFn func(context.Context, domain.PreviewRoute)) {
251+// StartTicker starts a background goroutine that checks for expired previews
252+// and orphaned preview containers. The teardownFn is called for each expired
253+// tracked preview. The orphanGCFn, if non-nil, is called on each tick to
254+// clean up orphaned containers.
255+func (s *Service) StartTicker(ctx context.Context, interval time.Duration, teardownFn func(context.Context, domain.PreviewRoute), orphanGCFn func(context.Context)) {
254256 go func() {
255257 ticker := time.NewTicker(interval)
256258 defer ticker.Stop()
@@ -263,6 +265,9 @@ func (s *Service) StartTicker(ctx context.Context, interval time.Duration, teard
263265 for _, p := range expired {
264266 teardownFn(ctx, p)
265267 }
268+ if orphanGCFn != nil {
269+ orphanGCFn(ctx)
270+ }
266271 }
267272 }
268273 }()
@@ -248,9 +248,11 @@ func (s *Service) CollectOrphans(ctx context.Context, lister ContainerLister, ta
248 return orphans248 return orphans
249 }249 }
250 250
251-// StartTicker starts a background goroutine that checks for expired previews.251+// StartTicker starts a background goroutine that checks for expired previews
252-// The teardownFn is called for each expired preview to clean up containers/volumes.252+// and orphaned preview containers. The teardownFn is called for each expired
253-func (s *Service) StartTicker(ctx context.Context, interval time.Duration, teardownFn func(context.Context, domain.PreviewRoute)) {253+// tracked preview. The orphanGCFn, if non-nil, is called on each tick to
254+// clean up orphaned containers.
255+func (s *Service) StartTicker(ctx context.Context, interval time.Duration, teardownFn func(context.Context, domain.PreviewRoute), orphanGCFn func(context.Context)) {
254 go func() {256 go func() {
255 ticker := time.NewTicker(interval)257 ticker := time.NewTicker(interval)
256 defer ticker.Stop()258 defer ticker.Stop()
@@ -263,6 +265,9 @@ func (s *Service) StartTicker(ctx context.Context, interval time.Duration, teard
263 for _, p := range expired {265 for _, p := range expired {
264 teardownFn(ctx, p)266 teardownFn(ctx, p)
265 }267 }
268+ if orphanGCFn != nil {
269+ orphanGCFn(ctx)
270+ }
266 }271 }
267 }272 }
268 }()273 }()

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