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}