feat(preview): add CollectOrphans method to service
Combines IsOrphanPreview and IsExpiredOrphan to scan running containers and return those that are untracked preview orphans past their TTL.
- commit
- fd61806a1a5bc4c56867a91a5ba315931f8f53a5
- author
- brice · · 2026-03-24 08:34 UTC
- committer
- brice · · 2026-03-25 05:38 UTC
- parent
- cb4022c
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
2 files · +96 -0
M internal/usecase/auto/preview/service.go +23 -0
| @@ -225,6 +225,29 @@ func (s *Service) CleanupExpired(ctx context.Context) []domain.PreviewRoute { | |||
| 225 | 225 | return expired | |
| 226 | 226 | } | |
| 227 | 227 | ||
| 228 | + | // CollectOrphans lists Docker containers and returns those that look like | |
| 229 | + | // expired preview orphans (not tracked, matching tag pattern + separator, | |
| 230 | + | // past TTL based on container creation time). | |
| 231 | + | func (s *Service) CollectOrphans(ctx context.Context, lister ContainerLister, tagPatterns []string, separator string) []*domain.Container { | |
| 232 | + | containers, err := lister.ListContainers(ctx, false) | |
| 233 | + | if err != nil { | |
| 234 | + | return nil | |
| 235 | + | } | |
| 236 | + | ||
| 237 | + | s.mu.RLock() | |
| 238 | + | tracked := make([]domain.PreviewRoute, len(s.previews)) | |
| 239 | + | copy(tracked, s.previews) | |
| 240 | + | s.mu.RUnlock() | |
| 241 | + | ||
| 242 | + | var orphans []*domain.Container | |
| 243 | + | for _, c := range containers { | |
| 244 | + | if IsOrphanPreview(c, tracked, tagPatterns, separator) && IsExpiredOrphan(c, s.defaultTTL) { | |
| 245 | + | orphans = append(orphans, c) | |
| 246 | + | } | |
| 247 | + | } | |
| 248 | + | return orphans | |
| 249 | + | } | |
| 250 | + | ||
| 228 | 251 | // StartTicker starts a background goroutine that checks for expired previews. | |
| 229 | 252 | // The teardownFn is called for each expired preview to clean up containers/volumes. | |
| 230 | 253 | func (s *Service) StartTicker(ctx context.Context, interval time.Duration, teardownFn func(context.Context, domain.PreviewRoute)) { | |
| @@ -225,6 +225,29 @@ func (s *Service) CleanupExpired(ctx context.Context) []domain.PreviewRoute { | |||||
| 225 | return expired | 225 | return expired | ||
| 226 | } | 226 | } | ||
| 227 | 227 | ||||
| 228 | + | // CollectOrphans lists Docker containers and returns those that look like | |||
| 229 | + | // expired preview orphans (not tracked, matching tag pattern + separator, | |||
| 230 | + | // past TTL based on container creation time). | |||
| 231 | + | func (s *Service) CollectOrphans(ctx context.Context, lister ContainerLister, tagPatterns []string, separator string) []*domain.Container { | |||
| 232 | + | containers, err := lister.ListContainers(ctx, false) | |||
| 233 | + | if err != nil { | |||
| 234 | + | return nil | |||
| 235 | + | } | |||
| 236 | + | ||||
| 237 | + | s.mu.RLock() | |||
| 238 | + | tracked := make([]domain.PreviewRoute, len(s.previews)) | |||
| 239 | + | copy(tracked, s.previews) | |||
| 240 | + | s.mu.RUnlock() | |||
| 241 | + | ||||
| 242 | + | var orphans []*domain.Container | |||
| 243 | + | for _, c := range containers { | |||
| 244 | + | if IsOrphanPreview(c, tracked, tagPatterns, separator) && IsExpiredOrphan(c, s.defaultTTL) { | |||
| 245 | + | orphans = append(orphans, c) | |||
| 246 | + | } | |||
| 247 | + | } | |||
| 248 | + | return orphans | |||
| 249 | + | } | |||
| 250 | + | ||||
| 228 | // StartTicker starts a background goroutine that checks for expired previews. | 251 | // StartTicker starts a background goroutine that checks for expired previews. | ||
| 229 | // The teardownFn is called for each expired preview to clean up containers/volumes. | 252 | // The teardownFn is called for each expired preview to clean up containers/volumes. | ||
| 230 | func (s *Service) StartTicker(ctx context.Context, interval time.Duration, teardownFn func(context.Context, domain.PreviewRoute)) { | 253 | func (s *Service) StartTicker(ctx context.Context, interval time.Duration, teardownFn func(context.Context, domain.PreviewRoute)) { | ||
M internal/usecase/auto/preview/service_test.go +73 -0
| @@ -140,3 +140,76 @@ func TestPreviewService_Update_NotFound(t *testing.T) { | |||
| 140 | 140 | err := svc.Update(t.Context(), domain.PreviewRoute{Name: "nonexistent"}) | |
| 141 | 141 | assert.ErrorIs(t, err, domain.ErrPreviewNotFound) | |
| 142 | 142 | } | |
| 143 | + | ||
| 144 | + | type fakeRuntime struct { | |
| 145 | + | containers []*domain.Container | |
| 146 | + | } | |
| 147 | + | ||
| 148 | + | func (f *fakeRuntime) ListContainers(_ context.Context, _ bool) ([]*domain.Container, error) { | |
| 149 | + | return f.containers, nil | |
| 150 | + | } | |
| 151 | + | ||
| 152 | + | func TestPreviewService_CollectOrphans(t *testing.T) { | |
| 153 | + | store := &fakeStore{ | |
| 154 | + | previews: []domain.PreviewRoute{ | |
| 155 | + | {Name: "tracked", Domain: "myapp--tracked.example.com"}, | |
| 156 | + | }, | |
| 157 | + | } | |
| 158 | + | svc := NewService(store, 48*time.Hour) | |
| 159 | + | require.NoError(t, svc.Load(t.Context())) | |
| 160 | + | ||
| 161 | + | runtime := &fakeRuntime{ | |
| 162 | + | containers: []*domain.Container{ | |
| 163 | + | { | |
| 164 | + | Name: "gordon-myapp--orphan.example.com", | |
| 165 | + | Created: time.Now().Add(-72 * time.Hour), | |
| 166 | + | Labels: map[string]string{ | |
| 167 | + | domain.LabelImage: "myapp:preview-orphan", | |
| 168 | + | domain.LabelDomain: "myapp--orphan.example.com", | |
| 169 | + | }, | |
| 170 | + | }, | |
| 171 | + | { | |
| 172 | + | Name: "gordon-myapp--tracked.example.com", | |
| 173 | + | Created: time.Now().Add(-72 * time.Hour), | |
| 174 | + | Labels: map[string]string{ | |
| 175 | + | domain.LabelImage: "myapp:preview-tracked", | |
| 176 | + | domain.LabelDomain: "myapp--tracked.example.com", | |
| 177 | + | }, | |
| 178 | + | }, | |
| 179 | + | { | |
| 180 | + | Name: "gordon-prod.example.com", | |
| 181 | + | Created: time.Now().Add(-72 * time.Hour), | |
| 182 | + | Labels: map[string]string{ | |
| 183 | + | domain.LabelImage: "myapp:latest", | |
| 184 | + | domain.LabelDomain: "prod.example.com", | |
| 185 | + | }, | |
| 186 | + | }, | |
| 187 | + | }, | |
| 188 | + | } | |
| 189 | + | ||
| 190 | + | orphans := svc.CollectOrphans(t.Context(), runtime, []string{"preview-*"}, "--") | |
| 191 | + | assert.Len(t, orphans, 1) | |
| 192 | + | assert.Equal(t, "gordon-myapp--orphan.example.com", orphans[0].Name) | |
| 193 | + | } | |
| 194 | + | ||
| 195 | + | func TestPreviewService_CollectOrphans_NotExpired(t *testing.T) { | |
| 196 | + | store := &fakeStore{} | |
| 197 | + | svc := NewService(store, 48*time.Hour) | |
| 198 | + | require.NoError(t, svc.Load(t.Context())) | |
| 199 | + | ||
| 200 | + | runtime := &fakeRuntime{ | |
| 201 | + | containers: []*domain.Container{ | |
| 202 | + | { | |
| 203 | + | Name: "gordon-myapp--recent.example.com", | |
| 204 | + | Created: time.Now().Add(-12 * time.Hour), | |
| 205 | + | Labels: map[string]string{ | |
| 206 | + | domain.LabelImage: "myapp:preview-recent", | |
| 207 | + | domain.LabelDomain: "myapp--recent.example.com", | |
| 208 | + | }, | |
| 209 | + | }, | |
| 210 | + | }, | |
| 211 | + | } | |
| 212 | + | ||
| 213 | + | orphans := svc.CollectOrphans(t.Context(), runtime, []string{"preview-*"}, "--") | |
| 214 | + | assert.Empty(t, orphans) | |
| 215 | + | } | |
| @@ -140,3 +140,76 @@ func TestPreviewService_Update_NotFound(t *testing.T) { | |||||
| 140 | err := svc.Update(t.Context(), domain.PreviewRoute{Name: "nonexistent"}) | 140 | err := svc.Update(t.Context(), domain.PreviewRoute{Name: "nonexistent"}) | ||
| 141 | assert.ErrorIs(t, err, domain.ErrPreviewNotFound) | 141 | assert.ErrorIs(t, err, domain.ErrPreviewNotFound) | ||
| 142 | } | 142 | } | ||
| 143 | + | ||||
| 144 | + | type fakeRuntime struct { | |||
| 145 | + | containers []*domain.Container | |||
| 146 | + | } | |||
| 147 | + | ||||
| 148 | + | func (f *fakeRuntime) ListContainers(_ context.Context, _ bool) ([]*domain.Container, error) { | |||
| 149 | + | return f.containers, nil | |||
| 150 | + | } | |||
| 151 | + | ||||
| 152 | + | func TestPreviewService_CollectOrphans(t *testing.T) { | |||
| 153 | + | store := &fakeStore{ | |||
| 154 | + | previews: []domain.PreviewRoute{ | |||
| 155 | + | {Name: "tracked", Domain: "myapp--tracked.example.com"}, | |||
| 156 | + | }, | |||
| 157 | + | } | |||
| 158 | + | svc := NewService(store, 48*time.Hour) | |||
| 159 | + | require.NoError(t, svc.Load(t.Context())) | |||
| 160 | + | ||||
| 161 | + | runtime := &fakeRuntime{ | |||
| 162 | + | containers: []*domain.Container{ | |||
| 163 | + | { | |||
| 164 | + | Name: "gordon-myapp--orphan.example.com", | |||
| 165 | + | Created: time.Now().Add(-72 * time.Hour), | |||
| 166 | + | Labels: map[string]string{ | |||
| 167 | + | domain.LabelImage: "myapp:preview-orphan", | |||
| 168 | + | domain.LabelDomain: "myapp--orphan.example.com", | |||
| 169 | + | }, | |||
| 170 | + | }, | |||
| 171 | + | { | |||
| 172 | + | Name: "gordon-myapp--tracked.example.com", | |||
| 173 | + | Created: time.Now().Add(-72 * time.Hour), | |||
| 174 | + | Labels: map[string]string{ | |||
| 175 | + | domain.LabelImage: "myapp:preview-tracked", | |||
| 176 | + | domain.LabelDomain: "myapp--tracked.example.com", | |||
| 177 | + | }, | |||
| 178 | + | }, | |||
| 179 | + | { | |||
| 180 | + | Name: "gordon-prod.example.com", | |||
| 181 | + | Created: time.Now().Add(-72 * time.Hour), | |||
| 182 | + | Labels: map[string]string{ | |||
| 183 | + | domain.LabelImage: "myapp:latest", | |||
| 184 | + | domain.LabelDomain: "prod.example.com", | |||
| 185 | + | }, | |||
| 186 | + | }, | |||
| 187 | + | }, | |||
| 188 | + | } | |||
| 189 | + | ||||
| 190 | + | orphans := svc.CollectOrphans(t.Context(), runtime, []string{"preview-*"}, "--") | |||
| 191 | + | assert.Len(t, orphans, 1) | |||
| 192 | + | assert.Equal(t, "gordon-myapp--orphan.example.com", orphans[0].Name) | |||
| 193 | + | } | |||
| 194 | + | ||||
| 195 | + | func TestPreviewService_CollectOrphans_NotExpired(t *testing.T) { | |||
| 196 | + | store := &fakeStore{} | |||
| 197 | + | svc := NewService(store, 48*time.Hour) | |||
| 198 | + | require.NoError(t, svc.Load(t.Context())) | |||
| 199 | + | ||||
| 200 | + | runtime := &fakeRuntime{ | |||
| 201 | + | containers: []*domain.Container{ | |||
| 202 | + | { | |||
| 203 | + | Name: "gordon-myapp--recent.example.com", | |||
| 204 | + | Created: time.Now().Add(-12 * time.Hour), | |||
| 205 | + | Labels: map[string]string{ | |||
| 206 | + | domain.LabelImage: "myapp:preview-recent", | |||
| 207 | + | domain.LabelDomain: "myapp--recent.example.com", | |||
| 208 | + | }, | |||
| 209 | + | }, | |||
| 210 | + | }, | |||
| 211 | + | } | |||
| 212 | + | ||||
| 213 | + | orphans := svc.CollectOrphans(t.Context(), runtime, []string{"preview-*"}, "--") | |||
| 214 | + | assert.Empty(t, orphans) | |||
| 215 | + | } | |||