1package preview
2
3import (
4 "context"
5 "fmt"
6 "strings"
7
8 "github.com/bnema/zerowrap"
9
10 "github.com/bnema/gordon/internal/domain"
11 "github.com/bnema/gordon/internal/usecase/auto"
12)
13
14// AutoPreviewHandler processes EventImagePushed for preview tag patterns.
15type AutoPreviewHandler struct {
16 serviceCtx context.Context
17 config auto.AutoConfigProvider
18 previewService *Service
19 jobs chan struct{}
20}
21
22func NewAutoPreviewHandler(
23 serviceCtx context.Context,
24 config auto.AutoConfigProvider,
25 previewService *Service,
26) *AutoPreviewHandler {
27 return &AutoPreviewHandler{
28 serviceCtx: serviceCtx,
29 config: config,
30 previewService: previewService,
31 jobs: make(chan struct{}, 16),
32 }
33}
34
35func (h *AutoPreviewHandler) CanHandle(t domain.EventType) bool {
36 return t == domain.EventImagePushed
37}
38
39func (h *AutoPreviewHandler) Handle(ctx context.Context, event domain.Event) error {
40 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
41 zerowrap.FieldLayer: "usecase",
42 zerowrap.FieldHandler: "AutoPreviewHandler",
43 zerowrap.FieldEvent: string(event.Type),
44 "event_id": event.ID,
45 })
46 log := zerowrap.FromCtx(ctx)
47
48 payload, ok := event.Data.(domain.ImagePushedPayload)
49 if !ok {
50 log.Debug().Str("actual_type", fmt.Sprintf("%T", event.Data)).Msg("unexpected event data type, skipping")
51 return nil
52 }
53
54 previewConfig := h.config.GetPreviewConfig()
55 previewName := ExtractPreviewName(payload.Reference, previewConfig.TagPatterns)
56
57 routes := h.config.FindRoutesByImage(ctx, payload.Name)
58 baseRoutes := resolveBaseRoutes(routes, previewConfig)
59 if len(baseRoutes) == 0 {
60 log.Debug().Str("image", payload.Name).Msg("no trusted route found for preview base, skipping")
61 return nil
62 }
63
64 allowedDomains := h.config.GetAllowedDomains()
65
66 imageName := payload.Name + ":" + payload.Reference
67 for _, baseRoute := range baseRoutes {
68 previewDomain, err := GeneratePreviewDomain(baseRoute.Domain, previewName, previewConfig.Separator)
69 if err != nil {
70 log.Debug().Err(err).Str("base_domain", baseRoute.Domain).Msg("failed to generate preview domain")
71 continue
72 }
73 if !auto.MatchesDomainAllowlist(previewDomain, allowedDomains) {
74 log.Debug().Str("domain", previewDomain).Strs("patterns", allowedDomains).Msg("preview domain not in allowlist, skipping")
75 continue
76 }
77
78 // Launch async — volume cloning may exceed event bus timeout. The bounded
79 // semaphore prevents a push burst from creating an unbounded goroutine backlog.
80 select {
81 case h.jobs <- struct{}{}:
82 case <-ctx.Done():
83 return fmt.Errorf("schedule preview %s for %s: %w", previewName, baseRoute.Domain, ctx.Err())
84 case <-h.serviceCtx.Done():
85 return h.serviceCtx.Err()
86 }
87 go func(baseRoute baseRouteInfo, previewDomain string) {
88 defer func() { <-h.jobs }()
89 if err := h.previewService.CreatePreview(h.serviceCtx, CreatePreviewRequest{
90 Name: previewName,
91 Domain: previewDomain,
92 BaseRoute: baseRoute.Domain,
93 Image: imageName,
94 HTTPS: baseRoute.HTTPS,
95 PreviewConfig: previewConfig,
96 }); err != nil {
97 log.Error().Err(err).Str("preview", previewName).Str("base_route", baseRoute.Domain).Msg("failed to create preview")
98 }
99 }(baseRoute, previewDomain)
100 }
101
102 return nil
103}
104
105// resolveBaseRoutes determines the eligible base routes for preview env/data inheritance.
106type baseRouteInfo struct {
107 Domain string
108 HTTPS bool
109}
110
111func resolveBaseRoutes(routes []domain.Route, previewConfig domain.PreviewConfig) []baseRouteInfo {
112 domainSet := make(map[string]struct{}, len(routes))
113 for _, r := range routes {
114 domainSet[r.Domain] = struct{}{}
115 }
116
117 baseRoutes := make([]baseRouteInfo, 0, len(routes))
118 for _, r := range routes {
119 if !isPreviewDomain(r.Domain, previewConfig.Separator, domainSet) {
120 baseRoutes = append(baseRoutes, baseRouteInfo{Domain: r.Domain, HTTPS: r.HTTPS})
121 }
122 }
123 return baseRoutes
124}
125
126// isPreviewDomain checks if a domain is a preview domain by examining the first
127// DNS label for the separator AND verifying that the implied base domain exists
128// in the route set. This avoids false positives like "my--app.example.com" when
129// no route "my.example.com" exists — that domain just happens to contain the
130// separator in its name.
131// If separator is empty, no domain is treated as a preview domain.
132func isPreviewDomain(d, separator string, routeSet map[string]struct{}) bool {
133 if separator == "" {
134 return false
135 }
136 // Extract the first label (subdomain) before the first dot
137 firstLabel := d
138 rest := ""
139 if idx := strings.Index(d, "."); idx != -1 {
140 firstLabel = d[:idx]
141 rest = d[idx:] // includes leading dot
142 }
143 // Check if the first label contains the separator
144 sepIdx := strings.Index(firstLabel, separator)
145 if sepIdx < 0 {
146 return false
147 }
148 // Reconstruct the potential base domain: everything before the separator + rest
149 baseLabel := firstLabel[:sepIdx]
150 if baseLabel == "" {
151 return false
152 }
153 baseDomain := baseLabel + rest
154 // Only treat as preview if the base domain actually exists in the route set
155 _, exists := routeSet[baseDomain]
156 return exists
157}