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}