detached cb4022c

feat(preview): add orphan detection functions

commit
cb4022c73e47cea5eccb09f40ec3b9dbc320495b
author
brice · · 2026-03-24 08:33 UTC
committer
brice · · 2026-03-25 05:38 UTC
parent
20af812
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +251 -0
A internal/usecase/auto/preview/orphans.go +86 -0
@@ -0,0 +1,86 @@
1+package preview
2+
3+import (
4+ "context"
5+ "path/filepath"
6+ "strings"
7+ "time"
8+
9+ "github.com/bnema/gordon/internal/domain"
10+)
11+
12+const orphanGracePeriod = 5 * time.Minute
13+
14+// ContainerLister is the subset of ContainerRuntime needed by the orphan GC.
15+type ContainerLister interface {
16+ ListContainers(ctx context.Context, all bool) ([]*domain.Container, error)
17+}
18+
19+// IsOrphanPreview returns true if the container looks like a preview (by image
20+// tag pattern and domain separator) but is not in the tracked preview list.
21+// Containers younger than 5 minutes are skipped to avoid racing with
22+// CreatePreview.
23+func IsOrphanPreview(c *domain.Container, tracked []domain.PreviewRoute, tagPatterns []string, separator string) bool {
24+ imgLabel := c.Labels[domain.LabelImage]
25+ domLabel := c.Labels[domain.LabelDomain]
26+ if imgLabel == "" || domLabel == "" {
27+ return false
28+ }
29+
30+ // Grace period: skip very recent containers.
31+ if !c.Created.IsZero() && time.Since(c.Created) < orphanGracePeriod {
32+ return false
33+ }
34+
35+ // Condition 1: image tag matches a preview pattern.
36+ tag := tagFromImage(imgLabel)
37+ if !matchesAnyPattern(tag, tagPatterns) {
38+ return false
39+ }
40+
41+ // Condition 2: domain contains the separator.
42+ if !strings.Contains(domLabel, separator) {
43+ return false
44+ }
45+
46+ // Not an orphan if tracked.
47+ for _, p := range tracked {
48+ if p.Domain == domLabel {
49+ return false
50+ }
51+ }
52+ return true
53+}
54+
55+// IsExpiredOrphan returns true if the container has exceeded the TTL based on
56+// its creation time.
57+func IsExpiredOrphan(c *domain.Container, ttl time.Duration) bool {
58+ if c.Created.IsZero() {
59+ return true // no creation time — treat as expired
60+ }
61+ return time.Since(c.Created) > ttl
62+}
63+
64+// tagFromImage extracts the tag portion from an image reference.
65+// "myapp:preview-feat" -> "preview-feat"
66+// "registry.example.com/myapp:preview-feat" -> "preview-feat"
67+// "myapp" -> "" (no tag)
68+func tagFromImage(image string) string {
69+ if idx := strings.Index(image, "@"); idx != -1 {
70+ return ""
71+ }
72+ if idx := strings.LastIndex(image, ":"); idx != -1 {
73+ return image[idx+1:]
74+ }
75+ return ""
76+}
77+
78+// matchesAnyPattern checks if the tag matches any of the glob patterns.
79+func matchesAnyPattern(tag string, patterns []string) bool {
80+ for _, p := range patterns {
81+ if matched, _ := filepath.Match(p, tag); matched {
82+ return true
83+ }
84+ }
85+ return false
86+}
@@ -0,0 +1,86 @@
1+package preview
2+
3+import (
4+ "context"
5+ "path/filepath"
6+ "strings"
7+ "time"
8+
9+ "github.com/bnema/gordon/internal/domain"
10+)
11+
12+const orphanGracePeriod = 5 * time.Minute
13+
14+// ContainerLister is the subset of ContainerRuntime needed by the orphan GC.
15+type ContainerLister interface {
16+ ListContainers(ctx context.Context, all bool) ([]*domain.Container, error)
17+}
18+
19+// IsOrphanPreview returns true if the container looks like a preview (by image
20+// tag pattern and domain separator) but is not in the tracked preview list.
21+// Containers younger than 5 minutes are skipped to avoid racing with
22+// CreatePreview.
23+func IsOrphanPreview(c *domain.Container, tracked []domain.PreviewRoute, tagPatterns []string, separator string) bool {
24+ imgLabel := c.Labels[domain.LabelImage]
25+ domLabel := c.Labels[domain.LabelDomain]
26+ if imgLabel == "" || domLabel == "" {
27+ return false
28+ }
29+
30+ // Grace period: skip very recent containers.
31+ if !c.Created.IsZero() && time.Since(c.Created) < orphanGracePeriod {
32+ return false
33+ }
34+
35+ // Condition 1: image tag matches a preview pattern.
36+ tag := tagFromImage(imgLabel)
37+ if !matchesAnyPattern(tag, tagPatterns) {
38+ return false
39+ }
40+
41+ // Condition 2: domain contains the separator.
42+ if !strings.Contains(domLabel, separator) {
43+ return false
44+ }
45+
46+ // Not an orphan if tracked.
47+ for _, p := range tracked {
48+ if p.Domain == domLabel {
49+ return false
50+ }
51+ }
52+ return true
53+}
54+
55+// IsExpiredOrphan returns true if the container has exceeded the TTL based on
56+// its creation time.
57+func IsExpiredOrphan(c *domain.Container, ttl time.Duration) bool {
58+ if c.Created.IsZero() {
59+ return true // no creation time — treat as expired
60+ }
61+ return time.Since(c.Created) > ttl
62+}
63+
64+// tagFromImage extracts the tag portion from an image reference.
65+// "myapp:preview-feat" -> "preview-feat"
66+// "registry.example.com/myapp:preview-feat" -> "preview-feat"
67+// "myapp" -> "" (no tag)
68+func tagFromImage(image string) string {
69+ if idx := strings.Index(image, "@"); idx != -1 {
70+ return ""
71+ }
72+ if idx := strings.LastIndex(image, ":"); idx != -1 {
73+ return image[idx+1:]
74+ }
75+ return ""
76+}
77+
78+// matchesAnyPattern checks if the tag matches any of the glob patterns.
79+func matchesAnyPattern(tag string, patterns []string) bool {
80+ for _, p := range patterns {
81+ if matched, _ := filepath.Match(p, tag); matched {
82+ return true
83+ }
84+ }
85+ return false
86+}
A internal/usecase/auto/preview/orphans_test.go +165 -0
@@ -0,0 +1,165 @@
1+package preview
2+
3+import (
4+ "testing"
5+ "time"
6+
7+ "github.com/stretchr/testify/assert"
8+
9+ "github.com/bnema/gordon/internal/domain"
10+)
11+
12+func TestIsOrphanPreview(t *testing.T) {
13+ patterns := []string{"preview-*"}
14+ separator := "--"
15+
16+ tests := []struct {
17+ name string
18+ c *domain.Container
19+ tracked []domain.PreviewRoute
20+ want bool
21+ }{
22+ {
23+ name: "orphan preview container",
24+ c: &domain.Container{
25+ Created: time.Now().Add(-1 * time.Hour),
26+ Labels: map[string]string{
27+ domain.LabelImage: "myapp:preview-feat",
28+ domain.LabelDomain: "myapp--feat.example.com",
29+ },
30+ },
31+ tracked: nil,
32+ want: true,
33+ },
34+ {
35+ name: "tracked preview is not orphan",
36+ c: &domain.Container{
37+ Created: time.Now().Add(-1 * time.Hour),
38+ Labels: map[string]string{
39+ domain.LabelImage: "myapp:preview-feat",
40+ domain.LabelDomain: "myapp--feat.example.com",
41+ },
42+ },
43+ tracked: []domain.PreviewRoute{
44+ {Domain: "myapp--feat.example.com"},
45+ },
46+ want: false,
47+ },
48+ {
49+ name: "production container — no separator in domain",
50+ c: &domain.Container{
51+ Created: time.Now().Add(-1 * time.Hour),
52+ Labels: map[string]string{
53+ domain.LabelImage: "myapp:preview-release",
54+ domain.LabelDomain: "myapp.example.com",
55+ },
56+ },
57+ tracked: nil,
58+ want: false,
59+ },
60+ {
61+ name: "production container — no preview tag",
62+ c: &domain.Container{
63+ Created: time.Now().Add(-1 * time.Hour),
64+ Labels: map[string]string{
65+ domain.LabelImage: "myapp:latest",
66+ domain.LabelDomain: "myapp--feat.example.com",
67+ },
68+ },
69+ tracked: nil,
70+ want: false,
71+ },
72+ {
73+ name: "container without gordon labels",
74+ c: &domain.Container{
75+ Labels: map[string]string{},
76+ },
77+ tracked: nil,
78+ want: false,
79+ },
80+ {
81+ name: "container too young — grace period",
82+ c: &domain.Container{
83+ Created: time.Now().Add(-2 * time.Minute),
84+ Labels: map[string]string{
85+ domain.LabelImage: "myapp:preview-feat",
86+ domain.LabelDomain: "myapp--feat.example.com",
87+ },
88+ },
89+ tracked: nil,
90+ want: false,
91+ },
92+ {
93+ name: "digest-only image — no tag to match",
94+ c: &domain.Container{
95+ Created: time.Now().Add(-1 * time.Hour),
96+ Labels: map[string]string{
97+ domain.LabelImage: "myapp@sha256:abc123",
98+ domain.LabelDomain: "myapp--feat.example.com",
99+ },
100+ },
101+ tracked: nil,
102+ want: false,
103+ },
104+ }
105+
106+ for _, tt := range tests {
107+ t.Run(tt.name, func(t *testing.T) {
108+ got := IsOrphanPreview(tt.c, tt.tracked, patterns, separator)
109+ assert.Equal(t, tt.want, got)
110+ })
111+ }
112+}
113+
114+func TestIsExpiredOrphan(t *testing.T) {
115+ ttl := 48 * time.Hour
116+
117+ tests := []struct {
118+ name string
119+ created time.Time
120+ want bool
121+ }{
122+ {
123+ name: "expired — older than TTL",
124+ created: time.Now().Add(-72 * time.Hour),
125+ want: true,
126+ },
127+ {
128+ name: "not expired — within TTL",
129+ created: time.Now().Add(-12 * time.Hour),
130+ want: false,
131+ },
132+ {
133+ name: "zero time — treat as expired",
134+ created: time.Time{},
135+ want: true,
136+ },
137+ }
138+
139+ for _, tt := range tests {
140+ t.Run(tt.name, func(t *testing.T) {
141+ c := &domain.Container{Created: tt.created}
142+ got := IsExpiredOrphan(c, ttl)
143+ assert.Equal(t, tt.want, got)
144+ })
145+ }
146+}
147+
148+func TestTagFromImage(t *testing.T) {
149+ tests := []struct {
150+ image string
151+ want string
152+ }{
153+ {"myapp:preview-feat", "preview-feat"},
154+ {"registry.example.com/myapp:preview-feat", "preview-feat"},
155+ {"myapp:latest", "latest"},
156+ {"myapp", ""},
157+ {"myapp@sha256:abc123", ""},
158+ }
159+
160+ for _, tt := range tests {
161+ t.Run(tt.image, func(t *testing.T) {
162+ assert.Equal(t, tt.want, tagFromImage(tt.image))
163+ })
164+ }
165+}
@@ -0,0 +1,165 @@
1+package preview
2+
3+import (
4+ "testing"
5+ "time"
6+
7+ "github.com/stretchr/testify/assert"
8+
9+ "github.com/bnema/gordon/internal/domain"
10+)
11+
12+func TestIsOrphanPreview(t *testing.T) {
13+ patterns := []string{"preview-*"}
14+ separator := "--"
15+
16+ tests := []struct {
17+ name string
18+ c *domain.Container
19+ tracked []domain.PreviewRoute
20+ want bool
21+ }{
22+ {
23+ name: "orphan preview container",
24+ c: &domain.Container{
25+ Created: time.Now().Add(-1 * time.Hour),
26+ Labels: map[string]string{
27+ domain.LabelImage: "myapp:preview-feat",
28+ domain.LabelDomain: "myapp--feat.example.com",
29+ },
30+ },
31+ tracked: nil,
32+ want: true,
33+ },
34+ {
35+ name: "tracked preview is not orphan",
36+ c: &domain.Container{
37+ Created: time.Now().Add(-1 * time.Hour),
38+ Labels: map[string]string{
39+ domain.LabelImage: "myapp:preview-feat",
40+ domain.LabelDomain: "myapp--feat.example.com",
41+ },
42+ },
43+ tracked: []domain.PreviewRoute{
44+ {Domain: "myapp--feat.example.com"},
45+ },
46+ want: false,
47+ },
48+ {
49+ name: "production container — no separator in domain",
50+ c: &domain.Container{
51+ Created: time.Now().Add(-1 * time.Hour),
52+ Labels: map[string]string{
53+ domain.LabelImage: "myapp:preview-release",
54+ domain.LabelDomain: "myapp.example.com",
55+ },
56+ },
57+ tracked: nil,
58+ want: false,
59+ },
60+ {
61+ name: "production container — no preview tag",
62+ c: &domain.Container{
63+ Created: time.Now().Add(-1 * time.Hour),
64+ Labels: map[string]string{
65+ domain.LabelImage: "myapp:latest",
66+ domain.LabelDomain: "myapp--feat.example.com",
67+ },
68+ },
69+ tracked: nil,
70+ want: false,
71+ },
72+ {
73+ name: "container without gordon labels",
74+ c: &domain.Container{
75+ Labels: map[string]string{},
76+ },
77+ tracked: nil,
78+ want: false,
79+ },
80+ {
81+ name: "container too young — grace period",
82+ c: &domain.Container{
83+ Created: time.Now().Add(-2 * time.Minute),
84+ Labels: map[string]string{
85+ domain.LabelImage: "myapp:preview-feat",
86+ domain.LabelDomain: "myapp--feat.example.com",
87+ },
88+ },
89+ tracked: nil,
90+ want: false,
91+ },
92+ {
93+ name: "digest-only image — no tag to match",
94+ c: &domain.Container{
95+ Created: time.Now().Add(-1 * time.Hour),
96+ Labels: map[string]string{
97+ domain.LabelImage: "myapp@sha256:abc123",
98+ domain.LabelDomain: "myapp--feat.example.com",
99+ },
100+ },
101+ tracked: nil,
102+ want: false,
103+ },
104+ }
105+
106+ for _, tt := range tests {
107+ t.Run(tt.name, func(t *testing.T) {
108+ got := IsOrphanPreview(tt.c, tt.tracked, patterns, separator)
109+ assert.Equal(t, tt.want, got)
110+ })
111+ }
112+}
113+
114+func TestIsExpiredOrphan(t *testing.T) {
115+ ttl := 48 * time.Hour
116+
117+ tests := []struct {
118+ name string
119+ created time.Time
120+ want bool
121+ }{
122+ {
123+ name: "expired — older than TTL",
124+ created: time.Now().Add(-72 * time.Hour),
125+ want: true,
126+ },
127+ {
128+ name: "not expired — within TTL",
129+ created: time.Now().Add(-12 * time.Hour),
130+ want: false,
131+ },
132+ {
133+ name: "zero time — treat as expired",
134+ created: time.Time{},
135+ want: true,
136+ },
137+ }
138+
139+ for _, tt := range tests {
140+ t.Run(tt.name, func(t *testing.T) {
141+ c := &domain.Container{Created: tt.created}
142+ got := IsExpiredOrphan(c, ttl)
143+ assert.Equal(t, tt.want, got)
144+ })
145+ }
146+}
147+
148+func TestTagFromImage(t *testing.T) {
149+ tests := []struct {
150+ image string
151+ want string
152+ }{
153+ {"myapp:preview-feat", "preview-feat"},
154+ {"registry.example.com/myapp:preview-feat", "preview-feat"},
155+ {"myapp:latest", "latest"},
156+ {"myapp", ""},
157+ {"myapp@sha256:abc123", ""},
158+ }
159+
160+ for _, tt := range tests {
161+ t.Run(tt.image, func(t *testing.T) {
162+ assert.Equal(t, tt.want, tagFromImage(tt.image))
163+ })
164+ }
165+}

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