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