v2.30.0-pre 7341e0e

service.go RawHistoryFolder

1package preview
2
3import (
4	"context"
5	"fmt"
6	"strings"
7	"sync"
8	"time"
9
10	"github.com/bnema/zerowrap"
11
12	"github.com/bnema/gordon/internal/boundaries/out"
13	"github.com/bnema/gordon/internal/domain"
14)
15
16type nameLockEntry struct {
17	mu   *sync.Mutex
18	refs int
19}
20
21// Deployer abstracts the container service deploy method.
22type Deployer interface {
23	Deploy(ctx context.Context, route domain.Route) (*domain.Container, error)
24}
25
26// RouteManager abstracts config service methods needed for preview route lifecycle.
27type RouteManager interface {
28	AddRoute(ctx context.Context, route domain.Route) error
29	RemoveRoute(ctx context.Context, domain string) error
30	GetRoute(ctx context.Context, domain string) (*domain.Route, error)
31	GetVolumeConfig() (autoCreate bool, prefix string, preserve bool)
32}
33
34// Service manages preview lifecycle: CRUD, TTL, persistence.
35type Service struct {
36	store          out.PreviewStore
37	defaultTTL     time.Duration
38	deployer       Deployer
39	routeManager   RouteManager
40	volumeCloner   VolumeCloner
41	envLoader      out.EnvLoader
42	registryDomain string
43	previews       []domain.PreviewRoute
44	mu             sync.RWMutex
45	nameLocks      map[string]*nameLockEntry
46	nameLockMu     sync.Mutex
47}
48
49func NewService(store out.PreviewStore, defaultTTL time.Duration) *Service {
50	return &Service{
51		store:      store,
52		defaultTTL: defaultTTL,
53		nameLocks:  make(map[string]*nameLockEntry),
54	}
55}
56
57func (s *Service) WithDeployer(d Deployer) *Service {
58	s.deployer = d
59	return s
60}
61
62func (s *Service) WithRouteManager(rm RouteManager) *Service {
63	s.routeManager = rm
64	return s
65}
66
67func (s *Service) WithVolumeCloner(vc VolumeCloner) *Service {
68	s.volumeCloner = vc
69	return s
70}
71
72func (s *Service) WithRegistryDomain(d string) *Service {
73	s.registryDomain = d
74	return s
75}
76
77func (s *Service) WithEnvLoader(el out.EnvLoader) *Service {
78	s.envLoader = el
79	return s
80}
81
82func (s *Service) Load(ctx context.Context) error {
83	previews, err := s.store.Load(ctx)
84	if err != nil {
85		return err
86	}
87	s.mu.Lock()
88	s.previews = previews
89	s.mu.Unlock()
90	return nil
91}
92
93func (s *Service) Add(ctx context.Context, p domain.PreviewRoute) error {
94	s.mu.Lock()
95	s.previews = append(s.previews, p)
96	cp := make([]domain.PreviewRoute, len(s.previews))
97	copy(cp, s.previews)
98	s.mu.Unlock()
99	return s.store.Save(ctx, cp)
100}
101
102func (s *Service) Update(ctx context.Context, p domain.PreviewRoute) error {
103	s.mu.Lock()
104	for i := range s.previews {
105		if s.previews[i].Name == p.Name {
106			s.previews[i] = p
107			cp := make([]domain.PreviewRoute, len(s.previews))
108			copy(cp, s.previews)
109			s.mu.Unlock()
110			return s.store.Save(ctx, cp)
111		}
112	}
113	s.mu.Unlock()
114	return fmt.Errorf("preview %q: %w", p.Name, domain.ErrPreviewNotFound)
115}
116
117func (s *Service) Delete(ctx context.Context, name string) error {
118	s.mu.Lock()
119	var deleted domain.PreviewRoute
120	found := false
121	filtered := make([]domain.PreviewRoute, 0, len(s.previews))
122	for _, p := range s.previews {
123		if p.Name == name {
124			found = true
125			deleted = p
126		} else {
127			filtered = append(filtered, p)
128		}
129	}
130	if !found {
131		s.mu.Unlock()
132		return fmt.Errorf("preview %q: %w", name, domain.ErrPreviewNotFound)
133	}
134	s.previews = filtered
135	cp := make([]domain.PreviewRoute, len(s.previews))
136	copy(cp, s.previews)
137	s.mu.Unlock()
138
139	if err := s.store.Save(ctx, cp); err != nil {
140		return err
141	}
142
143	// Clean up the route from config so proxy stops routing to this domain.
144	if s.routeManager != nil && deleted.Domain != "" {
145		log := zerowrap.FromCtx(ctx)
146		if err := s.routeManager.RemoveRoute(ctx, deleted.Domain); err != nil {
147			log.Debug().Err(err).Str("domain", deleted.Domain).Msg("preview route already removed from config")
148		}
149	}
150	return nil
151}
152
153func (s *Service) Get(_ context.Context, name string) (*domain.PreviewRoute, error) {
154	s.mu.RLock()
155	defer s.mu.RUnlock()
156	for i := range s.previews {
157		if s.previews[i].Name == name {
158			p := s.previews[i]
159			return &p, nil
160		}
161	}
162	return nil, fmt.Errorf("preview %q: %w", name, domain.ErrPreviewNotFound)
163}
164
165func (s *Service) List(_ context.Context) ([]domain.PreviewRoute, error) {
166	s.mu.RLock()
167	defer s.mu.RUnlock()
168	cp := make([]domain.PreviewRoute, len(s.previews))
169	copy(cp, s.previews)
170	return cp, nil
171}
172
173func (s *Service) Extend(ctx context.Context, name string, ttl time.Duration) error {
174	s.mu.Lock()
175	for i := range s.previews {
176		if s.previews[i].Name == name {
177			s.previews[i].ExpiresAt = time.Now().Add(ttl)
178			cp := make([]domain.PreviewRoute, len(s.previews))
179			copy(cp, s.previews)
180			s.mu.Unlock()
181			return s.store.Save(ctx, cp)
182		}
183	}
184	s.mu.Unlock()
185	return fmt.Errorf("preview %q: %w", name, domain.ErrPreviewNotFound)
186}
187
188func (s *Service) GetExpired() []domain.PreviewRoute {
189	s.mu.RLock()
190	defer s.mu.RUnlock()
191	var expired []domain.PreviewRoute
192	for _, p := range s.previews {
193		if p.IsExpired(time.Now()) {
194			expired = append(expired, p)
195		}
196	}
197	return expired
198}
199
200// CleanupExpired removes expired previews from the service and persists.
201// Returns the removed previews so the caller can teardown their resources.
202func (s *Service) CleanupExpired(ctx context.Context) []domain.PreviewRoute {
203	s.mu.Lock()
204	var expired []domain.PreviewRoute
205	var active []domain.PreviewRoute
206	for _, p := range s.previews {
207		if p.IsExpired(time.Now()) {
208			expired = append(expired, p)
209		} else {
210			active = append(active, p)
211		}
212	}
213	s.previews = active
214	cp := make([]domain.PreviewRoute, len(active))
215	copy(cp, active)
216	s.mu.Unlock()
217
218	if len(expired) > 0 {
219		if err := s.store.Save(ctx, cp); err != nil {
220			// Log but don't fail — expired previews are already removed from memory
221			log := zerowrap.FromCtx(ctx)
222			log.Warn().Err(err).Msg("failed to persist preview state after cleanup")
223		}
224	}
225	return expired
226}
227
228// StartTicker starts a background goroutine that checks for expired previews.
229// The teardownFn is called for each expired preview to clean up containers/volumes.
230func (s *Service) StartTicker(ctx context.Context, interval time.Duration, teardownFn func(context.Context, domain.PreviewRoute)) {
231	go func() {
232		ticker := time.NewTicker(interval)
233		defer ticker.Stop()
234		for {
235			select {
236			case <-ctx.Done():
237				return
238			case <-ticker.C:
239				expired := s.CleanupExpired(ctx)
240				for _, p := range expired {
241					teardownFn(ctx, p)
242				}
243			}
244		}
245	}()
246}
247
248func (s *Service) AcquireNameLock(name string) *sync.Mutex {
249	s.nameLockMu.Lock()
250	defer s.nameLockMu.Unlock()
251	entry, ok := s.nameLocks[name]
252	if !ok {
253		entry = &nameLockEntry{mu: &sync.Mutex{}}
254		s.nameLocks[name] = entry
255	}
256	entry.refs++
257	return entry.mu
258}
259
260// ReleaseNameLock decrements the refcount and removes the per-name mutex when no longer in use.
261func (s *Service) ReleaseNameLock(name string) {
262	s.nameLockMu.Lock()
263	defer s.nameLockMu.Unlock()
264	entry, ok := s.nameLocks[name]
265	if !ok {
266		return
267	}
268	entry.refs--
269	if entry.refs <= 0 {
270		delete(s.nameLocks, name)
271	}
272}
273
274func (s *Service) qualifyImage(image string) string {
275	if s.registryDomain != "" && !strings.Contains(image, "/") {
276		return s.registryDomain + "/" + image
277	}
278	return image
279}
280
281// generateVolumeName mirrors the naming convention in container/service.go.
282// This MUST stay in sync with the container service's generateVolumeName.
283func generateVolumeName(prefix, domainName, volumePath string) string {
284	return fmt.Sprintf("%s-%s-%s",
285		prefix,
286		strings.ReplaceAll(domainName, ".", "-"),
287		strings.ReplaceAll(strings.Trim(volumePath, "/"), "/", "-"))
288}
289
290func (s *Service) cloneBaseRouteVolumes(ctx context.Context, baseRoute, previewDomain string) ([]string, error) {
291	if s.volumeCloner == nil || s.routeManager == nil {
292		return nil, nil
293	}
294
295	_, prefix, _ := s.routeManager.GetVolumeConfig()
296	if prefix == "" {
297		prefix = "gordon"
298	}
299
300	log := zerowrap.FromCtx(ctx)
301	basePrefix := prefix + "-" + strings.ReplaceAll(baseRoute, ".", "-") + "-"
302
303	vols, err := s.volumeCloner.ListVolumes(ctx)
304	if err != nil {
305		return nil, fmt.Errorf("list volumes: %w", err)
306	}
307
308	var sourceVols []string
309	for _, v := range vols {
310		if strings.HasPrefix(v.Name, basePrefix) {
311			sourceVols = append(sourceVols, v.Name)
312		}
313	}
314
315	if len(sourceVols) == 0 {
316		return nil, nil
317	}
318
319	namer := func(sourceVolName string) string {
320		pathSuffix := strings.TrimPrefix(sourceVolName, basePrefix)
321		return generateVolumeName(prefix, previewDomain, pathSuffix)
322	}
323
324	for _, src := range sourceVols {
325		log.Debug().Str("source", src).Str("target", namer(src)).Msg("will clone volume for preview")
326	}
327
328	return CloneVolumes(ctx, s.volumeCloner, namer, sourceVols)
329}
330
331// CreatePreviewRequest contains parameters for creating a preview.
332type CreatePreviewRequest struct {
333	Name          string
334	Domain        string
335	BaseRoute     string
336	Image         string
337	HTTPS         bool
338	PreviewConfig domain.PreviewConfig
339}
340
341// CreatePreview orchestrates the full preview creation: lock, clone volumes, start containers, register.
342func (s *Service) CreatePreview(ctx context.Context, req CreatePreviewRequest) error {
343	lock := s.AcquireNameLock(req.Name)
344	lock.Lock()
345	defer func() {
346		lock.Unlock()
347		s.ReleaseNameLock(req.Name)
348	}()
349
350	log := zerowrap.FromCtx(ctx)
351	now := time.Now()
352
353	preview := domain.PreviewRoute{
354		Domain:    req.Domain,
355		Image:     req.Image,
356		BaseRoute: req.BaseRoute,
357		Name:      req.Name,
358		CreatedAt: now,
359		ExpiresAt: now.Add(req.PreviewConfig.TTL),
360		HTTPS:     req.HTTPS,
361		Status:    domain.PreviewStatusDeploying,
362	}
363
364	if err := s.Add(ctx, preview); err != nil {
365		return fmt.Errorf("register preview %q: %w", req.Name, err)
366	}
367
368	if req.PreviewConfig.DataCopy {
369		cloned, err := s.cloneBaseRouteVolumes(ctx, req.BaseRoute, req.Domain)
370		if err != nil {
371			log.Warn().Err(err).Str("base_route", req.BaseRoute).Msg("failed to clone volumes, deploying with empty volumes")
372		} else if len(cloned) > 0 {
373			preview.Volumes = cloned
374		}
375	}
376
377	if s.routeManager != nil {
378		imageRef := s.qualifyImage(req.Image)
379		if err := s.routeManager.AddRoute(ctx, domain.Route{
380			Domain: req.Domain,
381			Image:  imageRef,
382			HTTPS:  req.HTTPS,
383		}); err != nil {
384			preview.Status = domain.PreviewStatusFailed
385			if updateErr := s.Update(ctx, preview); updateErr != nil {
386				log.Warn().Err(updateErr).Str("preview", req.Name).Msg("failed to update preview status")
387			}
388			return fmt.Errorf("register preview route %q: %w", req.Domain, err)
389		}
390	}
391
392	var baseEnv []string
393	if s.envLoader != nil && req.PreviewConfig.EnvCopy {
394		var envErr error
395		baseEnv, envErr = s.envLoader.LoadEnv(ctx, req.BaseRoute)
396		if envErr != nil {
397			log.Warn().Err(envErr).Str("base_route", req.BaseRoute).Msg("failed to load base route env, deploying without env vars")
398		}
399	}
400
401	if s.deployer != nil {
402		imageRef := s.qualifyImage(req.Image)
403		deployCtx := domain.WithInternalDeploy(ctx)
404		container, err := s.deployer.Deploy(deployCtx, domain.Route{
405			Domain: req.Domain,
406			Image:  imageRef,
407			HTTPS:  req.HTTPS,
408			Env:    baseEnv,
409		})
410		if err != nil {
411			preview.Status = domain.PreviewStatusFailed
412			if updateErr := s.Update(ctx, preview); updateErr != nil {
413				log.Warn().Err(updateErr).Str("preview", req.Name).Msg("failed to update preview status")
414			}
415			return fmt.Errorf("deploy preview %q: %w", req.Name, err)
416		}
417		preview.Containers = []string{container.Name}
418	}
419
420	preview.Status = domain.PreviewStatusRunning
421	if err := s.Update(ctx, preview); err != nil {
422		return fmt.Errorf("finalize preview %q: %w", req.Name, err)
423	}
424
425	log.Info().
426		Str("name", req.Name).
427		Str("domain", req.Domain).
428		Str("image", req.Image).
429		Str("base_route", req.BaseRoute).
430		Msg("preview environment created")
431
432	return nil
433}

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