detached 2a743d6

feat(preview): AutoPreviewHandler with async preview creation

Adds AutoPreviewHandler that processes EventImagePushed events matching preview
tag patterns, generates a preview domain, validates against the allowlist, and
launches CreatePreview asynchronously. Also adds CreatePreviewRequest and
CreatePreview to the Service, with a TODO stub for volume/container wiring in Task 13.
commit
2a743d6b31f643a9683df373069fd125ea0c1db4
author
brice · · 2026-03-19 05:01 UTC
committer
brice · · 2026-03-19 05:13 UTC
parent
4bf8eb7
signature
unsigned
3 files · +159 -0
A internal/usecase/auto/preview/handler.go +94 -0
@@ -0,0 +1,94 @@
1+package preview
2+
3+import (
4+ "context"
5+
6+ "github.com/bnema/gordon/internal/boundaries/out"
7+ "github.com/bnema/gordon/internal/domain"
8+ "github.com/bnema/gordon/internal/usecase/auto"
9+)
10+
11+// AutoPreviewHandler processes EventImagePushed for preview tag patterns.
12+type AutoPreviewHandler struct {
13+ serviceCtx context.Context
14+ config auto.AutoConfigProvider
15+ blobStorage out.BlobStorage
16+ previewService *Service
17+ registryDomain string
18+}
19+
20+func NewAutoPreviewHandler(
21+ serviceCtx context.Context,
22+ config auto.AutoConfigProvider,
23+ blobStorage out.BlobStorage,
24+ previewService *Service,
25+ registryDomain string,
26+) *AutoPreviewHandler {
27+ return &AutoPreviewHandler{
28+ serviceCtx: serviceCtx,
29+ config: config,
30+ blobStorage: blobStorage,
31+ previewService: previewService,
32+ registryDomain: registryDomain,
33+ }
34+}
35+
36+func (h *AutoPreviewHandler) CanHandle(t domain.EventType) bool {
37+ return t == domain.EventImagePushed
38+}
39+
40+func (h *AutoPreviewHandler) Handle(ctx context.Context, event domain.Event) error {
41+ payload, ok := event.Data.(domain.ImagePushedPayload)
42+ if !ok {
43+ return nil
44+ }
45+
46+ // Extract labels from manifest
47+ labels, err := auto.ExtractLabels(ctx, payload.Manifest, h.blobStorage)
48+ if err != nil {
49+ return nil
50+ }
51+
52+ previewConfig := h.config.GetPreviewConfig()
53+ previewName := ExtractPreviewName(payload.Reference, previewConfig.TagPatterns)
54+
55+ // Resolve base route from labels
56+ domains := collectDomains(labels)
57+ if len(domains) == 0 {
58+ return nil
59+ }
60+ baseRouteDomain := domains[0]
61+
62+ // Validate allowlist
63+ allowedDomains := h.config.GetAllowedDomains()
64+ previewDomain, err := GeneratePreviewDomain(baseRouteDomain, previewName, previewConfig.Separator)
65+ if err != nil {
66+ return nil
67+ }
68+ if !auto.MatchesDomainAllowlist(previewDomain, allowedDomains) {
69+ return nil
70+ }
71+
72+ imageName := payload.Name + ":" + payload.Reference
73+
74+ // Launch async — volume cloning may exceed event bus timeout
75+ go h.previewService.CreatePreview(h.serviceCtx, CreatePreviewRequest{
76+ Name: previewName,
77+ Domain: previewDomain,
78+ BaseRoute: baseRouteDomain,
79+ Image: imageName,
80+ HTTPS: true,
81+ PreviewConfig: previewConfig,
82+ })
83+
84+ return nil
85+}
86+
87+func collectDomains(labels *domain.ImageLabels) []string {
88+ var domains []string
89+ if labels.Domain != "" {
90+ domains = append(domains, labels.Domain)
91+ }
92+ domains = append(domains, labels.Domains...)
93+ return domains
94+}
@@ -0,0 +1,94 @@
1+package preview
2+
3+import (
4+ "context"
5+
6+ "github.com/bnema/gordon/internal/boundaries/out"
7+ "github.com/bnema/gordon/internal/domain"
8+ "github.com/bnema/gordon/internal/usecase/auto"
9+)
10+
11+// AutoPreviewHandler processes EventImagePushed for preview tag patterns.
12+type AutoPreviewHandler struct {
13+ serviceCtx context.Context
14+ config auto.AutoConfigProvider
15+ blobStorage out.BlobStorage
16+ previewService *Service
17+ registryDomain string
18+}
19+
20+func NewAutoPreviewHandler(
21+ serviceCtx context.Context,
22+ config auto.AutoConfigProvider,
23+ blobStorage out.BlobStorage,
24+ previewService *Service,
25+ registryDomain string,
26+) *AutoPreviewHandler {
27+ return &AutoPreviewHandler{
28+ serviceCtx: serviceCtx,
29+ config: config,
30+ blobStorage: blobStorage,
31+ previewService: previewService,
32+ registryDomain: registryDomain,
33+ }
34+}
35+
36+func (h *AutoPreviewHandler) CanHandle(t domain.EventType) bool {
37+ return t == domain.EventImagePushed
38+}
39+
40+func (h *AutoPreviewHandler) Handle(ctx context.Context, event domain.Event) error {
41+ payload, ok := event.Data.(domain.ImagePushedPayload)
42+ if !ok {
43+ return nil
44+ }
45+
46+ // Extract labels from manifest
47+ labels, err := auto.ExtractLabels(ctx, payload.Manifest, h.blobStorage)
48+ if err != nil {
49+ return nil
50+ }
51+
52+ previewConfig := h.config.GetPreviewConfig()
53+ previewName := ExtractPreviewName(payload.Reference, previewConfig.TagPatterns)
54+
55+ // Resolve base route from labels
56+ domains := collectDomains(labels)
57+ if len(domains) == 0 {
58+ return nil
59+ }
60+ baseRouteDomain := domains[0]
61+
62+ // Validate allowlist
63+ allowedDomains := h.config.GetAllowedDomains()
64+ previewDomain, err := GeneratePreviewDomain(baseRouteDomain, previewName, previewConfig.Separator)
65+ if err != nil {
66+ return nil
67+ }
68+ if !auto.MatchesDomainAllowlist(previewDomain, allowedDomains) {
69+ return nil
70+ }
71+
72+ imageName := payload.Name + ":" + payload.Reference
73+
74+ // Launch async — volume cloning may exceed event bus timeout
75+ go h.previewService.CreatePreview(h.serviceCtx, CreatePreviewRequest{
76+ Name: previewName,
77+ Domain: previewDomain,
78+ BaseRoute: baseRouteDomain,
79+ Image: imageName,
80+ HTTPS: true,
81+ PreviewConfig: previewConfig,
82+ })
83+
84+ return nil
85+}
86+
87+func collectDomains(labels *domain.ImageLabels) []string {
88+ var domains []string
89+ if labels.Domain != "" {
90+ domains = append(domains, labels.Domain)
91+ }
92+ domains = append(domains, labels.Domains...)
93+ return domains
94+}
A internal/usecase/auto/preview/handler_test.go +32 -0
@@ -0,0 +1,32 @@
1+package preview
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/gordon/internal/domain"
7+ "github.com/stretchr/testify/assert"
8+)
9+
10+func TestAutoPreviewHandler_CanHandle(t *testing.T) {
11+ h := &AutoPreviewHandler{}
12+ assert.True(t, h.CanHandle(domain.EventImagePushed))
13+ assert.False(t, h.CanHandle(domain.EventConfigReload))
14+}
15+
16+func TestCollectDomains(t *testing.T) {
17+ tests := []struct {
18+ name string
19+ labels *domain.ImageLabels
20+ want []string
21+ }{
22+ {"single domain", &domain.ImageLabels{Domain: "app.example.com"}, []string{"app.example.com"}},
23+ {"multiple domains", &domain.ImageLabels{Domains: []string{"a.com", "b.com"}}, []string{"a.com", "b.com"}},
24+ {"both", &domain.ImageLabels{Domain: "main.com", Domains: []string{"alt.com"}}, []string{"main.com", "alt.com"}},
25+ {"empty", &domain.ImageLabels{}, nil},
26+ }
27+ for _, tt := range tests {
28+ t.Run(tt.name, func(t *testing.T) {
29+ assert.Equal(t, tt.want, collectDomains(tt.labels))
30+ })
31+ }
32+}
@@ -0,0 +1,32 @@
1+package preview
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/gordon/internal/domain"
7+ "github.com/stretchr/testify/assert"
8+)
9+
10+func TestAutoPreviewHandler_CanHandle(t *testing.T) {
11+ h := &AutoPreviewHandler{}
12+ assert.True(t, h.CanHandle(domain.EventImagePushed))
13+ assert.False(t, h.CanHandle(domain.EventConfigReload))
14+}
15+
16+func TestCollectDomains(t *testing.T) {
17+ tests := []struct {
18+ name string
19+ labels *domain.ImageLabels
20+ want []string
21+ }{
22+ {"single domain", &domain.ImageLabels{Domain: "app.example.com"}, []string{"app.example.com"}},
23+ {"multiple domains", &domain.ImageLabels{Domains: []string{"a.com", "b.com"}}, []string{"a.com", "b.com"}},
24+ {"both", &domain.ImageLabels{Domain: "main.com", Domains: []string{"alt.com"}}, []string{"main.com", "alt.com"}},
25+ {"empty", &domain.ImageLabels{}, nil},
26+ }
27+ for _, tt := range tests {
28+ t.Run(tt.name, func(t *testing.T) {
29+ assert.Equal(t, tt.want, collectDomains(tt.labels))
30+ })
31+ }
32+}
M internal/usecase/auto/preview/service.go +33 -0
@@ -164,3 +164,36 @@ func (s *Service) AcquireNameLock(name string) *sync.Mutex {
164164 }
165165 return mu
166166 }
167+
168+// CreatePreviewRequest contains parameters for creating a preview.
169+type CreatePreviewRequest struct {
170+ Name string
171+ Domain string
172+ BaseRoute string
173+ Image string
174+ HTTPS bool
175+ PreviewConfig domain.PreviewConfig
176+}
177+
178+// CreatePreview orchestrates the full preview creation: lock, clone volumes, start containers, register.
179+func (s *Service) CreatePreview(ctx context.Context, req CreatePreviewRequest) {
180+ lock := s.AcquireNameLock(req.Name)
181+ lock.Lock()
182+ defer lock.Unlock()
183+
184+ now := time.Now()
185+ preview := domain.PreviewRoute{
186+ Domain: req.Domain,
187+ Image: req.Image,
188+ BaseRoute: req.BaseRoute,
189+ Name: req.Name,
190+ CreatedAt: now,
191+ ExpiresAt: now.Add(req.PreviewConfig.TTL),
192+ HTTPS: req.HTTPS,
193+ }
194+
195+ // TODO: Volume cloning and container startup will be wired in Task 13
196+ if err := s.Add(ctx, preview); err != nil {
197+ return
198+ }
199+}
@@ -164,3 +164,36 @@ func (s *Service) AcquireNameLock(name string) *sync.Mutex {
164 }164 }
165 return mu165 return mu
166 }166 }
167+
168+// CreatePreviewRequest contains parameters for creating a preview.
169+type CreatePreviewRequest struct {
170+ Name string
171+ Domain string
172+ BaseRoute string
173+ Image string
174+ HTTPS bool
175+ PreviewConfig domain.PreviewConfig
176+}
177+
178+// CreatePreview orchestrates the full preview creation: lock, clone volumes, start containers, register.
179+func (s *Service) CreatePreview(ctx context.Context, req CreatePreviewRequest) {
180+ lock := s.AcquireNameLock(req.Name)
181+ lock.Lock()
182+ defer lock.Unlock()
183+
184+ now := time.Now()
185+ preview := domain.PreviewRoute{
186+ Domain: req.Domain,
187+ Image: req.Image,
188+ BaseRoute: req.BaseRoute,
189+ Name: req.Name,
190+ CreatedAt: now,
191+ ExpiresAt: now.Add(req.PreviewConfig.TTL),
192+ HTTPS: req.HTTPS,
193+ }
194+
195+ // TODO: Volume cloning and container startup will be wired in Task 13
196+ if err := s.Add(ctx, preview); err != nil {
197+ return
198+ }
199+}

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