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 | |||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | 5 | "fmt" | |
| 6 | + | "strings" | |
| 6 | 7 | "sync" | |
| 7 | 8 | "time" | |
| 8 | 9 | ||
| @@ -92,6 +93,21 @@ func (s *Service) Add(ctx context.Context, p domain.PreviewRoute) error { | |||
| 92 | 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 | 111 | func (s *Service) Delete(ctx context.Context, name string) error { | |
| 96 | 112 | s.mu.Lock() | |
| 97 | 113 | 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 | 311 | // CreatePreviewRequest contains parameters for creating a preview. | |
| 239 | 312 | type CreatePreviewRequest struct { | |
| 240 | 313 | Name string | |
| @@ -254,7 +327,9 @@ func (s *Service) CreatePreview(ctx context.Context, req CreatePreviewRequest) e | |||
| 254 | 327 | s.ReleaseNameLock(req.Name) | |
| 255 | 328 | }() | |
| 256 | 329 | ||
| 330 | + | log := zerowrap.FromCtx(ctx) | |
| 257 | 331 | now := time.Now() | |
| 332 | + | ||
| 258 | 333 | preview := domain.PreviewRoute{ | |
| 259 | 334 | Domain: req.Domain, | |
| 260 | 335 | Image: req.Image, | |
| @@ -263,11 +338,62 @@ func (s *Service) CreatePreview(ctx context.Context, req CreatePreviewRequest) e | |||
| 263 | 338 | CreatedAt: now, | |
| 264 | 339 | ExpiresAt: now.Add(req.PreviewConfig.TTL), | |
| 265 | 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 | 344 | if err := s.Add(ctx, preview); err != nil { | |
| 270 | 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 | 398 | return nil | |
| 273 | 399 | } | |
| @@ -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 := false | 113 | 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 string | 313 | 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 nil | 398 | return nil | ||
| 273 | } | 399 | } | ||