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 { | |||
| 164 | 164 | } | |
| 165 | 165 | 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 | + | } | |
| @@ -164,3 +164,36 @@ func (s *Service) AcquireNameLock(name string) *sync.Mutex { | |||||
| 164 | } | 164 | } | ||
| 165 | return mu | 165 | 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 | + | } | |||