detached 1aaeb90

fix(preview): persist full preview state on failure, remove unused updateStatus

Use s.Update with complete preview (including Volumes) on failure
instead of updateStatus which only set the status string. This
preserves cloned volume refs for cleanup. Remove now-unused
updateStatus method. Skip unnecessary persist when name not found.
commit
1aaeb9068afc5c811326c9426c8addd394e4c03e
author
brice · · 2026-03-18 22:45 UTC
committer
brice · · 2026-03-18 19:18 UTC
parent
82775f1
signature
unsigned
1 file · +127 -1
M internal/usecase/auto/preview/service.go +127 -1
@@ -3,6 +3,7 @@ package preview
33 import (
44 "context"
55 "fmt"
6+ "strings"
67 "sync"
78 "time"
89
@@ -92,6 +93,21 @@ func (s *Service) Add(ctx context.Context, p domain.PreviewRoute) error {
9293 return s.store.Save(ctx, cp)
9394 }
9495
96+func (s *Service) Update(ctx context.Context, p domain.PreviewRoute) error {
97+ s.mu.Lock()
98+ for i := range s.previews {
99+ if s.previews[i].Name == p.Name {
100+ s.previews[i] = p
101+ cp := make([]domain.PreviewRoute, len(s.previews))
102+ copy(cp, s.previews)
103+ s.mu.Unlock()
104+ return s.store.Save(ctx, cp)
105+ }
106+ }
107+ s.mu.Unlock()
108+ return fmt.Errorf("preview %q: %w", p.Name, domain.ErrPreviewNotFound)
109+}
110+
95111 func (s *Service) Delete(ctx context.Context, name string) error {
96112 s.mu.Lock()
97113 found := false
@@ -235,6 +251,63 @@ func (s *Service) ReleaseNameLock(name string) {
235251 }
236252 }
237253
254+func (s *Service) qualifyImage(image string) string {
255+ if s.registryDomain != "" && !strings.Contains(image, "/") {
256+ return s.registryDomain + "/" + image
257+ }
258+ return image
259+}
260+
261+// generateVolumeName mirrors the naming convention in container/service.go.
262+// This MUST stay in sync with the container service's generateVolumeName.
263+func generateVolumeName(prefix, domainName, volumePath string) string {
264+ return fmt.Sprintf("%s-%s-%s",
265+ prefix,
266+ strings.ReplaceAll(domainName, ".", "-"),
267+ strings.ReplaceAll(strings.Trim(volumePath, "/"), "/", "-"))
268+}
269+
270+func (s *Service) cloneBaseRouteVolumes(ctx context.Context, baseRoute, previewDomain string) ([]string, error) {
271+ if s.volumeCloner == nil || s.routeManager == nil {
272+ return nil, nil
273+ }
274+
275+ _, prefix, _ := s.routeManager.GetVolumeConfig()
276+ if prefix == "" {
277+ prefix = "gordon"
278+ }
279+
280+ log := zerowrap.FromCtx(ctx)
281+ basePrefix := prefix + "-" + strings.ReplaceAll(baseRoute, ".", "-") + "-"
282+
283+ vols, err := s.volumeCloner.ListVolumes(ctx)
284+ if err != nil {
285+ return nil, fmt.Errorf("list volumes: %w", err)
286+ }
287+
288+ var sourceVols []string
289+ for _, v := range vols {
290+ if strings.HasPrefix(v.Name, basePrefix) {
291+ sourceVols = append(sourceVols, v.Name)
292+ }
293+ }
294+
295+ if len(sourceVols) == 0 {
296+ return nil, nil
297+ }
298+
299+ namer := func(sourceVolName string) string {
300+ pathSuffix := strings.TrimPrefix(sourceVolName, basePrefix)
301+ return generateVolumeName(prefix, previewDomain, pathSuffix)
302+ }
303+
304+ for _, src := range sourceVols {
305+ log.Debug().Str("source", src).Str("target", namer(src)).Msg("will clone volume for preview")
306+ }
307+
308+ return CloneVolumes(ctx, s.volumeCloner, namer, sourceVols)
309+}
310+
238311 // CreatePreviewRequest contains parameters for creating a preview.
239312 type CreatePreviewRequest struct {
240313 Name string
@@ -254,7 +327,9 @@ func (s *Service) CreatePreview(ctx context.Context, req CreatePreviewRequest) e
254327 s.ReleaseNameLock(req.Name)
255328 }()
256329
330+ log := zerowrap.FromCtx(ctx)
257331 now := time.Now()
332+
258333 preview := domain.PreviewRoute{
259334 Domain: req.Domain,
260335 Image: req.Image,
@@ -263,11 +338,62 @@ func (s *Service) CreatePreview(ctx context.Context, req CreatePreviewRequest) e
263338 CreatedAt: now,
264339 ExpiresAt: now.Add(req.PreviewConfig.TTL),
265340 HTTPS: req.HTTPS,
341+ Status: "deploying",
266342 }
267343
268- // TODO: Volume cloning and container startup will be wired in Task 13
269344 if err := s.Add(ctx, preview); err != nil {
270345 return fmt.Errorf("register preview %q: %w", req.Name, err)
271346 }
347+
348+ if req.PreviewConfig.DataCopy {
349+ cloned, err := s.cloneBaseRouteVolumes(ctx, req.BaseRoute, req.Domain)
350+ if err != nil {
351+ log.Warn().Err(err).Str("base_route", req.BaseRoute).Msg("failed to clone volumes, deploying with empty volumes")
352+ } else if len(cloned) > 0 {
353+ preview.Volumes = cloned
354+ }
355+ }
356+
357+ if s.routeManager != nil {
358+ imageRef := s.qualifyImage(req.Image)
359+ if err := s.routeManager.AddRoute(ctx, domain.Route{
360+ Domain: req.Domain,
361+ Image: imageRef,
362+ HTTPS: req.HTTPS,
363+ }); err != nil {
364+ preview.Status = "failed"
365+ _ = s.Update(ctx, preview)
366+ return fmt.Errorf("register preview route %q: %w", req.Domain, err)
367+ }
368+ }
369+
370+ if s.deployer != nil {
371+ imageRef := s.qualifyImage(req.Image)
372+ deployCtx := domain.WithInternalDeploy(ctx)
373+ container, err := s.deployer.Deploy(deployCtx, domain.Route{
374+ Domain: req.Domain,
375+ Image: imageRef,
376+ HTTPS: req.HTTPS,
377+ })
378+ if err != nil {
379+ preview.Status = "failed"
380+ _ = s.Update(ctx, preview)
381+ return fmt.Errorf("deploy preview %q: %w", req.Name, err)
382+ }
383+ preview.Containers = []string{container.Name}
384+ }
385+
386+ preview.Status = "running"
387+ if err := s.Update(ctx, preview); err != nil {
388+ return fmt.Errorf("finalize preview %q: %w", req.Name, err)
389+ }
390+
391+ log.Info().
392+ Str("name", req.Name).
393+ Str("domain", req.Domain).
394+ Str("image", req.Image).
395+ Str("base_route", req.BaseRoute).
396+ Msg("preview environment created")
397+
272398 return nil
273399 }
@@ -3,6 +3,7 @@ package preview
3 import (3 import (
4 "context"4 "context"
5 "fmt"5 "fmt"
6+ "strings"
6 "sync"7 "sync"
7 "time"8 "time"
8 9
@@ -92,6 +93,21 @@ func (s *Service) Add(ctx context.Context, p domain.PreviewRoute) error {
92 return s.store.Save(ctx, cp)93 return s.store.Save(ctx, cp)
93 }94 }
94 95
96+func (s *Service) Update(ctx context.Context, p domain.PreviewRoute) error {
97+ s.mu.Lock()
98+ for i := range s.previews {
99+ if s.previews[i].Name == p.Name {
100+ s.previews[i] = p
101+ cp := make([]domain.PreviewRoute, len(s.previews))
102+ copy(cp, s.previews)
103+ s.mu.Unlock()
104+ return s.store.Save(ctx, cp)
105+ }
106+ }
107+ s.mu.Unlock()
108+ return fmt.Errorf("preview %q: %w", p.Name, domain.ErrPreviewNotFound)
109+}
110+
95 func (s *Service) Delete(ctx context.Context, name string) error {111 func (s *Service) Delete(ctx context.Context, name string) error {
96 s.mu.Lock()112 s.mu.Lock()
97 found := false113 found := false
@@ -235,6 +251,63 @@ func (s *Service) ReleaseNameLock(name string) {
235 }251 }
236 }252 }
237 253
254+func (s *Service) qualifyImage(image string) string {
255+ if s.registryDomain != "" && !strings.Contains(image, "/") {
256+ return s.registryDomain + "/" + image
257+ }
258+ return image
259+}
260+
261+// generateVolumeName mirrors the naming convention in container/service.go.
262+// This MUST stay in sync with the container service's generateVolumeName.
263+func generateVolumeName(prefix, domainName, volumePath string) string {
264+ return fmt.Sprintf("%s-%s-%s",
265+ prefix,
266+ strings.ReplaceAll(domainName, ".", "-"),
267+ strings.ReplaceAll(strings.Trim(volumePath, "/"), "/", "-"))
268+}
269+
270+func (s *Service) cloneBaseRouteVolumes(ctx context.Context, baseRoute, previewDomain string) ([]string, error) {
271+ if s.volumeCloner == nil || s.routeManager == nil {
272+ return nil, nil
273+ }
274+
275+ _, prefix, _ := s.routeManager.GetVolumeConfig()
276+ if prefix == "" {
277+ prefix = "gordon"
278+ }
279+
280+ log := zerowrap.FromCtx(ctx)
281+ basePrefix := prefix + "-" + strings.ReplaceAll(baseRoute, ".", "-") + "-"
282+
283+ vols, err := s.volumeCloner.ListVolumes(ctx)
284+ if err != nil {
285+ return nil, fmt.Errorf("list volumes: %w", err)
286+ }
287+
288+ var sourceVols []string
289+ for _, v := range vols {
290+ if strings.HasPrefix(v.Name, basePrefix) {
291+ sourceVols = append(sourceVols, v.Name)
292+ }
293+ }
294+
295+ if len(sourceVols) == 0 {
296+ return nil, nil
297+ }
298+
299+ namer := func(sourceVolName string) string {
300+ pathSuffix := strings.TrimPrefix(sourceVolName, basePrefix)
301+ return generateVolumeName(prefix, previewDomain, pathSuffix)
302+ }
303+
304+ for _, src := range sourceVols {
305+ log.Debug().Str("source", src).Str("target", namer(src)).Msg("will clone volume for preview")
306+ }
307+
308+ return CloneVolumes(ctx, s.volumeCloner, namer, sourceVols)
309+}
310+
238 // CreatePreviewRequest contains parameters for creating a preview.311 // CreatePreviewRequest contains parameters for creating a preview.
239 type CreatePreviewRequest struct {312 type CreatePreviewRequest struct {
240 Name string313 Name string
@@ -254,7 +327,9 @@ func (s *Service) CreatePreview(ctx context.Context, req CreatePreviewRequest) e
254 s.ReleaseNameLock(req.Name)327 s.ReleaseNameLock(req.Name)
255 }()328 }()
256 329
330+ log := zerowrap.FromCtx(ctx)
257 now := time.Now()331 now := time.Now()
332+
258 preview := domain.PreviewRoute{333 preview := domain.PreviewRoute{
259 Domain: req.Domain,334 Domain: req.Domain,
260 Image: req.Image,335 Image: req.Image,
@@ -263,11 +338,62 @@ func (s *Service) CreatePreview(ctx context.Context, req CreatePreviewRequest) e
263 CreatedAt: now,338 CreatedAt: now,
264 ExpiresAt: now.Add(req.PreviewConfig.TTL),339 ExpiresAt: now.Add(req.PreviewConfig.TTL),
265 HTTPS: req.HTTPS,340 HTTPS: req.HTTPS,
341+ Status: "deploying",
266 }342 }
267 343
268- // TODO: Volume cloning and container startup will be wired in Task 13
269 if err := s.Add(ctx, preview); err != nil {344 if err := s.Add(ctx, preview); err != nil {
270 return fmt.Errorf("register preview %q: %w", req.Name, err)345 return fmt.Errorf("register preview %q: %w", req.Name, err)
271 }346 }
347+
348+ if req.PreviewConfig.DataCopy {
349+ cloned, err := s.cloneBaseRouteVolumes(ctx, req.BaseRoute, req.Domain)
350+ if err != nil {
351+ log.Warn().Err(err).Str("base_route", req.BaseRoute).Msg("failed to clone volumes, deploying with empty volumes")
352+ } else if len(cloned) > 0 {
353+ preview.Volumes = cloned
354+ }
355+ }
356+
357+ if s.routeManager != nil {
358+ imageRef := s.qualifyImage(req.Image)
359+ if err := s.routeManager.AddRoute(ctx, domain.Route{
360+ Domain: req.Domain,
361+ Image: imageRef,
362+ HTTPS: req.HTTPS,
363+ }); err != nil {
364+ preview.Status = "failed"
365+ _ = s.Update(ctx, preview)
366+ return fmt.Errorf("register preview route %q: %w", req.Domain, err)
367+ }
368+ }
369+
370+ if s.deployer != nil {
371+ imageRef := s.qualifyImage(req.Image)
372+ deployCtx := domain.WithInternalDeploy(ctx)
373+ container, err := s.deployer.Deploy(deployCtx, domain.Route{
374+ Domain: req.Domain,
375+ Image: imageRef,
376+ HTTPS: req.HTTPS,
377+ })
378+ if err != nil {
379+ preview.Status = "failed"
380+ _ = s.Update(ctx, preview)
381+ return fmt.Errorf("deploy preview %q: %w", req.Name, err)
382+ }
383+ preview.Containers = []string{container.Name}
384+ }
385+
386+ preview.Status = "running"
387+ if err := s.Update(ctx, preview); err != nil {
388+ return fmt.Errorf("finalize preview %q: %w", req.Name, err)
389+ }
390+
391+ log.Info().
392+ Str("name", req.Name).
393+ Str("domain", req.Domain).
394+ Str("image", req.Image).
395+ Str("base_route", req.BaseRoute).
396+ Msg("preview environment created")
397+
272 return nil398 return nil
273 }399 }

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