v2.31.0-dev13 0d280f4

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// CollectOrphans lists Docker containers and returns those that look like
229// expired preview orphans (not tracked, matching tag pattern + separator,
230// past TTL based on container creation time).
231func (s *Service) CollectOrphans(ctx context.Context, lister out.ContainerLister, tagPatterns []string, separator string) []*domain.Container {
232	containers, err := lister.ListContainers(ctx, true)
233	if err != nil {
234		log := zerowrap.FromCtx(ctx)
235		log.Warn().Err(err).Msg("orphan GC: failed to list containers")
236		return nil
237	}
238
239	s.mu.RLock()
240	tracked := make([]domain.PreviewRoute, len(s.previews))
241	copy(tracked, s.previews)
242	s.mu.RUnlock()
243
244	var orphans []*domain.Container
245	for _, c := range containers {
246		if IsOrphanPreview(c, tracked, tagPatterns, separator) && IsExpiredOrphan(c, s.defaultTTL) {
247			orphans = append(orphans, c)
248		}
249	}
250	return orphans
251}
252
253// StartTicker starts a background goroutine that checks for expired previews
254// and orphaned preview containers. The teardownFn is called for each expired
255// tracked preview. The orphanGCFn, if non-nil, is called on each tick to
256// clean up orphaned containers.
257func (s *Service) StartTicker(ctx context.Context, interval time.Duration, teardownFn func(context.Context, domain.PreviewRoute), orphanGCFn func(context.Context)) {
258	go func() {
259		ticker := time.NewTicker(interval)
260		defer ticker.Stop()
261		for {
262			select {
263			case <-ctx.Done():
264				return
265			case <-ticker.C:
266				expired := s.CleanupExpired(ctx)
267				for _, p := range expired {
268					teardownFn(ctx, p)
269				}
270				if orphanGCFn != nil {
271					orphanGCFn(ctx)
272				}
273			}
274		}
275	}()
276}
277
278func (s *Service) AcquireNameLock(name string) *sync.Mutex {
279	s.nameLockMu.Lock()
280	defer s.nameLockMu.Unlock()
281	entry, ok := s.nameLocks[name]
282	if !ok {
283		entry = &nameLockEntry{mu: &sync.Mutex{}}
284		s.nameLocks[name] = entry
285	}
286	entry.refs++
287	return entry.mu
288}
289
290// ReleaseNameLock decrements the refcount and removes the per-name mutex when no longer in use.
291func (s *Service) ReleaseNameLock(name string) {
292	s.nameLockMu.Lock()
293	defer s.nameLockMu.Unlock()
294	entry, ok := s.nameLocks[name]
295	if !ok {
296		return
297	}
298	entry.refs--
299	if entry.refs <= 0 {
300		delete(s.nameLocks, name)
301	}
302}
303
304func (s *Service) qualifyImage(image string) string {
305	if s.registryDomain != "" && !strings.Contains(image, "/") {
306		return s.registryDomain + "/" + image
307	}
308	return image
309}
310
311// generateVolumeName mirrors the naming convention in container/service.go.
312// This MUST stay in sync with the container service's generateVolumeName.
313func generateVolumeName(prefix, domainName, volumePath string) string {
314	return fmt.Sprintf("%s-%s-%s",
315		prefix,
316		strings.ReplaceAll(domainName, ".", "-"),
317		strings.ReplaceAll(strings.Trim(volumePath, "/"), "/", "-"))
318}
319
320func (s *Service) cloneBaseRouteVolumes(ctx context.Context, baseRoute, previewDomain string) ([]string, error) {
321	if s.volumeCloner == nil || s.routeManager == nil {
322		return nil, nil
323	}
324
325	_, prefix, _ := s.routeManager.GetVolumeConfig()
326	if prefix == "" {
327		prefix = "gordon"
328	}
329
330	log := zerowrap.FromCtx(ctx)
331	basePrefix := prefix + "-" + strings.ReplaceAll(baseRoute, ".", "-") + "-"
332
333	vols, err := s.volumeCloner.ListVolumes(ctx)
334	if err != nil {
335		return nil, fmt.Errorf("list volumes: %w", err)
336	}
337
338	var sourceVols []string
339	for _, v := range vols {
340		if strings.HasPrefix(v.Name, basePrefix) {
341			sourceVols = append(sourceVols, v.Name)
342		}
343	}
344
345	if len(sourceVols) == 0 {
346		return nil, nil
347	}
348
349	namer := func(sourceVolName string) string {
350		pathSuffix := strings.TrimPrefix(sourceVolName, basePrefix)
351		return generateVolumeName(prefix, previewDomain, pathSuffix)
352	}
353
354	for _, src := range sourceVols {
355		log.Debug().Str("source", src).Str("target", namer(src)).Msg("will clone volume for preview")
356	}
357
358	return CloneVolumes(ctx, s.volumeCloner, namer, sourceVols)
359}
360
361// CreatePreviewRequest contains parameters for creating a preview.
362type CreatePreviewRequest struct {
363	Name          string
364	Domain        string
365	BaseRoute     string
366	Image         string
367	HTTPS         bool
368	PreviewConfig domain.PreviewConfig
369}
370
371// CreatePreview orchestrates the full preview creation: lock, clone volumes, start containers, register.
372func (s *Service) CreatePreview(ctx context.Context, req CreatePreviewRequest) error {
373	lock := s.AcquireNameLock(req.Name)
374	lock.Lock()
375	defer func() {
376		lock.Unlock()
377		s.ReleaseNameLock(req.Name)
378	}()
379
380	log := zerowrap.FromCtx(ctx)
381	now := time.Now()
382
383	preview := domain.PreviewRoute{
384		Domain:    req.Domain,
385		Image:     req.Image,
386		BaseRoute: req.BaseRoute,
387		Name:      req.Name,
388		CreatedAt: now,
389		ExpiresAt: now.Add(req.PreviewConfig.TTL),
390		HTTPS:     req.HTTPS,
391		Status:    domain.PreviewStatusDeploying,
392	}
393
394	if err := s.Add(ctx, preview); err != nil {
395		return fmt.Errorf("register preview %q: %w", req.Name, err)
396	}
397
398	if req.PreviewConfig.DataCopy {
399		cloned, err := s.cloneBaseRouteVolumes(ctx, req.BaseRoute, req.Domain)
400		if err != nil {
401			log.Warn().Err(err).Str("base_route", req.BaseRoute).Msg("failed to clone volumes, deploying with empty volumes")
402		} else if len(cloned) > 0 {
403			preview.Volumes = cloned
404		}
405	}
406
407	if s.routeManager != nil {
408		imageRef := s.qualifyImage(req.Image)
409		if err := s.routeManager.AddRoute(ctx, domain.Route{
410			Domain: req.Domain,
411			Image:  imageRef,
412			HTTPS:  req.HTTPS,
413		}); err != nil {
414			preview.Status = domain.PreviewStatusFailed
415			if updateErr := s.Update(ctx, preview); updateErr != nil {
416				log.Warn().Err(updateErr).Str("preview", req.Name).Msg("failed to update preview status")
417			}
418			return fmt.Errorf("register preview route %q: %w", req.Domain, err)
419		}
420	}
421
422	var baseEnv []string
423	if s.envLoader != nil && req.PreviewConfig.EnvCopy {
424		var envErr error
425		baseEnv, envErr = s.envLoader.LoadEnv(ctx, req.BaseRoute)
426		if envErr != nil {
427			log.Warn().Err(envErr).Str("base_route", req.BaseRoute).Msg("failed to load base route env, deploying without env vars")
428		}
429	}
430
431	if s.deployer != nil {
432		imageRef := s.qualifyImage(req.Image)
433		deployCtx := domain.WithInternalDeploy(ctx)
434		container, err := s.deployer.Deploy(deployCtx, domain.Route{
435			Domain: req.Domain,
436			Image:  imageRef,
437			HTTPS:  req.HTTPS,
438			Env:    baseEnv,
439		})
440		if err != nil {
441			preview.Status = domain.PreviewStatusFailed
442			if updateErr := s.Update(ctx, preview); updateErr != nil {
443				log.Warn().Err(updateErr).Str("preview", req.Name).Msg("failed to update preview status")
444			}
445			return fmt.Errorf("deploy preview %q: %w", req.Name, err)
446		}
447		preview.Containers = []string{container.Name}
448	}
449
450	preview.Status = domain.PreviewStatusRunning
451	if err := s.Update(ctx, preview); err != nil {
452		return fmt.Errorf("finalize preview %q: %w", req.Name, err)
453	}
454
455	log.Info().
456		Str("name", req.Name).
457		Str("domain", req.Domain).
458		Str("image", req.Image).
459		Str("base_route", req.BaseRoute).
460		Msg("preview environment created")
461
462	return nil
463}

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