v2.31.1 f37fe67

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	replaced := false
96	for i := range s.previews {
97		if s.previews[i].Domain == p.Domain {
98			s.previews[i] = p
99			replaced = true
100			break
101		}
102	}
103	if !replaced {
104		s.previews = append(s.previews, p)
105	}
106	cp := make([]domain.PreviewRoute, len(s.previews))
107	copy(cp, s.previews)
108	s.mu.Unlock()
109	return s.store.Save(ctx, cp)
110}
111
112func (s *Service) Update(ctx context.Context, p domain.PreviewRoute) error {
113	s.mu.Lock()
114	for i := range s.previews {
115		if s.previews[i].Domain == p.Domain {
116			s.previews[i] = p
117			cp := make([]domain.PreviewRoute, len(s.previews))
118			copy(cp, s.previews)
119			s.mu.Unlock()
120			return s.store.Save(ctx, cp)
121		}
122	}
123	s.mu.Unlock()
124	return fmt.Errorf("preview %q: %w", p.Name, domain.ErrPreviewNotFound)
125}
126
127func (s *Service) Delete(ctx context.Context, name string) error {
128	s.mu.Lock()
129	var deleted domain.PreviewRoute
130	found := false
131	filtered := make([]domain.PreviewRoute, 0, len(s.previews))
132	for _, p := range s.previews {
133		if p.Name == name {
134			found = true
135			deleted = p
136		} else {
137			filtered = append(filtered, p)
138		}
139	}
140	if !found {
141		s.mu.Unlock()
142		return fmt.Errorf("preview %q: %w", name, domain.ErrPreviewNotFound)
143	}
144	s.previews = filtered
145	cp := make([]domain.PreviewRoute, len(s.previews))
146	copy(cp, s.previews)
147	s.mu.Unlock()
148
149	if err := s.store.Save(ctx, cp); err != nil {
150		return err
151	}
152
153	// Clean up the route from config so proxy stops routing to this domain.
154	if s.routeManager != nil && deleted.Domain != "" {
155		log := zerowrap.FromCtx(ctx)
156		if err := s.routeManager.RemoveRoute(ctx, deleted.Domain); err != nil {
157			log.Debug().Err(err).Str("domain", deleted.Domain).Msg("preview route already removed from config")
158		}
159	}
160	return nil
161}
162
163func (s *Service) Get(_ context.Context, name string) (*domain.PreviewRoute, error) {
164	s.mu.RLock()
165	defer s.mu.RUnlock()
166	for i := range s.previews {
167		if s.previews[i].Name == name {
168			p := s.previews[i]
169			return &p, nil
170		}
171	}
172	return nil, fmt.Errorf("preview %q: %w", name, domain.ErrPreviewNotFound)
173}
174
175func (s *Service) List(_ context.Context) ([]domain.PreviewRoute, error) {
176	s.mu.RLock()
177	defer s.mu.RUnlock()
178	cp := make([]domain.PreviewRoute, len(s.previews))
179	copy(cp, s.previews)
180	return cp, nil
181}
182
183func (s *Service) Extend(ctx context.Context, name string, ttl time.Duration) error {
184	s.mu.Lock()
185	for i := range s.previews {
186		if s.previews[i].Name == name {
187			s.previews[i].ExpiresAt = time.Now().Add(ttl)
188			cp := make([]domain.PreviewRoute, len(s.previews))
189			copy(cp, s.previews)
190			s.mu.Unlock()
191			return s.store.Save(ctx, cp)
192		}
193	}
194	s.mu.Unlock()
195	return fmt.Errorf("preview %q: %w", name, domain.ErrPreviewNotFound)
196}
197
198func (s *Service) GetExpired() []domain.PreviewRoute {
199	s.mu.RLock()
200	defer s.mu.RUnlock()
201	var expired []domain.PreviewRoute
202	for _, p := range s.previews {
203		if p.IsExpired(time.Now()) {
204			expired = append(expired, p)
205		}
206	}
207	return expired
208}
209
210// CleanupExpired removes expired previews from the service and persists.
211// Returns the removed previews so the caller can teardown their resources.
212func (s *Service) CleanupExpired(ctx context.Context) []domain.PreviewRoute {
213	s.mu.Lock()
214	var expired []domain.PreviewRoute
215	var active []domain.PreviewRoute
216	for _, p := range s.previews {
217		if p.IsExpired(time.Now()) {
218			expired = append(expired, p)
219		} else {
220			active = append(active, p)
221		}
222	}
223	s.previews = active
224	cp := make([]domain.PreviewRoute, len(active))
225	copy(cp, active)
226	s.mu.Unlock()
227
228	if len(expired) > 0 {
229		if err := s.store.Save(ctx, cp); err != nil {
230			// Log but don't fail — expired previews are already removed from memory
231			log := zerowrap.FromCtx(ctx)
232			log.Warn().Err(err).Msg("failed to persist preview state after cleanup")
233		}
234	}
235	return expired
236}
237
238// CollectOrphans lists Docker containers and returns those that look like
239// expired preview orphans (not tracked, matching tag pattern + separator,
240// past TTL based on container creation time).
241func (s *Service) CollectOrphans(ctx context.Context, lister out.ContainerLister, tagPatterns []string, separator string) []*domain.Container {
242	containers, err := lister.ListContainers(ctx, true)
243	if err != nil {
244		log := zerowrap.FromCtx(ctx)
245		log.Warn().Err(err).Msg("orphan GC: failed to list containers")
246		return nil
247	}
248
249	s.mu.RLock()
250	tracked := make([]domain.PreviewRoute, len(s.previews))
251	copy(tracked, s.previews)
252	s.mu.RUnlock()
253
254	var orphans []*domain.Container
255	for _, c := range containers {
256		if IsOrphanPreview(c, tracked, tagPatterns, separator) && IsExpiredOrphan(c, s.defaultTTL) {
257			orphans = append(orphans, c)
258		}
259	}
260	return orphans
261}
262
263// StartTicker starts a background goroutine that checks for expired previews
264// and orphaned preview containers. The teardownFn is called for each expired
265// tracked preview. The orphanGCFn, if non-nil, is called on each tick to
266// clean up orphaned containers.
267func (s *Service) StartTicker(ctx context.Context, interval time.Duration, teardownFn func(context.Context, domain.PreviewRoute), orphanGCFn func(context.Context)) {
268	go func() {
269		ticker := time.NewTicker(interval)
270		defer ticker.Stop()
271		for {
272			select {
273			case <-ctx.Done():
274				return
275			case <-ticker.C:
276				expired := s.CleanupExpired(ctx)
277				for _, p := range expired {
278					teardownFn(ctx, p)
279				}
280				if orphanGCFn != nil {
281					orphanGCFn(ctx)
282				}
283			}
284		}
285	}()
286}
287
288func (s *Service) AcquireNameLock(name string) *sync.Mutex {
289	s.nameLockMu.Lock()
290	defer s.nameLockMu.Unlock()
291	entry, ok := s.nameLocks[name]
292	if !ok {
293		entry = &nameLockEntry{mu: &sync.Mutex{}}
294		s.nameLocks[name] = entry
295	}
296	entry.refs++
297	return entry.mu
298}
299
300// ReleaseNameLock decrements the refcount and removes the per-name mutex when no longer in use.
301func (s *Service) ReleaseNameLock(name string) {
302	s.nameLockMu.Lock()
303	defer s.nameLockMu.Unlock()
304	entry, ok := s.nameLocks[name]
305	if !ok {
306		return
307	}
308	entry.refs--
309	if entry.refs <= 0 {
310		delete(s.nameLocks, name)
311	}
312}
313
314func (s *Service) qualifyImage(image string) string {
315	if s.registryDomain != "" && !strings.Contains(image, "/") {
316		return s.registryDomain + "/" + image
317	}
318	return image
319}
320
321// generateVolumeName mirrors the naming convention in container/service.go.
322// This MUST stay in sync with the container service's generateVolumeName.
323func generateVolumeName(prefix, domainName, volumePath string) string {
324	return fmt.Sprintf("%s-%s-%s",
325		prefix,
326		domain.StableResourceName(domainName),
327		domain.StableResourceName(strings.Trim(volumePath, "/")))
328}
329
330func (s *Service) cloneBaseRouteVolumes(ctx context.Context, baseRoute, previewDomain string) ([]string, error) {
331	if s.volumeCloner == nil || s.routeManager == nil {
332		return nil, nil
333	}
334
335	_, prefix, _ := s.routeManager.GetVolumeConfig()
336	if prefix == "" {
337		prefix = "gordon"
338	}
339
340	log := zerowrap.FromCtx(ctx)
341	basePrefix := prefix + "-" + domain.StableResourceName(baseRoute) + "-"
342	legacyBasePrefix := prefix + "-" + strings.ReplaceAll(baseRoute, ".", "-") + "-"
343
344	vols, err := s.volumeCloner.ListVolumes(ctx)
345	if err != nil {
346		return nil, fmt.Errorf("list volumes: %w", err)
347	}
348
349	var sourceVols []string
350	for _, v := range vols {
351		if strings.HasPrefix(v.Name, basePrefix) || strings.HasPrefix(v.Name, legacyBasePrefix) {
352			sourceVols = append(sourceVols, v.Name)
353		}
354	}
355
356	if len(sourceVols) == 0 {
357		return nil, nil
358	}
359
360	namer := func(sourceVolName string) string {
361		pathSuffix := strings.TrimPrefix(sourceVolName, basePrefix)
362		if pathSuffix == sourceVolName {
363			pathSuffix = strings.TrimPrefix(sourceVolName, legacyBasePrefix)
364		}
365		return generateVolumeName(prefix, previewDomain, pathSuffix)
366	}
367
368	for _, src := range sourceVols {
369		log.Debug().Str("source", src).Str("target", namer(src)).Msg("will clone volume for preview")
370	}
371
372	return CloneVolumes(ctx, s.volumeCloner, namer, sourceVols)
373}
374
375// CreatePreviewRequest contains parameters for creating a preview.
376type CreatePreviewRequest struct {
377	Name          string
378	Domain        string
379	BaseRoute     string
380	Image         string
381	HTTPS         bool
382	PreviewConfig domain.PreviewConfig
383}
384
385// CreatePreview orchestrates the full preview creation: lock, clone volumes, start containers, register.
386func (s *Service) CreatePreview(ctx context.Context, req CreatePreviewRequest) error {
387	lock := s.AcquireNameLock(req.Domain)
388	lock.Lock()
389	defer func() {
390		lock.Unlock()
391		s.ReleaseNameLock(req.Domain)
392	}()
393
394	log := zerowrap.FromCtx(ctx)
395	now := time.Now()
396
397	preview := domain.PreviewRoute{
398		Domain:    req.Domain,
399		Image:     req.Image,
400		BaseRoute: req.BaseRoute,
401		Name:      req.Name,
402		CreatedAt: now,
403		ExpiresAt: now.Add(req.PreviewConfig.TTL),
404		HTTPS:     req.HTTPS,
405		Status:    domain.PreviewStatusDeploying,
406	}
407
408	if err := s.Add(ctx, preview); err != nil {
409		return fmt.Errorf("register preview %q: %w", req.Name, err)
410	}
411
412	if req.PreviewConfig.DataCopy {
413		cloned, err := s.cloneBaseRouteVolumes(ctx, req.BaseRoute, req.Domain)
414		if err != nil {
415			log.Warn().Err(err).Str("base_route", req.BaseRoute).Msg("failed to clone volumes, deploying with empty volumes")
416		} else if len(cloned) > 0 {
417			preview.Volumes = cloned
418		}
419	}
420
421	if s.routeManager != nil {
422		imageRef := s.qualifyImage(req.Image)
423		if err := s.routeManager.AddRoute(ctx, domain.Route{
424			Domain: req.Domain,
425			Image:  imageRef,
426			HTTPS:  req.HTTPS,
427		}); err != nil {
428			preview.Status = domain.PreviewStatusFailed
429			if updateErr := s.Update(ctx, preview); updateErr != nil {
430				log.Warn().Err(updateErr).Str("preview", req.Name).Msg("failed to update preview status")
431			}
432			return fmt.Errorf("register preview route %q: %w", req.Domain, err)
433		}
434	}
435
436	var baseEnv []string
437	if s.envLoader != nil && req.PreviewConfig.EnvCopy {
438		var envErr error
439		baseEnv, envErr = s.envLoader.LoadEnv(ctx, req.BaseRoute)
440		if envErr != nil {
441			log.Warn().Err(envErr).Str("base_route", req.BaseRoute).Msg("failed to load base route env, deploying without env vars")
442		}
443	}
444
445	if s.deployer != nil {
446		imageRef := s.qualifyImage(req.Image)
447		deployCtx := domain.WithInternalDeploy(ctx)
448		container, err := s.deployer.Deploy(deployCtx, domain.Route{
449			Domain: req.Domain,
450			Image:  imageRef,
451			HTTPS:  req.HTTPS,
452			Env:    baseEnv,
453		})
454		if err != nil {
455			preview.Status = domain.PreviewStatusFailed
456			if updateErr := s.Update(ctx, preview); updateErr != nil {
457				log.Warn().Err(updateErr).Str("preview", req.Name).Msg("failed to update preview status")
458			}
459			return fmt.Errorf("deploy preview %q: %w", req.Name, err)
460		}
461		preview.Containers = []string{container.Name}
462	}
463
464	preview.Status = domain.PreviewStatusRunning
465	if err := s.Update(ctx, preview); err != nil {
466		return fmt.Errorf("finalize preview %q: %w", req.Name, err)
467	}
468
469	log.Info().
470		Str("name", req.Name).
471		Str("domain", req.Domain).
472		Str("image", req.Image).
473		Str("base_route", req.BaseRoute).
474		Msg("preview environment created")
475
476	return nil
477}

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