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}