detached fd61806

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 VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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 {
225225 return expired
226226 }
227227
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+
228251 // StartTicker starts a background goroutine that checks for expired previews.
229252 // The teardownFn is called for each expired preview to clean up containers/volumes.
230253 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 expired225 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) {
140140 err := svc.Update(t.Context(), domain.PreviewRoute{Name: "nonexistent"})
141141 assert.ErrorIs(t, err, domain.ErrPreviewNotFound)
142142 }
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+}

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