detached 7341e0e

feat: preview environment variable inheritance and CLI fixes

- Preview containers inherit env vars from base route at deploy time
- Add env_copy config option (default true) gated by auto.preview.env_copy
- Route.Env field allows pre-resolved env vars to skip EnvLoader
- Delete now removes routes from config (prevents stale routes)
- CLI: no more production route selector during preview create
- CLI: bare 'gordon preview' shows help instead of defaulting to create
- CLI: filter preview routes from push route resolution
- DefaultPreviewSeparator constant replaces hardcoded strings
- PreviewStatusTimeout typed constant
- Documented env var inheritance with override and disable options
commit
7341e0e8cbe19b9592b85f3d389b0c06cad48b14
author
brice · · 2026-03-18 18:24 UTC
committer
brice · · 2026-03-18 17:54 UTC
merge of
83b82b4 + fb3fd37 · diff against the first parent
signature
unsigned
refs
tag: v2.30.0-pre
20 files · +767 -71
M docs/config/preview.md +37 -3
@@ -13,6 +13,7 @@ ttl = "48h"
1313 separator = "--"
1414 tag_patterns = ["preview-*", "pr-*"]
1515 data_copy = true
16+env_copy = true
1617 ```
1718
1819 ## Options
@@ -24,6 +25,7 @@ data_copy = true
2425 | `separator` | string | `"--"` | String inserted between the base domain and the branch slug |
2526 | `tag_patterns` | string array | `[]` | Glob patterns matched against image tags to trigger preview creation |
2627 | `data_copy` | bool | `true` | Clone the production route's volumes into the preview environment on creation |
28+| `env_copy` | bool | `true` | Inherit environment variables from the base route's secret store into the preview container |
2729
2830 ### `ttl` Format
2931
@@ -179,8 +181,9 @@ When a push matches a `tag_patterns` entry:
179181
180182 1. Gordon creates a new route: `<app><separator><preview-name><rest-of-domain>` → pushed image
181183 2. If `data_copy = true`, volumes from the base route are cloned into the preview
182-3. The preview TTL timer starts
183-4. The container is deployed with zero-downtime rules disabled (previews are always cold-starts)
184+3. If `env_copy = true`, environment variables from the base route's secret store are loaded and passed to the preview container (without persisting them)
185+4. The preview TTL timer starts
186+5. The container is deployed with zero-downtime rules disabled (previews are always cold-starts)
184187
185188 ### TTL and Automatic Teardown
186189
@@ -201,6 +204,35 @@ When `data_copy = true`, Gordon copies the named volumes attached to the base ro
201204 - Cloned volumes are removed on preview teardown unless `volumes.preserve = true`
202205 - Set `data_copy = false` to start previews with empty volumes (faster creation, no production data)
203206
207+### Environment Variable Inheritance
208+
209+Preview containers receive environment variables from three sources, merged in this order (last wins):
210+
211+1. **Dockerfile `ENV`** — baked into the image at build time
212+2. **Base route secrets** — inherited from the production domain's secret store on first deploy (when `env_copy = true`)
213+3. **Preview domain secrets** — set directly on the preview domain, picked up on redeploy
214+
215+On initial creation, Gordon loads the base route's secrets and passes them to the container. The variables are **not** persisted under the preview domain — they are resolved at deploy time.
216+
217+#### Overriding variables for a preview
218+
219+To change a variable for a specific preview, set it on the preview domain:
220+
221+```bash
222+gordon secrets set gordon--pr42.example.com API_URL=https://staging-api.example.com
223+```
224+
225+This writes the secret to the preview domain's store and triggers an automatic redeploy (~60s debounce). On redeploy, the container picks up its own domain secrets instead of the inherited base route ones.
226+
227+#### Disabling env inheritance
228+
229+To start previews with a clean environment (Dockerfile `ENV` only):
230+
231+```toml
232+[auto.preview]
233+env_copy = false
234+```
235+
204236 ## Example Config
205237
206238 ```toml
@@ -216,6 +248,7 @@ ttl = "48h"
216248 separator = "--"
217249 tag_patterns = ["preview-*", "pr-*"]
218250 data_copy = true
251+env_copy = true
219252
220253 [routes]
221254 "app.example.com" = "myapp:latest"
@@ -227,9 +260,10 @@ preserve = false
227260 ```
228261
229262 With this config:
230-- Pushing `myapp:pr-99` creates `myapp--99.example.com` with cloned volumes
263+- Pushing `myapp:pr-99` creates `myapp--99.example.com` with cloned volumes and inherited env vars
231264 - Pushing `myapi:preview-auth-refactor` creates `myapi--auth-refactor.example.com`
232265 - Both previews expire after 48 hours and volumes are removed on teardown
266+- Env vars are loaded at deploy time from the base route's secret store and are not persisted for the preview domain
233267
234268 ## Related
235269
@@ -13,6 +13,7 @@ ttl = "48h"
13 separator = "--"13 separator = "--"
14 tag_patterns = ["preview-*", "pr-*"]14 tag_patterns = ["preview-*", "pr-*"]
15 data_copy = true15 data_copy = true
16+env_copy = true
16 ```17 ```
17 18
18 ## Options19 ## Options
@@ -24,6 +25,7 @@ data_copy = true
24 | `separator` | string | `"--"` | String inserted between the base domain and the branch slug |25 | `separator` | string | `"--"` | String inserted between the base domain and the branch slug |
25 | `tag_patterns` | string array | `[]` | Glob patterns matched against image tags to trigger preview creation |26 | `tag_patterns` | string array | `[]` | Glob patterns matched against image tags to trigger preview creation |
26 | `data_copy` | bool | `true` | Clone the production route's volumes into the preview environment on creation |27 | `data_copy` | bool | `true` | Clone the production route's volumes into the preview environment on creation |
28+| `env_copy` | bool | `true` | Inherit environment variables from the base route's secret store into the preview container |
27 29
28 ### `ttl` Format30 ### `ttl` Format
29 31
@@ -179,8 +181,9 @@ When a push matches a `tag_patterns` entry:
179 181
180 1. Gordon creates a new route: `<app><separator><preview-name><rest-of-domain>` → pushed image182 1. Gordon creates a new route: `<app><separator><preview-name><rest-of-domain>` → pushed image
181 2. If `data_copy = true`, volumes from the base route are cloned into the preview183 2. If `data_copy = true`, volumes from the base route are cloned into the preview
182-3. The preview TTL timer starts184+3. If `env_copy = true`, environment variables from the base route's secret store are loaded and passed to the preview container (without persisting them)
183-4. The container is deployed with zero-downtime rules disabled (previews are always cold-starts)185+4. The preview TTL timer starts
186+5. The container is deployed with zero-downtime rules disabled (previews are always cold-starts)
184 187
185 ### TTL and Automatic Teardown188 ### TTL and Automatic Teardown
186 189
@@ -201,6 +204,35 @@ When `data_copy = true`, Gordon copies the named volumes attached to the base ro
201 - Cloned volumes are removed on preview teardown unless `volumes.preserve = true`204 - Cloned volumes are removed on preview teardown unless `volumes.preserve = true`
202 - Set `data_copy = false` to start previews with empty volumes (faster creation, no production data)205 - Set `data_copy = false` to start previews with empty volumes (faster creation, no production data)
203 206
207+### Environment Variable Inheritance
208+
209+Preview containers receive environment variables from three sources, merged in this order (last wins):
210+
211+1. **Dockerfile `ENV`** — baked into the image at build time
212+2. **Base route secrets** — inherited from the production domain's secret store on first deploy (when `env_copy = true`)
213+3. **Preview domain secrets** — set directly on the preview domain, picked up on redeploy
214+
215+On initial creation, Gordon loads the base route's secrets and passes them to the container. The variables are **not** persisted under the preview domain — they are resolved at deploy time.
216+
217+#### Overriding variables for a preview
218+
219+To change a variable for a specific preview, set it on the preview domain:
220+
221+```bash
222+gordon secrets set gordon--pr42.example.com API_URL=https://staging-api.example.com
223+```
224+
225+This writes the secret to the preview domain's store and triggers an automatic redeploy (~60s debounce). On redeploy, the container picks up its own domain secrets instead of the inherited base route ones.
226+
227+#### Disabling env inheritance
228+
229+To start previews with a clean environment (Dockerfile `ENV` only):
230+
231+```toml
232+[auto.preview]
233+env_copy = false
234+```
235+
204 ## Example Config236 ## Example Config
205 237
206 ```toml238 ```toml
@@ -216,6 +248,7 @@ ttl = "48h"
216 separator = "--"248 separator = "--"
217 tag_patterns = ["preview-*", "pr-*"]249 tag_patterns = ["preview-*", "pr-*"]
218 data_copy = true250 data_copy = true
251+env_copy = true
219 252
220 [routes]253 [routes]
221 "app.example.com" = "myapp:latest"254 "app.example.com" = "myapp:latest"
@@ -227,9 +260,10 @@ preserve = false
227 ```260 ```
228 261
229 With this config:262 With this config:
230-- Pushing `myapp:pr-99` creates `myapp--99.example.com` with cloned volumes263+- Pushing `myapp:pr-99` creates `myapp--99.example.com` with cloned volumes and inherited env vars
231 - Pushing `myapi:preview-auth-refactor` creates `myapi--auth-refactor.example.com`264 - Pushing `myapi:preview-auth-refactor` creates `myapi--auth-refactor.example.com`
232 - Both previews expire after 48 hours and volumes are removed on teardown265 - Both previews expire after 48 hours and volumes are removed on teardown
266+- Env vars are loaded at deploy time from the base route's secret store and are not persisted for the preview domain
233 267
234 ## Related268 ## Related
235 269
M internal/adapters/in/cli/preview.go +170 -17
@@ -10,17 +10,20 @@ import (
1010 "text/tabwriter"
1111 "time"
1212
13+ tea "github.com/charmbracelet/bubbletea"
1314 "github.com/spf13/cobra"
1415
16+ "github.com/bnema/gordon/internal/adapters/in/cli/remote"
17+ "github.com/bnema/gordon/internal/adapters/in/cli/ui/components"
1518 "github.com/bnema/gordon/internal/domain"
1619 "github.com/bnema/gordon/internal/usecase/auto/preview"
1720 )
1821
1922 func newPreviewCmd() *cobra.Command {
2023 cmd := &cobra.Command{
21- Use: "preview [name]",
24+ Use: "preview",
2225 Short: "Create or manage preview environments",
23- Long: "Build, push, and deploy an ephemeral preview environment. Defaults to current git branch name.",
26+ Long: "Build, push, and deploy an ephemeral preview environment. Use a subcommand (create, list, delete, extend).",
2427 }
2528
2629 createCmd := newPreviewCreateCmd()
@@ -32,11 +35,6 @@ func newPreviewCmd() *cobra.Command {
3235 newPreviewExtendCmd(),
3336 )
3437
35- // Default action (no subcommand) = create
36- cmd.RunE = createCmd.RunE
37- cmd.Args = cobra.MaximumNArgs(1)
38- cmd.Flags().AddFlagSet(createCmd.Flags())
39-
4038 return cmd
4139 }
4240
@@ -84,7 +82,7 @@ func newPreviewCreateCmd() *cobra.Command {
8482 return err
8583 }
8684
87- return printPreviewCreateSummary(out, name, ttl, noData)
85+ return waitForPreview(ctx, out, name, ttl, noData)
8886 },
8987 }
9088
@@ -234,9 +232,11 @@ func resolvePreviewImageRef(ctx context.Context, cp ControlPlane, out io.Writer,
234232 return "", err
235233 }
236234
237- registry, _, _, err := resolveRoute(ctx, cp, "", "", dockerfile)
235+ // Resolve registry from existing routes without showing the interactive
236+ // selector — preview only needs the registry URL, not a target domain.
237+ registry, err := resolveRegistryForImage(ctx, cp, imageName)
238238 if err != nil {
239- return "", fmt.Errorf("failed to resolve registry: %w", err)
239+ return "", err
240240 }
241241
242242 previewRef := fmt.Sprintf("%s/%s:%s", registry, imageName, previewTag)
@@ -246,6 +246,27 @@ func resolvePreviewImageRef(ctx context.Context, cp ControlPlane, out io.Writer,
246246 return previewRef, nil
247247 }
248248
249+// resolveRegistryForImage finds the registry URL from any existing non-preview
250+// route for the given image, without prompting.
251+func resolveRegistryForImage(ctx context.Context, cp ControlPlane, imageName string) (string, error) {
252+ routes, err := cp.FindRoutesByImage(ctx, imageName)
253+ if err != nil {
254+ return "", fmt.Errorf("failed to find routes for image %q: %w", imageName, err)
255+ }
256+
257+ for _, r := range routes {
258+ if strings.Contains(r.Domain, domain.DefaultPreviewSeparator) {
259+ continue
260+ }
261+ reg, _, _ := parseImageRef(r.Image)
262+ if reg != "" {
263+ return reg, nil
264+ }
265+ }
266+
267+ return "", fmt.Errorf("no route configured for image %q", imageName)
268+}
269+
249270 func buildAndPushPreview(ctx context.Context, out io.Writer, previewRef, previewTag, platform string, noBuild bool) error {
250271 imageOps, err := newImageOpsFn()
251272 if err != nil {
@@ -275,13 +296,7 @@ func buildAndPushPreview(ctx context.Context, out io.Writer, previewRef, preview
275296 return nil
276297 }
277298
278-func printPreviewCreateSummary(out io.Writer, name, ttl string, noData bool) error {
279- if err := cliWriteLine(out, cliRenderSuccess("Push complete")); err != nil {
280- return err
281- }
282- if err := cliWritef(out, "Preview %s will be created by the server's autopreview handler.\n", name); err != nil {
283- return err
284- }
299+func printPreviewFlags(out io.Writer, ttl string, noData bool) error {
285300 if ttl != "" {
286301 if err := cliWritef(out, "Requested TTL: %s (server config controls actual TTL)\n", ttl); err != nil {
287302 return err
@@ -295,6 +310,144 @@ func printPreviewCreateSummary(out io.Writer, name, ttl string, noData bool) err
295310 return nil
296311 }
297312
313+func waitForPreview(ctx context.Context, out io.Writer, name, ttl string, noData bool) error {
314+ if err := printPreviewFlags(out, ttl, noData); err != nil {
315+ return err
316+ }
317+
318+ client, isRemote := GetRemoteClient()
319+ if !isRemote {
320+ return cliWriteLine(out, cliRenderSuccess(fmt.Sprintf("Push complete. Preview %q will be created by the server (use --remote to poll status).", name)))
321+ }
322+
323+ result, err := pollPreviewWithSpinner(ctx, out, client, name)
324+ if err != nil {
325+ return err
326+ }
327+
328+ switch result.Status {
329+ case domain.PreviewStatusRunning:
330+ scheme := "http"
331+ if result.HTTPS {
332+ scheme = "https"
333+ }
334+ previewURL := fmt.Sprintf("%s://%s", scheme, result.Domain)
335+ if err := cliWriteLine(out, cliRenderSuccess("Preview deployed")); err != nil {
336+ return err
337+ }
338+ return cliWriteLine(out, cliRenderMeta("URL:", previewURL))
339+ case domain.PreviewStatusFailed:
340+ return fmt.Errorf("preview deployment failed")
341+ default:
342+ return cliWriteLine(out, cliRenderInfo("Preview is still deploying. Check status with: gordon preview list --remote"))
343+ }
344+}
345+
346+type previewPollResult struct {
347+ preview *domain.PreviewRoute
348+ err error
349+}
350+
351+type previewPollDoneMsg previewPollResult
352+
353+type previewSpinnerModel struct {
354+ spinner components.SpinnerModel
355+ done <-chan previewPollResult
356+ outcome previewPollResult
357+ finished bool
358+}
359+
360+func newPreviewSpinnerModel(name string, done <-chan previewPollResult) previewSpinnerModel {
361+ return previewSpinnerModel{
362+ spinner: components.NewSpinner(
363+ components.WithMessage(fmt.Sprintf("Deploying preview %s...", name)),
364+ components.WithSpinnerType(components.SpinnerMiniDot),
365+ ),
366+ done: done,
367+ }
368+}
369+
370+func (m previewSpinnerModel) Init() tea.Cmd {
371+ return tea.Batch(m.spinner.Init(), waitForPreviewPollDone(m.done))
372+}
373+
374+func (m previewSpinnerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
375+ switch msg := msg.(type) {
376+ case previewPollDoneMsg:
377+ m.outcome = previewPollResult(msg)
378+ m.finished = true
379+ return m, tea.Quit
380+ default:
381+ updated, cmd := m.spinner.Update(msg)
382+ if sm, ok := updated.(components.SpinnerModel); ok {
383+ m.spinner = sm
384+ }
385+ return m, cmd
386+ }
387+}
388+
389+func (m previewSpinnerModel) View() string {
390+ return m.spinner.View()
391+}
392+
393+func waitForPreviewPollDone(done <-chan previewPollResult) tea.Cmd {
394+ return func() tea.Msg {
395+ return previewPollDoneMsg(<-done)
396+ }
397+}
398+
399+func pollPreviewWithSpinner(ctx context.Context, out io.Writer, client *remote.Client, name string) (*domain.PreviewRoute, error) {
400+ done := make(chan previewPollResult, 1)
401+ go func() {
402+ result, err := pollPreviewStatus(ctx, client, name)
403+ done <- previewPollResult{preview: result, err: err}
404+ }()
405+
406+ if !isInteractiveTerminal() {
407+ if err := cliWritef(out, "Waiting for preview %s...\n", name); err != nil {
408+ return nil, err
409+ }
410+ r := <-done
411+ return r.preview, r.err
412+ }
413+
414+ model := newPreviewSpinnerModel(name, done)
415+ final, err := tea.NewProgram(model, tea.WithContext(ctx)).Run()
416+ fmt.Print("\r\033[K")
417+ if err != nil {
418+ return nil, err
419+ }
420+
421+ m, ok := final.(previewSpinnerModel)
422+ if !ok || !m.finished {
423+ return nil, fmt.Errorf("preview spinner exited unexpectedly")
424+ }
425+ return m.outcome.preview, m.outcome.err
426+}
427+
428+func pollPreviewStatus(ctx context.Context, client *remote.Client, name string) (*domain.PreviewRoute, error) {
429+ ticker := time.NewTicker(2 * time.Second)
430+ defer ticker.Stop()
431+ timeout := time.After(3 * time.Minute)
432+
433+ for {
434+ select {
435+ case <-ctx.Done():
436+ return nil, ctx.Err()
437+ case <-timeout:
438+ return &domain.PreviewRoute{Status: domain.PreviewStatusTimeout}, nil
439+ case <-ticker.C:
440+ p, err := client.GetPreview(ctx, name)
441+ if err != nil {
442+ continue
443+ }
444+ if p.Status == domain.PreviewStatusRunning || p.Status == domain.PreviewStatusFailed {
445+ return p, nil
446+ }
447+ }
448+ }
449+}
450+
298451 func detectGitBranch(ctx context.Context) (string, error) {
299452 out, err := exec.CommandContext(ctx, "git", "rev-parse", "--abbrev-ref", "HEAD").Output() // #nosec G204
300453 if err != nil {
@@ -10,17 +10,20 @@ import (
10 "text/tabwriter"10 "text/tabwriter"
11 "time"11 "time"
12 12
13+ tea "github.com/charmbracelet/bubbletea"
13 "github.com/spf13/cobra"14 "github.com/spf13/cobra"
14 15
16+ "github.com/bnema/gordon/internal/adapters/in/cli/remote"
17+ "github.com/bnema/gordon/internal/adapters/in/cli/ui/components"
15 "github.com/bnema/gordon/internal/domain"18 "github.com/bnema/gordon/internal/domain"
16 "github.com/bnema/gordon/internal/usecase/auto/preview"19 "github.com/bnema/gordon/internal/usecase/auto/preview"
17 )20 )
18 21
19 func newPreviewCmd() *cobra.Command {22 func newPreviewCmd() *cobra.Command {
20 cmd := &cobra.Command{23 cmd := &cobra.Command{
21- Use: "preview [name]",24+ Use: "preview",
22 Short: "Create or manage preview environments",25 Short: "Create or manage preview environments",
23- Long: "Build, push, and deploy an ephemeral preview environment. Defaults to current git branch name.",26+ Long: "Build, push, and deploy an ephemeral preview environment. Use a subcommand (create, list, delete, extend).",
24 }27 }
25 28
26 createCmd := newPreviewCreateCmd()29 createCmd := newPreviewCreateCmd()
@@ -32,11 +35,6 @@ func newPreviewCmd() *cobra.Command {
32 newPreviewExtendCmd(),35 newPreviewExtendCmd(),
33 )36 )
34 37
35- // Default action (no subcommand) = create
36- cmd.RunE = createCmd.RunE
37- cmd.Args = cobra.MaximumNArgs(1)
38- cmd.Flags().AddFlagSet(createCmd.Flags())
39-
40 return cmd38 return cmd
41 }39 }
42 40
@@ -84,7 +82,7 @@ func newPreviewCreateCmd() *cobra.Command {
84 return err82 return err
85 }83 }
86 84
87- return printPreviewCreateSummary(out, name, ttl, noData)85+ return waitForPreview(ctx, out, name, ttl, noData)
88 },86 },
89 }87 }
90 88
@@ -234,9 +232,11 @@ func resolvePreviewImageRef(ctx context.Context, cp ControlPlane, out io.Writer,
234 return "", err232 return "", err
235 }233 }
236 234
237- registry, _, _, err := resolveRoute(ctx, cp, "", "", dockerfile)235+ // Resolve registry from existing routes without showing the interactive
236+ // selector — preview only needs the registry URL, not a target domain.
237+ registry, err := resolveRegistryForImage(ctx, cp, imageName)
238 if err != nil {238 if err != nil {
239- return "", fmt.Errorf("failed to resolve registry: %w", err)239+ return "", err
240 }240 }
241 241
242 previewRef := fmt.Sprintf("%s/%s:%s", registry, imageName, previewTag)242 previewRef := fmt.Sprintf("%s/%s:%s", registry, imageName, previewTag)
@@ -246,6 +246,27 @@ func resolvePreviewImageRef(ctx context.Context, cp ControlPlane, out io.Writer,
246 return previewRef, nil246 return previewRef, nil
247 }247 }
248 248
249+// resolveRegistryForImage finds the registry URL from any existing non-preview
250+// route for the given image, without prompting.
251+func resolveRegistryForImage(ctx context.Context, cp ControlPlane, imageName string) (string, error) {
252+ routes, err := cp.FindRoutesByImage(ctx, imageName)
253+ if err != nil {
254+ return "", fmt.Errorf("failed to find routes for image %q: %w", imageName, err)
255+ }
256+
257+ for _, r := range routes {
258+ if strings.Contains(r.Domain, domain.DefaultPreviewSeparator) {
259+ continue
260+ }
261+ reg, _, _ := parseImageRef(r.Image)
262+ if reg != "" {
263+ return reg, nil
264+ }
265+ }
266+
267+ return "", fmt.Errorf("no route configured for image %q", imageName)
268+}
269+
249 func buildAndPushPreview(ctx context.Context, out io.Writer, previewRef, previewTag, platform string, noBuild bool) error {270 func buildAndPushPreview(ctx context.Context, out io.Writer, previewRef, previewTag, platform string, noBuild bool) error {
250 imageOps, err := newImageOpsFn()271 imageOps, err := newImageOpsFn()
251 if err != nil {272 if err != nil {
@@ -275,13 +296,7 @@ func buildAndPushPreview(ctx context.Context, out io.Writer, previewRef, preview
275 return nil296 return nil
276 }297 }
277 298
278-func printPreviewCreateSummary(out io.Writer, name, ttl string, noData bool) error {299+func printPreviewFlags(out io.Writer, ttl string, noData bool) error {
279- if err := cliWriteLine(out, cliRenderSuccess("Push complete")); err != nil {
280- return err
281- }
282- if err := cliWritef(out, "Preview %s will be created by the server's autopreview handler.\n", name); err != nil {
283- return err
284- }
285 if ttl != "" {300 if ttl != "" {
286 if err := cliWritef(out, "Requested TTL: %s (server config controls actual TTL)\n", ttl); err != nil {301 if err := cliWritef(out, "Requested TTL: %s (server config controls actual TTL)\n", ttl); err != nil {
287 return err302 return err
@@ -295,6 +310,144 @@ func printPreviewCreateSummary(out io.Writer, name, ttl string, noData bool) err
295 return nil310 return nil
296 }311 }
297 312
313+func waitForPreview(ctx context.Context, out io.Writer, name, ttl string, noData bool) error {
314+ if err := printPreviewFlags(out, ttl, noData); err != nil {
315+ return err
316+ }
317+
318+ client, isRemote := GetRemoteClient()
319+ if !isRemote {
320+ return cliWriteLine(out, cliRenderSuccess(fmt.Sprintf("Push complete. Preview %q will be created by the server (use --remote to poll status).", name)))
321+ }
322+
323+ result, err := pollPreviewWithSpinner(ctx, out, client, name)
324+ if err != nil {
325+ return err
326+ }
327+
328+ switch result.Status {
329+ case domain.PreviewStatusRunning:
330+ scheme := "http"
331+ if result.HTTPS {
332+ scheme = "https"
333+ }
334+ previewURL := fmt.Sprintf("%s://%s", scheme, result.Domain)
335+ if err := cliWriteLine(out, cliRenderSuccess("Preview deployed")); err != nil {
336+ return err
337+ }
338+ return cliWriteLine(out, cliRenderMeta("URL:", previewURL))
339+ case domain.PreviewStatusFailed:
340+ return fmt.Errorf("preview deployment failed")
341+ default:
342+ return cliWriteLine(out, cliRenderInfo("Preview is still deploying. Check status with: gordon preview list --remote"))
343+ }
344+}
345+
346+type previewPollResult struct {
347+ preview *domain.PreviewRoute
348+ err error
349+}
350+
351+type previewPollDoneMsg previewPollResult
352+
353+type previewSpinnerModel struct {
354+ spinner components.SpinnerModel
355+ done <-chan previewPollResult
356+ outcome previewPollResult
357+ finished bool
358+}
359+
360+func newPreviewSpinnerModel(name string, done <-chan previewPollResult) previewSpinnerModel {
361+ return previewSpinnerModel{
362+ spinner: components.NewSpinner(
363+ components.WithMessage(fmt.Sprintf("Deploying preview %s...", name)),
364+ components.WithSpinnerType(components.SpinnerMiniDot),
365+ ),
366+ done: done,
367+ }
368+}
369+
370+func (m previewSpinnerModel) Init() tea.Cmd {
371+ return tea.Batch(m.spinner.Init(), waitForPreviewPollDone(m.done))
372+}
373+
374+func (m previewSpinnerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
375+ switch msg := msg.(type) {
376+ case previewPollDoneMsg:
377+ m.outcome = previewPollResult(msg)
378+ m.finished = true
379+ return m, tea.Quit
380+ default:
381+ updated, cmd := m.spinner.Update(msg)
382+ if sm, ok := updated.(components.SpinnerModel); ok {
383+ m.spinner = sm
384+ }
385+ return m, cmd
386+ }
387+}
388+
389+func (m previewSpinnerModel) View() string {
390+ return m.spinner.View()
391+}
392+
393+func waitForPreviewPollDone(done <-chan previewPollResult) tea.Cmd {
394+ return func() tea.Msg {
395+ return previewPollDoneMsg(<-done)
396+ }
397+}
398+
399+func pollPreviewWithSpinner(ctx context.Context, out io.Writer, client *remote.Client, name string) (*domain.PreviewRoute, error) {
400+ done := make(chan previewPollResult, 1)
401+ go func() {
402+ result, err := pollPreviewStatus(ctx, client, name)
403+ done <- previewPollResult{preview: result, err: err}
404+ }()
405+
406+ if !isInteractiveTerminal() {
407+ if err := cliWritef(out, "Waiting for preview %s...\n", name); err != nil {
408+ return nil, err
409+ }
410+ r := <-done
411+ return r.preview, r.err
412+ }
413+
414+ model := newPreviewSpinnerModel(name, done)
415+ final, err := tea.NewProgram(model, tea.WithContext(ctx)).Run()
416+ fmt.Print("\r\033[K")
417+ if err != nil {
418+ return nil, err
419+ }
420+
421+ m, ok := final.(previewSpinnerModel)
422+ if !ok || !m.finished {
423+ return nil, fmt.Errorf("preview spinner exited unexpectedly")
424+ }
425+ return m.outcome.preview, m.outcome.err
426+}
427+
428+func pollPreviewStatus(ctx context.Context, client *remote.Client, name string) (*domain.PreviewRoute, error) {
429+ ticker := time.NewTicker(2 * time.Second)
430+ defer ticker.Stop()
431+ timeout := time.After(3 * time.Minute)
432+
433+ for {
434+ select {
435+ case <-ctx.Done():
436+ return nil, ctx.Err()
437+ case <-timeout:
438+ return &domain.PreviewRoute{Status: domain.PreviewStatusTimeout}, nil
439+ case <-ticker.C:
440+ p, err := client.GetPreview(ctx, name)
441+ if err != nil {
442+ continue
443+ }
444+ if p.Status == domain.PreviewStatusRunning || p.Status == domain.PreviewStatusFailed {
445+ return p, nil
446+ }
447+ }
448+ }
449+}
450+
298 func detectGitBranch(ctx context.Context) (string, error) {451 func detectGitBranch(ctx context.Context) (string, error) {
299 out, err := exec.CommandContext(ctx, "git", "rev-parse", "--abbrev-ref", "HEAD").Output() // #nosec G204452 out, err := exec.CommandContext(ctx, "git", "rev-parse", "--abbrev-ref", "HEAD").Output() // #nosec G204
300 if err != nil {453 if err != nil {
M internal/adapters/in/cli/push.go +10 -0
@@ -276,6 +276,16 @@ func resolveFromImage(ctx context.Context, cp ControlPlane, imageArg, dockerfile
276276 return "", "", "", fmt.Errorf("failed to find routes for image %q: %w", imageArg, err)
277277 }
278278
279+ // Filter out preview routes (domains containing "--") so they don't
280+ // pollute the selection when pushing the base image.
281+ filtered := routes[:0]
282+ for _, r := range routes {
283+ if !strings.Contains(r.Domain, domain.DefaultPreviewSeparator) {
284+ filtered = append(filtered, r)
285+ }
286+ }
287+ routes = filtered
288+
279289 if len(routes) == 0 {
280290 // bootstrap command is registered in bootstrap.go (see issue #98)
281291 return "", "", "", fmt.Errorf(
@@ -276,6 +276,16 @@ func resolveFromImage(ctx context.Context, cp ControlPlane, imageArg, dockerfile
276 return "", "", "", fmt.Errorf("failed to find routes for image %q: %w", imageArg, err)276 return "", "", "", fmt.Errorf("failed to find routes for image %q: %w", imageArg, err)
277 }277 }
278 278
279+ // Filter out preview routes (domains containing "--") so they don't
280+ // pollute the selection when pushing the base image.
281+ filtered := routes[:0]
282+ for _, r := range routes {
283+ if !strings.Contains(r.Domain, domain.DefaultPreviewSeparator) {
284+ filtered = append(filtered, r)
285+ }
286+ }
287+ routes = filtered
288+
279 if len(routes) == 0 {289 if len(routes) == 0 {
280 // bootstrap command is registered in bootstrap.go (see issue #98)290 // bootstrap command is registered in bootstrap.go (see issue #98)
281 return "", "", "", fmt.Errorf(291 return "", "", "", fmt.Errorf(
M internal/adapters/in/cli/remote/preview.go +14 -0
@@ -54,3 +54,17 @@ func (c *Client) ExtendPreview(ctx context.Context, name string, ttl string) err
5454 }
5555 return parseResponse(resp, nil)
5656 }
57+
58+// GetPreview returns a single preview by name.
59+func (c *Client) GetPreview(ctx context.Context, name string) (*domain.PreviewRoute, error) {
60+ previews, err := c.ListPreviews(ctx)
61+ if err != nil {
62+ return nil, fmt.Errorf("list previews: %w", err)
63+ }
64+ for _, p := range previews {
65+ if p.Name == name {
66+ return &p, nil
67+ }
68+ }
69+ return nil, fmt.Errorf("preview %q: %w", name, domain.ErrPreviewNotFound)
70+}
@@ -54,3 +54,17 @@ func (c *Client) ExtendPreview(ctx context.Context, name string, ttl string) err
54 }54 }
55 return parseResponse(resp, nil)55 return parseResponse(resp, nil)
56 }56 }
57+
58+// GetPreview returns a single preview by name.
59+func (c *Client) GetPreview(ctx context.Context, name string) (*domain.PreviewRoute, error) {
60+ previews, err := c.ListPreviews(ctx)
61+ if err != nil {
62+ return nil, fmt.Errorf("list previews: %w", err)
63+ }
64+ for _, p := range previews {
65+ if p.Name == name {
66+ return &p, nil
67+ }
68+ }
69+ return nil, fmt.Errorf("preview %q: %w", name, domain.ErrPreviewNotFound)
70+}
M internal/adapters/out/envloader/pass_loader.go +5 -2
@@ -86,8 +86,11 @@ func (l *PassLoader) LoadEnv(ctx context.Context, domain string) ([]string, erro
8686 // This is an acceptable trade-off for simplicity, but if such domains become common,
8787 // this should be replaced with a more robust detection mechanism (e.g., checking both
8888 // GetAll() and GetAllAttachment() and seeing which returns results).
89-func isAttachmentContainer(domain string) bool {
90- return strings.HasPrefix(domain, "gordon-")
89+func isAttachmentContainer(name string) bool {
90+ // Attachment container names start with "gordon-" and never contain dots.
91+ // Domain names (including preview domains like "gordon--test.bnema.dev")
92+ // always contain dots, so checking for dots distinguishes them.
93+ return strings.HasPrefix(name, "gordon-") && !strings.Contains(name, ".")
9194 }
9295
9396 // CreateEnvFile is a no-op for pass-backed loader.
@@ -86,8 +86,11 @@ func (l *PassLoader) LoadEnv(ctx context.Context, domain string) ([]string, erro
86 // This is an acceptable trade-off for simplicity, but if such domains become common,86 // This is an acceptable trade-off for simplicity, but if such domains become common,
87 // this should be replaced with a more robust detection mechanism (e.g., checking both87 // this should be replaced with a more robust detection mechanism (e.g., checking both
88 // GetAll() and GetAllAttachment() and seeing which returns results).88 // GetAll() and GetAllAttachment() and seeing which returns results).
89-func isAttachmentContainer(domain string) bool {89+func isAttachmentContainer(name string) bool {
90- return strings.HasPrefix(domain, "gordon-")90+ // Attachment container names start with "gordon-" and never contain dots.
91+ // Domain names (including preview domains like "gordon--test.bnema.dev")
92+ // always contain dots, so checking for dots distinguishes them.
93+ return strings.HasPrefix(name, "gordon-") && !strings.Contains(name, ".")
91 }94 }
92 95
93 // CreateEnvFile is a no-op for pass-backed loader.96 // CreateEnvFile is a no-op for pass-backed loader.
M internal/app/run.go +14 -1
@@ -572,7 +572,12 @@ func initPreviewService(ctx context.Context, cfg Config, svc *services, log zero
572572 previewStorePath := filepath.Join(resolveDataDir(cfg.Server.DataDir), "previews.json")
573573 svc.previewStore = filesystem.NewPreviewStore(previewStorePath)
574574 previewConfig := svc.configSvc.GetPreviewConfig()
575- svc.previewService = preview.NewService(svc.previewStore, previewConfig.TTL)
575+ svc.previewService = preview.NewService(svc.previewStore, previewConfig.TTL).
576+ WithDeployer(svc.containerSvc).
577+ WithRouteManager(svc.configSvc).
578+ WithVolumeCloner(svc.runtime).
579+ WithRegistryDomain(svc.configSvc.GetRegistryDomain()).
580+ WithEnvLoader(svc.envLoader)
576581 if err := svc.previewService.Load(ctx); err != nil {
577582 log.Warn().Err(err).Msg("failed to load previews")
578583 }
@@ -592,6 +597,14 @@ func initPreviewService(ctx context.Context, cfg Config, svc *services, log zero
592597 log.Warn().Err(err).Str("volume", volName).Str("preview", p.Domain).Msg("failed to remove preview volume")
593598 }
594599 }
600+ // Remove route from config so proxy stops routing to this domain
601+ if err := svc.configSvc.RemoveRoute(ctx, p.Domain); err != nil {
602+ if errors.Is(err, domain.ErrRouteNotFound) {
603+ log.Debug().Str("domain", p.Domain).Msg("preview route already removed from config")
604+ } else {
605+ log.Warn().Err(err).Str("domain", p.Domain).Msg("failed to remove preview route from config")
606+ }
607+ }
595608 svc.proxySvc.InvalidateTarget(ctx, p.Domain)
596609 })
597610 }
@@ -572,7 +572,12 @@ func initPreviewService(ctx context.Context, cfg Config, svc *services, log zero
572 previewStorePath := filepath.Join(resolveDataDir(cfg.Server.DataDir), "previews.json")572 previewStorePath := filepath.Join(resolveDataDir(cfg.Server.DataDir), "previews.json")
573 svc.previewStore = filesystem.NewPreviewStore(previewStorePath)573 svc.previewStore = filesystem.NewPreviewStore(previewStorePath)
574 previewConfig := svc.configSvc.GetPreviewConfig()574 previewConfig := svc.configSvc.GetPreviewConfig()
575- svc.previewService = preview.NewService(svc.previewStore, previewConfig.TTL)575+ svc.previewService = preview.NewService(svc.previewStore, previewConfig.TTL).
576+ WithDeployer(svc.containerSvc).
577+ WithRouteManager(svc.configSvc).
578+ WithVolumeCloner(svc.runtime).
579+ WithRegistryDomain(svc.configSvc.GetRegistryDomain()).
580+ WithEnvLoader(svc.envLoader)
576 if err := svc.previewService.Load(ctx); err != nil {581 if err := svc.previewService.Load(ctx); err != nil {
577 log.Warn().Err(err).Msg("failed to load previews")582 log.Warn().Err(err).Msg("failed to load previews")
578 }583 }
@@ -592,6 +597,14 @@ func initPreviewService(ctx context.Context, cfg Config, svc *services, log zero
592 log.Warn().Err(err).Str("volume", volName).Str("preview", p.Domain).Msg("failed to remove preview volume")597 log.Warn().Err(err).Str("volume", volName).Str("preview", p.Domain).Msg("failed to remove preview volume")
593 }598 }
594 }599 }
600+ // Remove route from config so proxy stops routing to this domain
601+ if err := svc.configSvc.RemoveRoute(ctx, p.Domain); err != nil {
602+ if errors.Is(err, domain.ErrRouteNotFound) {
603+ log.Debug().Str("domain", p.Domain).Msg("preview route already removed from config")
604+ } else {
605+ log.Warn().Err(err).Str("domain", p.Domain).Msg("failed to remove preview route from config")
606+ }
607+ }
595 svc.proxySvc.InvalidateTarget(ctx, p.Domain)608 svc.proxySvc.InvalidateTarget(ctx, p.Domain)
596 })609 })
597 }610 }
M internal/domain/auto.go +1 -0
@@ -20,4 +20,5 @@ type PreviewConfig struct {
2020 Separator string
2121 TagPatterns []string
2222 DataCopy bool
23+ EnvCopy bool
2324 }
@@ -20,4 +20,5 @@ type PreviewConfig struct {
20 Separator string20 Separator string
21 TagPatterns []string21 TagPatterns []string
22 DataCopy bool22 DataCopy bool
23+ EnvCopy bool
23 }24 }
M internal/domain/preview.go +24 -9
@@ -2,17 +2,32 @@ package domain
22
33 import "time"
44
5+// PreviewStatus represents the lifecycle state of a preview environment.
6+type PreviewStatus string
7+
8+const (
9+ PreviewStatusDeploying PreviewStatus = "deploying"
10+ PreviewStatusRunning PreviewStatus = "running"
11+ PreviewStatusFailed PreviewStatus = "failed"
12+ PreviewStatusTimeout PreviewStatus = "timeout"
13+
14+ // DefaultPreviewSeparator is the subdomain separator used to construct
15+ // preview domain names (e.g. "myapp--feat.example.com").
16+ DefaultPreviewSeparator = "--"
17+)
18+
519 // PreviewRoute represents an ephemeral preview environment.
620 type PreviewRoute struct {
7- Domain string `json:"domain"`
8- Image string `json:"image"`
9- BaseRoute string `json:"base_route"`
10- Name string `json:"name"`
11- CreatedAt time.Time `json:"created_at"`
12- ExpiresAt time.Time `json:"expires_at"`
13- HTTPS bool `json:"https"`
14- Volumes []string `json:"volumes"`
15- Containers []string `json:"containers"`
21+ Domain string `json:"domain"`
22+ Image string `json:"image"`
23+ BaseRoute string `json:"base_route"`
24+ Name string `json:"name"`
25+ CreatedAt time.Time `json:"created_at"`
26+ ExpiresAt time.Time `json:"expires_at"`
27+ HTTPS bool `json:"https"`
28+ Status PreviewStatus `json:"status"`
29+ Volumes []string `json:"volumes"`
30+ Containers []string `json:"containers"`
1631 }
1732
1833 // IsExpired returns true if the preview has exceeded its TTL.
@@ -2,17 +2,32 @@ package domain
2 2
3 import "time"3 import "time"
4 4
5+// PreviewStatus represents the lifecycle state of a preview environment.
6+type PreviewStatus string
7+
8+const (
9+ PreviewStatusDeploying PreviewStatus = "deploying"
10+ PreviewStatusRunning PreviewStatus = "running"
11+ PreviewStatusFailed PreviewStatus = "failed"
12+ PreviewStatusTimeout PreviewStatus = "timeout"
13+
14+ // DefaultPreviewSeparator is the subdomain separator used to construct
15+ // preview domain names (e.g. "myapp--feat.example.com").
16+ DefaultPreviewSeparator = "--"
17+)
18+
5 // PreviewRoute represents an ephemeral preview environment.19 // PreviewRoute represents an ephemeral preview environment.
6 type PreviewRoute struct {20 type PreviewRoute struct {
7- Domain string `json:"domain"`21+ Domain string `json:"domain"`
8- Image string `json:"image"`22+ Image string `json:"image"`
9- BaseRoute string `json:"base_route"`23+ BaseRoute string `json:"base_route"`
10- Name string `json:"name"`24+ Name string `json:"name"`
11- CreatedAt time.Time `json:"created_at"`25+ CreatedAt time.Time `json:"created_at"`
12- ExpiresAt time.Time `json:"expires_at"`26+ ExpiresAt time.Time `json:"expires_at"`
13- HTTPS bool `json:"https"`27+ HTTPS bool `json:"https"`
14- Volumes []string `json:"volumes"`28+ Status PreviewStatus `json:"status"`
15- Containers []string `json:"containers"`29+ Volumes []string `json:"volumes"`
30+ Containers []string `json:"containers"`
16 }31 }
17 32
18 // IsExpired returns true if the preview has exceeded its TTL.33 // IsExpired returns true if the preview has exceeded its TTL.
M internal/domain/route.go +1 -0
@@ -5,6 +5,7 @@ type Route struct {
55 Domain string
66 Image string
77 HTTPS bool
8+ Env []string // Pre-resolved env vars ("KEY=VALUE"); when set, Deploy skips EnvLoader lookup.
89 }
910
1011 // ProxyTarget represents the destination for proxying requests.
@@ -5,6 +5,7 @@ type Route struct {
5 Domain string5 Domain string
6 Image string6 Image string
7 HTTPS bool7 HTTPS bool
8+ Env []string // Pre-resolved env vars ("KEY=VALUE"); when set, Deploy skips EnvLoader lookup.
8 }9 }
9 10
10 // ProxyTarget represents the destination for proxying requests.11 // ProxyTarget represents the destination for proxying requests.
M internal/usecase/auto/dispatcher.go +1 -0
@@ -17,6 +17,7 @@ type AutoConfigProvider interface {
1717 GetPreviewTagPatterns() []string
1818 GetPreviewConfig() domain.PreviewConfig
1919 GetAllowedDomains() []string
20+ FindRoutesByImage(ctx context.Context, imageName string) []domain.Route
2021 }
2122
2223 // ImagePushDispatcher classifies image push events and delegates
@@ -17,6 +17,7 @@ type AutoConfigProvider interface {
17 GetPreviewTagPatterns() []string17 GetPreviewTagPatterns() []string
18 GetPreviewConfig() domain.PreviewConfig18 GetPreviewConfig() domain.PreviewConfig
19 GetAllowedDomains() []string19 GetAllowedDomains() []string
20+ FindRoutesByImage(ctx context.Context, imageName string) []domain.Route
20 }21 }
21 22
22 // ImagePushDispatcher classifies image push events and delegates23 // ImagePushDispatcher classifies image push events and delegates
M internal/usecase/auto/dispatcher_test.go +3 -0
@@ -32,6 +32,9 @@ func (m *mockAutoConfig) IsPreviewEnabled() bool { return m.prev
3232 func (m *mockAutoConfig) GetPreviewTagPatterns() []string { return m.tagPatterns }
3333 func (m *mockAutoConfig) GetPreviewConfig() domain.PreviewConfig { return domain.PreviewConfig{} }
3434 func (m *mockAutoConfig) GetAllowedDomains() []string { return nil }
35+func (m *mockAutoConfig) FindRoutesByImage(_ context.Context, _ string) []domain.Route {
36+ return nil
37+}
3538
3639 func TestDispatcher_PreviewTag_DelegatesToPreview(t *testing.T) {
3740 route := &mockHandler{}
@@ -32,6 +32,9 @@ func (m *mockAutoConfig) IsPreviewEnabled() bool { return m.prev
32 func (m *mockAutoConfig) GetPreviewTagPatterns() []string { return m.tagPatterns }32 func (m *mockAutoConfig) GetPreviewTagPatterns() []string { return m.tagPatterns }
33 func (m *mockAutoConfig) GetPreviewConfig() domain.PreviewConfig { return domain.PreviewConfig{} }33 func (m *mockAutoConfig) GetPreviewConfig() domain.PreviewConfig { return domain.PreviewConfig{} }
34 func (m *mockAutoConfig) GetAllowedDomains() []string { return nil }34 func (m *mockAutoConfig) GetAllowedDomains() []string { return nil }
35+func (m *mockAutoConfig) FindRoutesByImage(_ context.Context, _ string) []domain.Route {
36+ return nil
37+}
35 38
36 func TestDispatcher_PreviewTag_DelegatesToPreview(t *testing.T) {39 func TestDispatcher_PreviewTag_DelegatesToPreview(t *testing.T) {
37 route := &mockHandler{}40 route := &mockHandler{}
M internal/usecase/auto/preview/handler.go +13 -2
@@ -3,6 +3,7 @@ package preview
33 import (
44 "context"
55 "fmt"
6+ "strings"
67
78 "github.com/bnema/zerowrap"
89
@@ -62,10 +63,20 @@ func (h *AutoPreviewHandler) Handle(ctx context.Context, event domain.Event) err
6263 previewConfig := h.config.GetPreviewConfig()
6364 previewName := ExtractPreviewName(payload.Reference, previewConfig.TagPatterns)
6465
65- // Resolve base route from labels
66+ // Resolve base route: labels first, then route config fallback.
67+ // When falling back to routes, skip preview domains (they contain the separator)
68+ // to avoid using a preview as the base for another preview.
6669 domains := collectDomains(labels)
6770 if len(domains) == 0 {
68- log.Debug().Str("image", payload.Name).Msg("no gordon.domain label found, skipping preview handler")
71+ routes := h.config.FindRoutesByImage(ctx, payload.Name)
72+ for _, r := range routes {
73+ if !strings.Contains(r.Domain, previewConfig.Separator) {
74+ domains = append(domains, r.Domain)
75+ }
76+ }
77+ }
78+ if len(domains) == 0 {
79+ log.Debug().Str("image", payload.Name).Msg("no domain found from labels or routes, skipping preview")
6980 return nil
7081 }
7182 baseRouteDomain := domains[0]
@@ -3,6 +3,7 @@ package preview
3 import (3 import (
4 "context"4 "context"
5 "fmt"5 "fmt"
6+ "strings"
6 7
7 "github.com/bnema/zerowrap"8 "github.com/bnema/zerowrap"
8 9
@@ -62,10 +63,20 @@ func (h *AutoPreviewHandler) Handle(ctx context.Context, event domain.Event) err
62 previewConfig := h.config.GetPreviewConfig()63 previewConfig := h.config.GetPreviewConfig()
63 previewName := ExtractPreviewName(payload.Reference, previewConfig.TagPatterns)64 previewName := ExtractPreviewName(payload.Reference, previewConfig.TagPatterns)
64 65
65- // Resolve base route from labels66+ // Resolve base route: labels first, then route config fallback.
67+ // When falling back to routes, skip preview domains (they contain the separator)
68+ // to avoid using a preview as the base for another preview.
66 domains := collectDomains(labels)69 domains := collectDomains(labels)
67 if len(domains) == 0 {70 if len(domains) == 0 {
68- log.Debug().Str("image", payload.Name).Msg("no gordon.domain label found, skipping preview handler")71+ routes := h.config.FindRoutesByImage(ctx, payload.Name)
72+ for _, r := range routes {
73+ if !strings.Contains(r.Domain, previewConfig.Separator) {
74+ domains = append(domains, r.Domain)
75+ }
76+ }
77+ }
78+ if len(domains) == 0 {
79+ log.Debug().Str("image", payload.Name).Msg("no domain found from labels or routes, skipping preview")
69 return nil80 return nil
70 }81 }
71 baseRouteDomain := domains[0]82 baseRouteDomain := domains[0]
M internal/usecase/auto/preview/integration_test.go +149 -2
@@ -2,6 +2,7 @@ package preview
22
33 import (
44 "context"
5+ "fmt"
56 "testing"
67 "time"
78
@@ -11,11 +12,50 @@ import (
1112 "github.com/bnema/gordon/internal/domain"
1213 )
1314
15+type fakeDeployer struct {
16+ deployFn func(ctx context.Context, route domain.Route) (*domain.Container, error)
17+}
18+
19+func (f *fakeDeployer) Deploy(ctx context.Context, route domain.Route) (*domain.Container, error) {
20+ return f.deployFn(ctx, route)
21+}
22+
23+type fakeRouteManager struct {
24+ addRouteFn func(ctx context.Context, route domain.Route) error
25+ removeRouteFn func(ctx context.Context, domain string) error
26+ getRouteFn func(ctx context.Context, domain string) (*domain.Route, error)
27+}
28+
29+func (f *fakeRouteManager) AddRoute(ctx context.Context, route domain.Route) error {
30+ if f.addRouteFn != nil {
31+ return f.addRouteFn(ctx, route)
32+ }
33+ return nil
34+}
35+
36+func (f *fakeRouteManager) RemoveRoute(ctx context.Context, d string) error {
37+ if f.removeRouteFn != nil {
38+ return f.removeRouteFn(ctx, d)
39+ }
40+ return nil
41+}
42+
43+func (f *fakeRouteManager) GetRoute(ctx context.Context, d string) (*domain.Route, error) {
44+ if f.getRouteFn != nil {
45+ return f.getRouteFn(ctx, d)
46+ }
47+ return nil, fmt.Errorf("route %q: %w", d, domain.ErrRouteNotFound)
48+}
49+
50+func (f *fakeRouteManager) GetVolumeConfig() (bool, string, bool) {
51+ return true, "gordon", false
52+}
53+
1454 func TestPreviewLifecycle_Integration(t *testing.T) {
1555 store := &fakeStore{}
1656 svc := NewService(store, 2*time.Second)
1757
18- ctx := context.Background()
58+ ctx := t.Context()
1959
2060 // Create
2161 err := svc.CreatePreview(ctx, CreatePreviewRequest{
@@ -61,7 +101,7 @@ func TestPreviewLifecycle_CleanupExpired(t *testing.T) {
61101 store := &fakeStore{}
62102 svc := NewService(store, 1*time.Millisecond)
63103
64- ctx := context.Background()
104+ ctx := t.Context()
65105
66106 // Create with very short TTL
67107 err := svc.CreatePreview(ctx, CreatePreviewRequest{
@@ -91,3 +131,110 @@ func TestPreviewLifecycle_CleanupExpired(t *testing.T) {
91131 require.NoError(t, err)
92132 assert.Empty(t, all)
93133 }
134+
135+type fakeEnvLoader struct {
136+ loadEnvFn func(ctx context.Context, domain string) ([]string, error)
137+}
138+
139+func (f *fakeEnvLoader) LoadEnv(ctx context.Context, domain string) ([]string, error) {
140+ if f.loadEnvFn != nil {
141+ return f.loadEnvFn(ctx, domain)
142+ }
143+ return nil, nil
144+}
145+
146+func (f *fakeEnvLoader) CreateEnvFile(_ context.Context, _ string) error { return nil }
147+func (f *fakeEnvLoader) EnvFileExists(_ string) (bool, error) { return false, nil }
148+
149+func TestPreviewLifecycle_WithEnvInheritance(t *testing.T) {
150+ store := &fakeStore{}
151+ var deployedRoute domain.Route
152+
153+ mockDeployer := &fakeDeployer{
154+ deployFn: func(_ context.Context, route domain.Route) (*domain.Container, error) {
155+ deployedRoute = route
156+ return &domain.Container{Name: "gordon-" + route.Domain}, nil
157+ },
158+ }
159+ mockRouteManager := &fakeRouteManager{}
160+ mockEnvLoader := &fakeEnvLoader{
161+ loadEnvFn: func(_ context.Context, d string) ([]string, error) {
162+ if d == "myapp.example.com" {
163+ return []string{"DB_HOST=prod-db", "API_KEY=secret123"}, nil
164+ }
165+ return nil, nil
166+ },
167+ }
168+
169+ svc := NewService(store, 2*time.Second).
170+ WithDeployer(mockDeployer).
171+ WithRouteManager(mockRouteManager).
172+ WithEnvLoader(mockEnvLoader).
173+ WithRegistryDomain("reg.example.com")
174+
175+ ctx := t.Context()
176+ err := svc.CreatePreview(ctx, CreatePreviewRequest{
177+ Name: "test-feat",
178+ Domain: "myapp--test-feat.example.com",
179+ BaseRoute: "myapp.example.com",
180+ Image: "myapp:preview-test-feat",
181+ HTTPS: true,
182+ PreviewConfig: domain.PreviewConfig{
183+ TTL: 2 * time.Second,
184+ Separator: "--",
185+ EnvCopy: true,
186+ },
187+ })
188+ require.NoError(t, err)
189+
190+ // Deployer should receive base route's env vars via Route.Env
191+ assert.Equal(t, []string{"DB_HOST=prod-db", "API_KEY=secret123"}, deployedRoute.Env)
192+}
193+
194+func TestPreviewLifecycle_WithDeploy(t *testing.T) {
195+ store := &fakeStore{}
196+ deployed := false
197+ routeAdded := ""
198+
199+ mockDeployer := &fakeDeployer{
200+ deployFn: func(_ context.Context, route domain.Route) (*domain.Container, error) {
201+ deployed = true
202+ return &domain.Container{Name: "gordon-" + route.Domain}, nil
203+ },
204+ }
205+ mockRouteManager := &fakeRouteManager{
206+ addRouteFn: func(_ context.Context, route domain.Route) error {
207+ routeAdded = route.Domain
208+ return nil
209+ },
210+ }
211+
212+ svc := NewService(store, 2*time.Second).
213+ WithDeployer(mockDeployer).
214+ WithRouteManager(mockRouteManager).
215+ WithRegistryDomain("reg.example.com")
216+
217+ ctx := t.Context()
218+ err := svc.CreatePreview(ctx, CreatePreviewRequest{
219+ Name: "test-feat",
220+ Domain: "myapp--test-feat.example.com",
221+ BaseRoute: "myapp.example.com",
222+ Image: "myapp:preview-test-feat",
223+ HTTPS: true,
224+ PreviewConfig: domain.PreviewConfig{
225+ TTL: 2 * time.Second,
226+ Separator: "--",
227+ DataCopy: false,
228+ },
229+ })
230+ require.NoError(t, err)
231+
232+ assert.True(t, deployed, "deployer should have been called")
233+ assert.Equal(t, "myapp--test-feat.example.com", routeAdded)
234+
235+ all, err := svc.List(ctx)
236+ require.NoError(t, err)
237+ require.Len(t, all, 1)
238+ assert.Equal(t, domain.PreviewStatusRunning, all[0].Status)
239+ assert.Equal(t, []string{"gordon-myapp--test-feat.example.com"}, all[0].Containers)
240+}
@@ -2,6 +2,7 @@ package preview
2 2
3 import (3 import (
4 "context"4 "context"
5+ "fmt"
5 "testing"6 "testing"
6 "time"7 "time"
7 8
@@ -11,11 +12,50 @@ import (
11 "github.com/bnema/gordon/internal/domain"12 "github.com/bnema/gordon/internal/domain"
12 )13 )
13 14
15+type fakeDeployer struct {
16+ deployFn func(ctx context.Context, route domain.Route) (*domain.Container, error)
17+}
18+
19+func (f *fakeDeployer) Deploy(ctx context.Context, route domain.Route) (*domain.Container, error) {
20+ return f.deployFn(ctx, route)
21+}
22+
23+type fakeRouteManager struct {
24+ addRouteFn func(ctx context.Context, route domain.Route) error
25+ removeRouteFn func(ctx context.Context, domain string) error
26+ getRouteFn func(ctx context.Context, domain string) (*domain.Route, error)
27+}
28+
29+func (f *fakeRouteManager) AddRoute(ctx context.Context, route domain.Route) error {
30+ if f.addRouteFn != nil {
31+ return f.addRouteFn(ctx, route)
32+ }
33+ return nil
34+}
35+
36+func (f *fakeRouteManager) RemoveRoute(ctx context.Context, d string) error {
37+ if f.removeRouteFn != nil {
38+ return f.removeRouteFn(ctx, d)
39+ }
40+ return nil
41+}
42+
43+func (f *fakeRouteManager) GetRoute(ctx context.Context, d string) (*domain.Route, error) {
44+ if f.getRouteFn != nil {
45+ return f.getRouteFn(ctx, d)
46+ }
47+ return nil, fmt.Errorf("route %q: %w", d, domain.ErrRouteNotFound)
48+}
49+
50+func (f *fakeRouteManager) GetVolumeConfig() (bool, string, bool) {
51+ return true, "gordon", false
52+}
53+
14 func TestPreviewLifecycle_Integration(t *testing.T) {54 func TestPreviewLifecycle_Integration(t *testing.T) {
15 store := &fakeStore{}55 store := &fakeStore{}
16 svc := NewService(store, 2*time.Second)56 svc := NewService(store, 2*time.Second)
17 57
18- ctx := context.Background()58+ ctx := t.Context()
19 59
20 // Create60 // Create
21 err := svc.CreatePreview(ctx, CreatePreviewRequest{61 err := svc.CreatePreview(ctx, CreatePreviewRequest{
@@ -61,7 +101,7 @@ func TestPreviewLifecycle_CleanupExpired(t *testing.T) {
61 store := &fakeStore{}101 store := &fakeStore{}
62 svc := NewService(store, 1*time.Millisecond)102 svc := NewService(store, 1*time.Millisecond)
63 103
64- ctx := context.Background()104+ ctx := t.Context()
65 105
66 // Create with very short TTL106 // Create with very short TTL
67 err := svc.CreatePreview(ctx, CreatePreviewRequest{107 err := svc.CreatePreview(ctx, CreatePreviewRequest{
@@ -91,3 +131,110 @@ func TestPreviewLifecycle_CleanupExpired(t *testing.T) {
91 require.NoError(t, err)131 require.NoError(t, err)
92 assert.Empty(t, all)132 assert.Empty(t, all)
93 }133 }
134+
135+type fakeEnvLoader struct {
136+ loadEnvFn func(ctx context.Context, domain string) ([]string, error)
137+}
138+
139+func (f *fakeEnvLoader) LoadEnv(ctx context.Context, domain string) ([]string, error) {
140+ if f.loadEnvFn != nil {
141+ return f.loadEnvFn(ctx, domain)
142+ }
143+ return nil, nil
144+}
145+
146+func (f *fakeEnvLoader) CreateEnvFile(_ context.Context, _ string) error { return nil }
147+func (f *fakeEnvLoader) EnvFileExists(_ string) (bool, error) { return false, nil }
148+
149+func TestPreviewLifecycle_WithEnvInheritance(t *testing.T) {
150+ store := &fakeStore{}
151+ var deployedRoute domain.Route
152+
153+ mockDeployer := &fakeDeployer{
154+ deployFn: func(_ context.Context, route domain.Route) (*domain.Container, error) {
155+ deployedRoute = route
156+ return &domain.Container{Name: "gordon-" + route.Domain}, nil
157+ },
158+ }
159+ mockRouteManager := &fakeRouteManager{}
160+ mockEnvLoader := &fakeEnvLoader{
161+ loadEnvFn: func(_ context.Context, d string) ([]string, error) {
162+ if d == "myapp.example.com" {
163+ return []string{"DB_HOST=prod-db", "API_KEY=secret123"}, nil
164+ }
165+ return nil, nil
166+ },
167+ }
168+
169+ svc := NewService(store, 2*time.Second).
170+ WithDeployer(mockDeployer).
171+ WithRouteManager(mockRouteManager).
172+ WithEnvLoader(mockEnvLoader).
173+ WithRegistryDomain("reg.example.com")
174+
175+ ctx := t.Context()
176+ err := svc.CreatePreview(ctx, CreatePreviewRequest{
177+ Name: "test-feat",
178+ Domain: "myapp--test-feat.example.com",
179+ BaseRoute: "myapp.example.com",
180+ Image: "myapp:preview-test-feat",
181+ HTTPS: true,
182+ PreviewConfig: domain.PreviewConfig{
183+ TTL: 2 * time.Second,
184+ Separator: "--",
185+ EnvCopy: true,
186+ },
187+ })
188+ require.NoError(t, err)
189+
190+ // Deployer should receive base route's env vars via Route.Env
191+ assert.Equal(t, []string{"DB_HOST=prod-db", "API_KEY=secret123"}, deployedRoute.Env)
192+}
193+
194+func TestPreviewLifecycle_WithDeploy(t *testing.T) {
195+ store := &fakeStore{}
196+ deployed := false
197+ routeAdded := ""
198+
199+ mockDeployer := &fakeDeployer{
200+ deployFn: func(_ context.Context, route domain.Route) (*domain.Container, error) {
201+ deployed = true
202+ return &domain.Container{Name: "gordon-" + route.Domain}, nil
203+ },
204+ }
205+ mockRouteManager := &fakeRouteManager{
206+ addRouteFn: func(_ context.Context, route domain.Route) error {
207+ routeAdded = route.Domain
208+ return nil
209+ },
210+ }
211+
212+ svc := NewService(store, 2*time.Second).
213+ WithDeployer(mockDeployer).
214+ WithRouteManager(mockRouteManager).
215+ WithRegistryDomain("reg.example.com")
216+
217+ ctx := t.Context()
218+ err := svc.CreatePreview(ctx, CreatePreviewRequest{
219+ Name: "test-feat",
220+ Domain: "myapp--test-feat.example.com",
221+ BaseRoute: "myapp.example.com",
222+ Image: "myapp:preview-test-feat",
223+ HTTPS: true,
224+ PreviewConfig: domain.PreviewConfig{
225+ TTL: 2 * time.Second,
226+ Separator: "--",
227+ DataCopy: false,
228+ },
229+ })
230+ require.NoError(t, err)
231+
232+ assert.True(t, deployed, "deployer should have been called")
233+ assert.Equal(t, "myapp--test-feat.example.com", routeAdded)
234+
235+ all, err := svc.List(ctx)
236+ require.NoError(t, err)
237+ require.Len(t, all, 1)
238+ assert.Equal(t, domain.PreviewStatusRunning, all[0].Status)
239+ assert.Equal(t, []string{"gordon-myapp--test-feat.example.com"}, all[0].Containers)
240+}
M internal/usecase/auto/preview/service.go +205 -8
@@ -3,6 +3,7 @@ package preview
33 import (
44 "context"
55 "fmt"
6+ "strings"
67 "sync"
78 "time"
89
@@ -17,14 +18,32 @@ type nameLockEntry struct {
1718 refs int
1819 }
1920
21+// Deployer abstracts the container service deploy method.
22+type 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.
27+type 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+
2034 // Service manages preview lifecycle: CRUD, TTL, persistence.
2135 type Service struct {
22- store out.PreviewStore
23- defaultTTL time.Duration
24- previews []domain.PreviewRoute
25- mu sync.RWMutex
26- nameLocks map[string]*nameLockEntry
27- nameLockMu sync.Mutex
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
2847 }
2948
3049 func NewService(store out.PreviewStore, defaultTTL time.Duration) *Service {
@@ -35,6 +54,31 @@ func NewService(store out.PreviewStore, defaultTTL time.Duration) *Service {
3554 }
3655 }
3756
57+func (s *Service) WithDeployer(d Deployer) *Service {
58+ s.deployer = d
59+ return s
60+}
61+
62+func (s *Service) WithRouteManager(rm RouteManager) *Service {
63+ s.routeManager = rm
64+ return s
65+}
66+
67+func (s *Service) WithVolumeCloner(vc VolumeCloner) *Service {
68+ s.volumeCloner = vc
69+ return s
70+}
71+
72+func (s *Service) WithRegistryDomain(d string) *Service {
73+ s.registryDomain = d
74+ return s
75+}
76+
77+func (s *Service) WithEnvLoader(el out.EnvLoader) *Service {
78+ s.envLoader = el
79+ return s
80+}
81+
3882 func (s *Service) Load(ctx context.Context) error {
3983 previews, err := s.store.Load(ctx)
4084 if err != nil {
@@ -55,13 +99,30 @@ func (s *Service) Add(ctx context.Context, p domain.PreviewRoute) error {
5599 return s.store.Save(ctx, cp)
56100 }
57101
102+func (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+
58117 func (s *Service) Delete(ctx context.Context, name string) error {
59118 s.mu.Lock()
119+ var deleted domain.PreviewRoute
60120 found := false
61121 filtered := make([]domain.PreviewRoute, 0, len(s.previews))
62122 for _, p := range s.previews {
63123 if p.Name == name {
64124 found = true
125+ deleted = p
65126 } else {
66127 filtered = append(filtered, p)
67128 }
@@ -74,7 +135,19 @@ func (s *Service) Delete(ctx context.Context, name string) error {
74135 cp := make([]domain.PreviewRoute, len(s.previews))
75136 copy(cp, s.previews)
76137 s.mu.Unlock()
77- return s.store.Save(ctx, cp)
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
78151 }
79152
80153 func (s *Service) Get(_ context.Context, name string) (*domain.PreviewRoute, error) {
@@ -198,6 +271,63 @@ func (s *Service) ReleaseNameLock(name string) {
198271 }
199272 }
200273
274+func (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.
283+func 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+
290+func (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+
201331 // CreatePreviewRequest contains parameters for creating a preview.
202332 type CreatePreviewRequest struct {
203333 Name string
@@ -217,7 +347,9 @@ func (s *Service) CreatePreview(ctx context.Context, req CreatePreviewRequest) e
217347 s.ReleaseNameLock(req.Name)
218348 }()
219349
350+ log := zerowrap.FromCtx(ctx)
220351 now := time.Now()
352+
221353 preview := domain.PreviewRoute{
222354 Domain: req.Domain,
223355 Image: req.Image,
@@ -226,11 +358,76 @@ func (s *Service) CreatePreview(ctx context.Context, req CreatePreviewRequest) e
226358 CreatedAt: now,
227359 ExpiresAt: now.Add(req.PreviewConfig.TTL),
228360 HTTPS: req.HTTPS,
361+ Status: domain.PreviewStatusDeploying,
229362 }
230363
231- // TODO: Volume cloning and container startup will be wired in Task 13
232364 if err := s.Add(ctx, preview); err != nil {
233365 return fmt.Errorf("register preview %q: %w", req.Name, err)
234366 }
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+
235432 return nil
236433 }
@@ -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
@@ -17,14 +18,32 @@ type nameLockEntry struct {
17 refs int18 refs int
18 }19 }
19 20
21+// Deployer abstracts the container service deploy method.
22+type 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.
27+type 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+
20 // Service manages preview lifecycle: CRUD, TTL, persistence.34 // Service manages preview lifecycle: CRUD, TTL, persistence.
21 type Service struct {35 type Service struct {
22- store out.PreviewStore36+ store out.PreviewStore
23- defaultTTL time.Duration37+ defaultTTL time.Duration
24- previews []domain.PreviewRoute38+ deployer Deployer
25- mu sync.RWMutex39+ routeManager RouteManager
26- nameLocks map[string]*nameLockEntry40+ volumeCloner VolumeCloner
27- nameLockMu sync.Mutex41+ envLoader out.EnvLoader
42+ registryDomain string
43+ previews []domain.PreviewRoute
44+ mu sync.RWMutex
45+ nameLocks map[string]*nameLockEntry
46+ nameLockMu sync.Mutex
28 }47 }
29 48
30 func NewService(store out.PreviewStore, defaultTTL time.Duration) *Service {49 func NewService(store out.PreviewStore, defaultTTL time.Duration) *Service {
@@ -35,6 +54,31 @@ func NewService(store out.PreviewStore, defaultTTL time.Duration) *Service {
35 }54 }
36 }55 }
37 56
57+func (s *Service) WithDeployer(d Deployer) *Service {
58+ s.deployer = d
59+ return s
60+}
61+
62+func (s *Service) WithRouteManager(rm RouteManager) *Service {
63+ s.routeManager = rm
64+ return s
65+}
66+
67+func (s *Service) WithVolumeCloner(vc VolumeCloner) *Service {
68+ s.volumeCloner = vc
69+ return s
70+}
71+
72+func (s *Service) WithRegistryDomain(d string) *Service {
73+ s.registryDomain = d
74+ return s
75+}
76+
77+func (s *Service) WithEnvLoader(el out.EnvLoader) *Service {
78+ s.envLoader = el
79+ return s
80+}
81+
38 func (s *Service) Load(ctx context.Context) error {82 func (s *Service) Load(ctx context.Context) error {
39 previews, err := s.store.Load(ctx)83 previews, err := s.store.Load(ctx)
40 if err != nil {84 if err != nil {
@@ -55,13 +99,30 @@ func (s *Service) Add(ctx context.Context, p domain.PreviewRoute) error {
55 return s.store.Save(ctx, cp)99 return s.store.Save(ctx, cp)
56 }100 }
57 101
102+func (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+
58 func (s *Service) Delete(ctx context.Context, name string) error {117 func (s *Service) Delete(ctx context.Context, name string) error {
59 s.mu.Lock()118 s.mu.Lock()
119+ var deleted domain.PreviewRoute
60 found := false120 found := false
61 filtered := make([]domain.PreviewRoute, 0, len(s.previews))121 filtered := make([]domain.PreviewRoute, 0, len(s.previews))
62 for _, p := range s.previews {122 for _, p := range s.previews {
63 if p.Name == name {123 if p.Name == name {
64 found = true124 found = true
125+ deleted = p
65 } else {126 } else {
66 filtered = append(filtered, p)127 filtered = append(filtered, p)
67 }128 }
@@ -74,7 +135,19 @@ func (s *Service) Delete(ctx context.Context, name string) error {
74 cp := make([]domain.PreviewRoute, len(s.previews))135 cp := make([]domain.PreviewRoute, len(s.previews))
75 copy(cp, s.previews)136 copy(cp, s.previews)
76 s.mu.Unlock()137 s.mu.Unlock()
77- return s.store.Save(ctx, cp)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
78 }151 }
79 152
80 func (s *Service) Get(_ context.Context, name string) (*domain.PreviewRoute, error) {153 func (s *Service) Get(_ context.Context, name string) (*domain.PreviewRoute, error) {
@@ -198,6 +271,63 @@ func (s *Service) ReleaseNameLock(name string) {
198 }271 }
199 }272 }
200 273
274+func (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.
283+func 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+
290+func (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+
201 // CreatePreviewRequest contains parameters for creating a preview.331 // CreatePreviewRequest contains parameters for creating a preview.
202 type CreatePreviewRequest struct {332 type CreatePreviewRequest struct {
203 Name string333 Name string
@@ -217,7 +347,9 @@ func (s *Service) CreatePreview(ctx context.Context, req CreatePreviewRequest) e
217 s.ReleaseNameLock(req.Name)347 s.ReleaseNameLock(req.Name)
218 }()348 }()
219 349
350+ log := zerowrap.FromCtx(ctx)
220 now := time.Now()351 now := time.Now()
352+
221 preview := domain.PreviewRoute{353 preview := domain.PreviewRoute{
222 Domain: req.Domain,354 Domain: req.Domain,
223 Image: req.Image,355 Image: req.Image,
@@ -226,11 +358,76 @@ func (s *Service) CreatePreview(ctx context.Context, req CreatePreviewRequest) e
226 CreatedAt: now,358 CreatedAt: now,
227 ExpiresAt: now.Add(req.PreviewConfig.TTL),359 ExpiresAt: now.Add(req.PreviewConfig.TTL),
228 HTTPS: req.HTTPS,360 HTTPS: req.HTTPS,
361+ Status: domain.PreviewStatusDeploying,
229 }362 }
230 363
231- // TODO: Volume cloning and container startup will be wired in Task 13
232 if err := s.Add(ctx, preview); err != nil {364 if err := s.Add(ctx, preview); err != nil {
233 return fmt.Errorf("register preview %q: %w", req.Name, err)365 return fmt.Errorf("register preview %q: %w", req.Name, err)
234 }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+
235 return nil432 return nil
236 }433 }
M internal/usecase/auto/preview/service_test.go +45 -12
@@ -33,10 +33,10 @@ func TestPreviewService_Add(t *testing.T) {
3333 BaseRoute: "myapp.example.com",
3434 Image: "myapp:preview-feat",
3535 }
36- err := svc.Add(context.Background(), p)
36+ err := svc.Add(t.Context(), p)
3737 require.NoError(t, err)
3838
39- all, err := svc.List(context.Background())
39+ all, err := svc.List(t.Context())
4040 require.NoError(t, err)
4141 assert.Len(t, all, 1)
4242 assert.Equal(t, "feat", all[0].Name)
@@ -49,12 +49,12 @@ func TestPreviewService_Delete(t *testing.T) {
4949 },
5050 }
5151 svc := NewService(store, 48*time.Hour)
52- require.NoError(t, svc.Load(context.Background()))
52+ require.NoError(t, svc.Load(t.Context()))
5353
54- err := svc.Delete(context.Background(), "feat")
54+ err := svc.Delete(t.Context(), "feat")
5555 require.NoError(t, err)
5656
57- all, err := svc.List(context.Background())
57+ all, err := svc.List(t.Context())
5858 require.NoError(t, err)
5959 assert.Empty(t, all)
6060 }
@@ -66,13 +66,13 @@ func TestPreviewService_Get(t *testing.T) {
6666 },
6767 }
6868 svc := NewService(store, 48*time.Hour)
69- require.NoError(t, svc.Load(context.Background()))
69+ require.NoError(t, svc.Load(t.Context()))
7070
71- p, err := svc.Get(context.Background(), "feat")
71+ p, err := svc.Get(t.Context(), "feat")
7272 require.NoError(t, err)
7373 assert.Equal(t, "myapp--feat.example.com", p.Domain)
7474
75- _, err = svc.Get(context.Background(), "nonexistent")
75+ _, err = svc.Get(t.Context(), "nonexistent")
7676 assert.Error(t, err)
7777 }
7878
@@ -84,12 +84,13 @@ func TestPreviewService_Extend(t *testing.T) {
8484 },
8585 }
8686 svc := NewService(store, 48*time.Hour)
87- require.NoError(t, svc.Load(context.Background()))
87+ require.NoError(t, svc.Load(t.Context()))
8888
89- err := svc.Extend(context.Background(), "feat", 24*time.Hour)
89+ err := svc.Extend(t.Context(), "feat", 24*time.Hour)
9090 require.NoError(t, err)
9191
92- p, _ := svc.Get(context.Background(), "feat")
92+ p, err := svc.Get(t.Context(), "feat")
93+ require.NoError(t, err)
9394 assert.True(t, p.ExpiresAt.After(now.Add(23*time.Hour)))
9495 }
9596
@@ -101,9 +102,41 @@ func TestPreviewService_GetExpired(t *testing.T) {
101102 },
102103 }
103104 svc := NewService(store, 48*time.Hour)
104- require.NoError(t, svc.Load(context.Background()))
105+ require.NoError(t, svc.Load(t.Context()))
105106
106107 expired := svc.GetExpired()
107108 assert.Len(t, expired, 1)
108109 assert.Equal(t, "expired", expired[0].Name)
109110 }
111+
112+func TestPreviewService_Update(t *testing.T) {
113+ store := &fakeStore{
114+ previews: []domain.PreviewRoute{
115+ {Name: "feat", Domain: "myapp--feat.example.com", Status: domain.PreviewStatusDeploying},
116+ },
117+ }
118+ svc := NewService(store, 48*time.Hour)
119+ require.NoError(t, svc.Load(t.Context()))
120+
121+ updated := domain.PreviewRoute{
122+ Name: "feat",
123+ Domain: "myapp--feat.example.com",
124+ Status: domain.PreviewStatusRunning,
125+ Containers: []string{"gordon-myapp--feat.example.com"},
126+ }
127+ err := svc.Update(t.Context(), updated)
128+ require.NoError(t, err)
129+
130+ p, err := svc.Get(t.Context(), "feat")
131+ require.NoError(t, err)
132+ assert.Equal(t, domain.PreviewStatusRunning, p.Status)
133+ assert.Equal(t, []string{"gordon-myapp--feat.example.com"}, p.Containers)
134+}
135+
136+func TestPreviewService_Update_NotFound(t *testing.T) {
137+ store := &fakeStore{}
138+ svc := NewService(store, 48*time.Hour)
139+
140+ err := svc.Update(t.Context(), domain.PreviewRoute{Name: "nonexistent"})
141+ assert.ErrorIs(t, err, domain.ErrPreviewNotFound)
142+}
@@ -33,10 +33,10 @@ func TestPreviewService_Add(t *testing.T) {
33 BaseRoute: "myapp.example.com",33 BaseRoute: "myapp.example.com",
34 Image: "myapp:preview-feat",34 Image: "myapp:preview-feat",
35 }35 }
36- err := svc.Add(context.Background(), p)36+ err := svc.Add(t.Context(), p)
37 require.NoError(t, err)37 require.NoError(t, err)
38 38
39- all, err := svc.List(context.Background())39+ all, err := svc.List(t.Context())
40 require.NoError(t, err)40 require.NoError(t, err)
41 assert.Len(t, all, 1)41 assert.Len(t, all, 1)
42 assert.Equal(t, "feat", all[0].Name)42 assert.Equal(t, "feat", all[0].Name)
@@ -49,12 +49,12 @@ func TestPreviewService_Delete(t *testing.T) {
49 },49 },
50 }50 }
51 svc := NewService(store, 48*time.Hour)51 svc := NewService(store, 48*time.Hour)
52- require.NoError(t, svc.Load(context.Background()))52+ require.NoError(t, svc.Load(t.Context()))
53 53
54- err := svc.Delete(context.Background(), "feat")54+ err := svc.Delete(t.Context(), "feat")
55 require.NoError(t, err)55 require.NoError(t, err)
56 56
57- all, err := svc.List(context.Background())57+ all, err := svc.List(t.Context())
58 require.NoError(t, err)58 require.NoError(t, err)
59 assert.Empty(t, all)59 assert.Empty(t, all)
60 }60 }
@@ -66,13 +66,13 @@ func TestPreviewService_Get(t *testing.T) {
66 },66 },
67 }67 }
68 svc := NewService(store, 48*time.Hour)68 svc := NewService(store, 48*time.Hour)
69- require.NoError(t, svc.Load(context.Background()))69+ require.NoError(t, svc.Load(t.Context()))
70 70
71- p, err := svc.Get(context.Background(), "feat")71+ p, err := svc.Get(t.Context(), "feat")
72 require.NoError(t, err)72 require.NoError(t, err)
73 assert.Equal(t, "myapp--feat.example.com", p.Domain)73 assert.Equal(t, "myapp--feat.example.com", p.Domain)
74 74
75- _, err = svc.Get(context.Background(), "nonexistent")75+ _, err = svc.Get(t.Context(), "nonexistent")
76 assert.Error(t, err)76 assert.Error(t, err)
77 }77 }
78 78
@@ -84,12 +84,13 @@ func TestPreviewService_Extend(t *testing.T) {
84 },84 },
85 }85 }
86 svc := NewService(store, 48*time.Hour)86 svc := NewService(store, 48*time.Hour)
87- require.NoError(t, svc.Load(context.Background()))87+ require.NoError(t, svc.Load(t.Context()))
88 88
89- err := svc.Extend(context.Background(), "feat", 24*time.Hour)89+ err := svc.Extend(t.Context(), "feat", 24*time.Hour)
90 require.NoError(t, err)90 require.NoError(t, err)
91 91
92- p, _ := svc.Get(context.Background(), "feat")92+ p, err := svc.Get(t.Context(), "feat")
93+ require.NoError(t, err)
93 assert.True(t, p.ExpiresAt.After(now.Add(23*time.Hour)))94 assert.True(t, p.ExpiresAt.After(now.Add(23*time.Hour)))
94 }95 }
95 96
@@ -101,9 +102,41 @@ func TestPreviewService_GetExpired(t *testing.T) {
101 },102 },
102 }103 }
103 svc := NewService(store, 48*time.Hour)104 svc := NewService(store, 48*time.Hour)
104- require.NoError(t, svc.Load(context.Background()))105+ require.NoError(t, svc.Load(t.Context()))
105 106
106 expired := svc.GetExpired()107 expired := svc.GetExpired()
107 assert.Len(t, expired, 1)108 assert.Len(t, expired, 1)
108 assert.Equal(t, "expired", expired[0].Name)109 assert.Equal(t, "expired", expired[0].Name)
109 }110 }
111+
112+func TestPreviewService_Update(t *testing.T) {
113+ store := &fakeStore{
114+ previews: []domain.PreviewRoute{
115+ {Name: "feat", Domain: "myapp--feat.example.com", Status: domain.PreviewStatusDeploying},
116+ },
117+ }
118+ svc := NewService(store, 48*time.Hour)
119+ require.NoError(t, svc.Load(t.Context()))
120+
121+ updated := domain.PreviewRoute{
122+ Name: "feat",
123+ Domain: "myapp--feat.example.com",
124+ Status: domain.PreviewStatusRunning,
125+ Containers: []string{"gordon-myapp--feat.example.com"},
126+ }
127+ err := svc.Update(t.Context(), updated)
128+ require.NoError(t, err)
129+
130+ p, err := svc.Get(t.Context(), "feat")
131+ require.NoError(t, err)
132+ assert.Equal(t, domain.PreviewStatusRunning, p.Status)
133+ assert.Equal(t, []string{"gordon-myapp--feat.example.com"}, p.Containers)
134+}
135+
136+func TestPreviewService_Update_NotFound(t *testing.T) {
137+ store := &fakeStore{}
138+ svc := NewService(store, 48*time.Hour)
139+
140+ err := svc.Update(t.Context(), domain.PreviewRoute{Name: "nonexistent"})
141+ assert.ErrorIs(t, err, domain.ErrPreviewNotFound)
142+}
M internal/usecase/auto/preview/ticker_test.go +3 -4
@@ -1,7 +1,6 @@
11 package preview
22
33 import (
4- "context"
54 "testing"
65 "time"
76
@@ -19,13 +18,13 @@ func TestPreviewService_CleanupExpired(t *testing.T) {
1918 },
2019 }
2120 svc := NewService(store, 48*time.Hour)
22- require.NoError(t, svc.Load(context.Background()))
21+ require.NoError(t, svc.Load(t.Context()))
2322
24- expired := svc.CleanupExpired(context.Background())
23+ expired := svc.CleanupExpired(t.Context())
2524 assert.Len(t, expired, 1)
2625 assert.Equal(t, "expired", expired[0].Name)
2726
28- all, err := svc.List(context.Background())
27+ all, err := svc.List(t.Context())
2928 require.NoError(t, err)
3029 assert.Len(t, all, 1)
3130 assert.Equal(t, "active", all[0].Name)
@@ -1,7 +1,6 @@
1 package preview1 package preview
2 2
3 import (3 import (
4- "context"
5 "testing"4 "testing"
6 "time"5 "time"
7 6
@@ -19,13 +18,13 @@ func TestPreviewService_CleanupExpired(t *testing.T) {
19 },18 },
20 }19 }
21 svc := NewService(store, 48*time.Hour)20 svc := NewService(store, 48*time.Hour)
22- require.NoError(t, svc.Load(context.Background()))21+ require.NoError(t, svc.Load(t.Context()))
23 22
24- expired := svc.CleanupExpired(context.Background())23+ expired := svc.CleanupExpired(t.Context())
25 assert.Len(t, expired, 1)24 assert.Len(t, expired, 1)
26 assert.Equal(t, "expired", expired[0].Name)25 assert.Equal(t, "expired", expired[0].Name)
27 26
28- all, err := svc.List(context.Background())27+ all, err := svc.List(t.Context())
29 require.NoError(t, err)28 require.NoError(t, err)
30 assert.Len(t, all, 1)29 assert.Len(t, all, 1)
31 assert.Equal(t, "active", all[0].Name)30 assert.Equal(t, "active", all[0].Name)
M internal/usecase/auto/preview/volumes.go +15 -4
@@ -16,6 +16,17 @@ type VolumeCloner interface {
1616 StartContainer(ctx context.Context, containerID string) error
1717 WaitForContainer(ctx context.Context, containerID string) error
1818 RemoveContainer(ctx context.Context, containerID string, force bool) error
19+ ListVolumes(ctx context.Context) ([]*domain.VolumeInfo, error)
20+}
21+
22+// VolumeNamer is a function that maps a source volume name to a destination volume name.
23+type VolumeNamer func(sourceVolName string) string
24+
25+// DefaultNamer returns a VolumeNamer that uses BuildCloneVolumeName with the given preview name.
26+func DefaultNamer(previewName string) VolumeNamer {
27+ return func(sourceVolName string) string {
28+ return BuildCloneVolumeName(previewName, sourceVolName)
29+ }
1930 }
2031
2132 // BuildCloneVolumeName generates a preview volume name.
@@ -36,11 +47,11 @@ func BuildCloneContainerName(previewName, image string) string {
3647 }
3748
3849 // CloneVolumes copies volumes from source using a read-only helper container.
39-// sourceVolumes maps volume display names to actual Docker volume names.
40-func CloneVolumes(ctx context.Context, cloner VolumeCloner, previewName string, sourceVolumes map[string]string) ([]string, error) {
50+// sourceVolumes is a list of actual Docker volume names to clone.
51+func CloneVolumes(ctx context.Context, cloner VolumeCloner, namer VolumeNamer, sourceVolumes []string) ([]string, error) {
4152 var clonedNames []string
42- for volName, sourceVolName := range sourceVolumes {
43- destName := BuildCloneVolumeName(previewName, volName)
53+ for _, sourceVolName := range sourceVolumes {
54+ destName := namer(sourceVolName)
4455
4556 if err := cloner.CreateVolume(ctx, destName); err != nil {
4657 cleanupVolumes(ctx, cloner, clonedNames)
@@ -16,6 +16,17 @@ type VolumeCloner interface {
16 StartContainer(ctx context.Context, containerID string) error16 StartContainer(ctx context.Context, containerID string) error
17 WaitForContainer(ctx context.Context, containerID string) error17 WaitForContainer(ctx context.Context, containerID string) error
18 RemoveContainer(ctx context.Context, containerID string, force bool) error18 RemoveContainer(ctx context.Context, containerID string, force bool) error
19+ ListVolumes(ctx context.Context) ([]*domain.VolumeInfo, error)
20+}
21+
22+// VolumeNamer is a function that maps a source volume name to a destination volume name.
23+type VolumeNamer func(sourceVolName string) string
24+
25+// DefaultNamer returns a VolumeNamer that uses BuildCloneVolumeName with the given preview name.
26+func DefaultNamer(previewName string) VolumeNamer {
27+ return func(sourceVolName string) string {
28+ return BuildCloneVolumeName(previewName, sourceVolName)
29+ }
19 }30 }
20 31
21 // BuildCloneVolumeName generates a preview volume name.32 // BuildCloneVolumeName generates a preview volume name.
@@ -36,11 +47,11 @@ func BuildCloneContainerName(previewName, image string) string {
36 }47 }
37 48
38 // CloneVolumes copies volumes from source using a read-only helper container.49 // CloneVolumes copies volumes from source using a read-only helper container.
39-// sourceVolumes maps volume display names to actual Docker volume names.50+// sourceVolumes is a list of actual Docker volume names to clone.
40-func CloneVolumes(ctx context.Context, cloner VolumeCloner, previewName string, sourceVolumes map[string]string) ([]string, error) {51+func CloneVolumes(ctx context.Context, cloner VolumeCloner, namer VolumeNamer, sourceVolumes []string) ([]string, error) {
41 var clonedNames []string52 var clonedNames []string
42- for volName, sourceVolName := range sourceVolumes {53+ for _, sourceVolName := range sourceVolumes {
43- destName := BuildCloneVolumeName(previewName, volName)54+ destName := namer(sourceVolName)
44 55
45 if err := cloner.CreateVolume(ctx, destName); err != nil {56 if err := cloner.CreateVolume(ctx, destName); err != nil {
46 cleanupVolumes(ctx, cloner, clonedNames)57 cleanupVolumes(ctx, cloner, clonedNames)
M internal/usecase/config/service.go +3 -0
@@ -38,6 +38,7 @@ type Config struct {
3838 PreviewSeparator string
3939 PreviewTagPatterns []string
4040 PreviewDataCopy bool
41+ PreviewEnvCopy bool
4142 NetworkIsolation bool
4243 NetworkPrefix string
4344 Routes map[string]string
@@ -164,6 +165,7 @@ func (s *Service) loadConfigValues() Config {
164165 PreviewSeparator: previewSep,
165166 PreviewTagPatterns: append([]string{}, s.viper.GetStringSlice("auto.preview.tag_patterns")...),
166167 PreviewDataCopy: s.viper.GetBool("auto.preview.data_copy"),
168+ PreviewEnvCopy: !s.viper.IsSet("auto.preview.env_copy") || s.viper.GetBool("auto.preview.env_copy"),
167169 NetworkIsolation: s.viper.GetBool("network_isolation.enabled"),
168170 NetworkPrefix: s.viper.GetString("network_isolation.network_prefix"),
169171 RegistryAuthEnabled: s.viper.GetBool("auth.enabled"),
@@ -1104,6 +1106,7 @@ func (s *Service) GetPreviewConfig() domain.PreviewConfig {
11041106 Separator: s.config.PreviewSeparator,
11051107 TagPatterns: s.config.PreviewTagPatterns,
11061108 DataCopy: s.config.PreviewDataCopy,
1109+ EnvCopy: s.config.PreviewEnvCopy,
11071110 }
11081111 }
11091112
@@ -38,6 +38,7 @@ type Config struct {
38 PreviewSeparator string38 PreviewSeparator string
39 PreviewTagPatterns []string39 PreviewTagPatterns []string
40 PreviewDataCopy bool40 PreviewDataCopy bool
41+ PreviewEnvCopy bool
41 NetworkIsolation bool42 NetworkIsolation bool
42 NetworkPrefix string43 NetworkPrefix string
43 Routes map[string]string44 Routes map[string]string
@@ -164,6 +165,7 @@ func (s *Service) loadConfigValues() Config {
164 PreviewSeparator: previewSep,165 PreviewSeparator: previewSep,
165 PreviewTagPatterns: append([]string{}, s.viper.GetStringSlice("auto.preview.tag_patterns")...),166 PreviewTagPatterns: append([]string{}, s.viper.GetStringSlice("auto.preview.tag_patterns")...),
166 PreviewDataCopy: s.viper.GetBool("auto.preview.data_copy"),167 PreviewDataCopy: s.viper.GetBool("auto.preview.data_copy"),
168+ PreviewEnvCopy: !s.viper.IsSet("auto.preview.env_copy") || s.viper.GetBool("auto.preview.env_copy"),
167 NetworkIsolation: s.viper.GetBool("network_isolation.enabled"),169 NetworkIsolation: s.viper.GetBool("network_isolation.enabled"),
168 NetworkPrefix: s.viper.GetString("network_isolation.network_prefix"),170 NetworkPrefix: s.viper.GetString("network_isolation.network_prefix"),
169 RegistryAuthEnabled: s.viper.GetBool("auth.enabled"),171 RegistryAuthEnabled: s.viper.GetBool("auth.enabled"),
@@ -1104,6 +1106,7 @@ func (s *Service) GetPreviewConfig() domain.PreviewConfig {
1104 Separator: s.config.PreviewSeparator,1106 Separator: s.config.PreviewSeparator,
1105 TagPatterns: s.config.PreviewTagPatterns,1107 TagPatterns: s.config.PreviewTagPatterns,
1106 DataCopy: s.config.PreviewDataCopy,1108 DataCopy: s.config.PreviewDataCopy,
1109+ EnvCopy: s.config.PreviewEnvCopy,
1107 }1110 }
1108 }1111 }
1109 1112
M internal/usecase/container/service.go +13 -7
@@ -568,7 +568,7 @@ func (s *Service) prepareDeployResources(ctx context.Context, route domain.Route
568568 imageLabels = nil
569569 }
570570
571- envVars, err := s.loadEnvironment(ctx, route.Domain, actualImageRef)
571+ envVars, err := s.loadEnvironment(ctx, route.Env, route.Domain, actualImageRef)
572572 if err != nil {
573573 return nil, err
574574 }
@@ -1774,12 +1774,18 @@ func (s *Service) tagImageIfNeeded(ctx context.Context, sourceRef, targetRef str
17741774 return nil
17751775 }
17761776
1777-func (s *Service) loadEnvironment(ctx context.Context, domainName, imageRef string) ([]string, error) {
1777+func (s *Service) loadEnvironment(ctx context.Context, preResolved []string, domainName, imageRef string) ([]string, error) {
17781778 log := zerowrap.FromCtx(ctx)
17791779
1780- userEnvVars, err := s.envLoader.LoadEnv(ctx, domainName)
1781- if err != nil {
1782- return nil, log.WrapErr(err, "failed to load environment variables")
1780+ var userEnvVars []string
1781+ if len(preResolved) > 0 {
1782+ userEnvVars = preResolved
1783+ } else {
1784+ var err error
1785+ userEnvVars, err = s.envLoader.LoadEnv(ctx, domainName)
1786+ if err != nil {
1787+ return nil, log.WrapErr(err, "failed to load environment variables")
1788+ }
17831789 }
17841790
17851791 dockerfileEnvVars, err := s.runtime.InspectImageEnv(ctx, imageRef)
@@ -2219,7 +2225,7 @@ func (s *Service) deployAttachedService(ctx context.Context, ownerDomain, servic
22192225 }
22202226
22212227 // Load environment (attachment-specific env file)
2222- envVars, err := s.loadEnvironment(ctx, containerName, actualImageRef)
2228+ envVars, err := s.loadEnvironment(ctx, nil, containerName, actualImageRef)
22232229 if err != nil {
22242230 log.WrapErr(err, "failed to load environment for attachment")
22252231 envVars = []string{}
@@ -2268,7 +2274,7 @@ func (s *Service) deployAttachedService(ctx context.Context, ownerDomain, servic
22682274 }
22692275
22702276 func (s *Service) attachmentEnvDrifted(ctx context.Context, existing *domain.Container, containerName, serviceImage string) (bool, error) {
2271- currentEnv, err := s.loadEnvironment(ctx, containerName, s.buildImageRef(serviceImage))
2277+ currentEnv, err := s.loadEnvironment(ctx, nil, containerName, s.buildImageRef(serviceImage))
22722278 if err != nil {
22732279 return false, err
22742280 }
@@ -568,7 +568,7 @@ func (s *Service) prepareDeployResources(ctx context.Context, route domain.Route
568 imageLabels = nil568 imageLabels = nil
569 }569 }
570 570
571- envVars, err := s.loadEnvironment(ctx, route.Domain, actualImageRef)571+ envVars, err := s.loadEnvironment(ctx, route.Env, route.Domain, actualImageRef)
572 if err != nil {572 if err != nil {
573 return nil, err573 return nil, err
574 }574 }
@@ -1774,12 +1774,18 @@ func (s *Service) tagImageIfNeeded(ctx context.Context, sourceRef, targetRef str
1774 return nil1774 return nil
1775 }1775 }
1776 1776
1777-func (s *Service) loadEnvironment(ctx context.Context, domainName, imageRef string) ([]string, error) {1777+func (s *Service) loadEnvironment(ctx context.Context, preResolved []string, domainName, imageRef string) ([]string, error) {
1778 log := zerowrap.FromCtx(ctx)1778 log := zerowrap.FromCtx(ctx)
1779 1779
1780- userEnvVars, err := s.envLoader.LoadEnv(ctx, domainName)1780+ var userEnvVars []string
1781- if err != nil {1781+ if len(preResolved) > 0 {
1782- return nil, log.WrapErr(err, "failed to load environment variables")1782+ userEnvVars = preResolved
1783+ } else {
1784+ var err error
1785+ userEnvVars, err = s.envLoader.LoadEnv(ctx, domainName)
1786+ if err != nil {
1787+ return nil, log.WrapErr(err, "failed to load environment variables")
1788+ }
1783 }1789 }
1784 1790
1785 dockerfileEnvVars, err := s.runtime.InspectImageEnv(ctx, imageRef)1791 dockerfileEnvVars, err := s.runtime.InspectImageEnv(ctx, imageRef)
@@ -2219,7 +2225,7 @@ func (s *Service) deployAttachedService(ctx context.Context, ownerDomain, servic
2219 }2225 }
2220 2226
2221 // Load environment (attachment-specific env file)2227 // Load environment (attachment-specific env file)
2222- envVars, err := s.loadEnvironment(ctx, containerName, actualImageRef)2228+ envVars, err := s.loadEnvironment(ctx, nil, containerName, actualImageRef)
2223 if err != nil {2229 if err != nil {
2224 log.WrapErr(err, "failed to load environment for attachment")2230 log.WrapErr(err, "failed to load environment for attachment")
2225 envVars = []string{}2231 envVars = []string{}
@@ -2268,7 +2274,7 @@ func (s *Service) deployAttachedService(ctx context.Context, ownerDomain, servic
2268 }2274 }
2269 2275
2270 func (s *Service) attachmentEnvDrifted(ctx context.Context, existing *domain.Container, containerName, serviceImage string) (bool, error) {2276 func (s *Service) attachmentEnvDrifted(ctx context.Context, existing *domain.Container, containerName, serviceImage string) (bool, error) {
2271- currentEnv, err := s.loadEnvironment(ctx, containerName, s.buildImageRef(serviceImage))2277+ currentEnv, err := s.loadEnvironment(ctx, nil, containerName, s.buildImageRef(serviceImage))
2272 if err != nil {2278 if err != nil {
2273 return false, err2279 return false, err
2274 }2280 }
M internal/usecase/container/service_test.go +41 -0
@@ -3794,3 +3794,44 @@ func TestService_Deploy_PropagatesImageLabelsToContainerConfig(t *testing.T) {
37943794 assert.Equal(t, "c-gitea-123", result.ID)
37953795 assert.Equal(t, "running", result.Status)
37963796 }
3797+
3798+func TestLoadEnvironment_PreResolvedEnv(t *testing.T) {
3799+ ctx := testContext()
3800+
3801+ mockEnvLoader := mocks.NewMockEnvLoader(t)
3802+ mockRuntime := mocks.NewMockContainerRuntime(t)
3803+
3804+ // EnvLoader should NOT be called when pre-resolved env is provided
3805+ mockRuntime.EXPECT().InspectImageEnv(ctx, "myapp:latest").
3806+ Return([]string{"FROM_DOCKERFILE=base"}, nil)
3807+
3808+ svc := NewService(mockRuntime, mockEnvLoader, mocks.NewMockEventPublisher(t), nil, Config{}, nil)
3809+
3810+ preResolved := []string{"DB_HOST=localhost", "API_KEY=secret"}
3811+ result, err := svc.loadEnvironment(ctx, preResolved, "ignored-domain", "myapp:latest")
3812+ require.NoError(t, err)
3813+
3814+ assert.Contains(t, result, "DB_HOST=localhost")
3815+ assert.Contains(t, result, "API_KEY=secret")
3816+ assert.Contains(t, result, "FROM_DOCKERFILE=base")
3817+}
3818+
3819+func TestLoadEnvironment_NoPreResolvedEnv(t *testing.T) {
3820+ ctx := testContext()
3821+
3822+ mockEnvLoader := mocks.NewMockEnvLoader(t)
3823+ mockRuntime := mocks.NewMockContainerRuntime(t)
3824+
3825+ mockEnvLoader.EXPECT().LoadEnv(ctx, "myapp.example.com").
3826+ Return([]string{"DB_HOST=prod"}, nil)
3827+ mockRuntime.EXPECT().InspectImageEnv(ctx, "myapp:latest").
3828+ Return([]string{"FROM_DOCKERFILE=base"}, nil)
3829+
3830+ svc := NewService(mockRuntime, mockEnvLoader, mocks.NewMockEventPublisher(t), nil, Config{}, nil)
3831+
3832+ result, err := svc.loadEnvironment(ctx, nil, "myapp.example.com", "myapp:latest")
3833+ require.NoError(t, err)
3834+
3835+ assert.Contains(t, result, "DB_HOST=prod")
3836+ assert.Contains(t, result, "FROM_DOCKERFILE=base")
3837+}
@@ -3794,3 +3794,44 @@ func TestService_Deploy_PropagatesImageLabelsToContainerConfig(t *testing.T) {
3794 assert.Equal(t, "c-gitea-123", result.ID)3794 assert.Equal(t, "c-gitea-123", result.ID)
3795 assert.Equal(t, "running", result.Status)3795 assert.Equal(t, "running", result.Status)
3796 }3796 }
3797+
3798+func TestLoadEnvironment_PreResolvedEnv(t *testing.T) {
3799+ ctx := testContext()
3800+
3801+ mockEnvLoader := mocks.NewMockEnvLoader(t)
3802+ mockRuntime := mocks.NewMockContainerRuntime(t)
3803+
3804+ // EnvLoader should NOT be called when pre-resolved env is provided
3805+ mockRuntime.EXPECT().InspectImageEnv(ctx, "myapp:latest").
3806+ Return([]string{"FROM_DOCKERFILE=base"}, nil)
3807+
3808+ svc := NewService(mockRuntime, mockEnvLoader, mocks.NewMockEventPublisher(t), nil, Config{}, nil)
3809+
3810+ preResolved := []string{"DB_HOST=localhost", "API_KEY=secret"}
3811+ result, err := svc.loadEnvironment(ctx, preResolved, "ignored-domain", "myapp:latest")
3812+ require.NoError(t, err)
3813+
3814+ assert.Contains(t, result, "DB_HOST=localhost")
3815+ assert.Contains(t, result, "API_KEY=secret")
3816+ assert.Contains(t, result, "FROM_DOCKERFILE=base")
3817+}
3818+
3819+func TestLoadEnvironment_NoPreResolvedEnv(t *testing.T) {
3820+ ctx := testContext()
3821+
3822+ mockEnvLoader := mocks.NewMockEnvLoader(t)
3823+ mockRuntime := mocks.NewMockContainerRuntime(t)
3824+
3825+ mockEnvLoader.EXPECT().LoadEnv(ctx, "myapp.example.com").
3826+ Return([]string{"DB_HOST=prod"}, nil)
3827+ mockRuntime.EXPECT().InspectImageEnv(ctx, "myapp:latest").
3828+ Return([]string{"FROM_DOCKERFILE=base"}, nil)
3829+
3830+ svc := NewService(mockRuntime, mockEnvLoader, mocks.NewMockEventPublisher(t), nil, Config{}, nil)
3831+
3832+ result, err := svc.loadEnvironment(ctx, nil, "myapp.example.com", "myapp:latest")
3833+ require.NoError(t, err)
3834+
3835+ assert.Contains(t, result, "DB_HOST=prod")
3836+ assert.Contains(t, result, "FROM_DOCKERFILE=base")
3837+}

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