v2.31.1 f37fe67

handler.go RawHistoryFolder

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}

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