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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 | |||
| 587 | 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 | 592 | log := zerowrap.FromCtx(ctx) | |
| 592 | 593 | for _, containerName := range p.Containers { | |
| 593 | 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 | 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 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. | |
| 606 | 613 | if err := svc.configSvc.RemoveRoute(ctx, p.Domain); err != nil { | |
| 607 | 614 | if errors.Is(err, domain.ErrRouteNotFound) { | |
| 608 | 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 | 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 | ||
| @@ -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 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. | |||
| 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 | |||
| 248 | 248 | return orphans | |
| 249 | 249 | } | |
| 250 | 250 | ||
| 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)) { | |
| 254 | 256 | go func() { | |
| 255 | 257 | ticker := time.NewTicker(interval) | |
| 256 | 258 | defer ticker.Stop() | |
| @@ -263,6 +265,9 @@ func (s *Service) StartTicker(ctx context.Context, interval time.Duration, teard | |||
| 263 | 265 | for _, p := range expired { | |
| 264 | 266 | teardownFn(ctx, p) | |
| 265 | 267 | } | |
| 268 | + | if orphanGCFn != nil { | |
| 269 | + | orphanGCFn(ctx) | |
| 270 | + | } | |
| 266 | 271 | } | |
| 267 | 272 | } | |
| 268 | 273 | }() | |
| @@ -248,9 +248,11 @@ func (s *Service) CollectOrphans(ctx context.Context, lister ContainerLister, ta | |||||
| 248 | return orphans | 248 | 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 | }() | ||