detached b72aa77

Merge pull request #134 from bnema/feat/preview-environments

feat: ephemeral preview environments
commit
b72aa77e0a22ec5ae3be4fb3a573b08d01b6f816
author
bnema · · 2026-03-19 06:23 UTC
committer
GitHub · · 2026-03-19 06:23 UTC
merge of
a4e31e9 + c5e1989 · diff against the first parent
signature
unsigned
42 files · +3441 -292
A docs/config/preview.md +239 -0
@@ -0,0 +1,239 @@
1+# Preview Environments
2+
3+Push a branch, get a URL. Preview environments give every branch or pull request its own isolated deployment — automatically torn down when it expires.
4+
5+Gordon provisions a preview environment when a matching image tag is pushed to the registry. Each preview gets its own subdomain derived from the base route, lives for a configurable TTL, and is cleaned up automatically when the TTL expires.
6+
7+## Configuration
8+
9+```toml
10+[auto.preview]
11+enabled = true
12+ttl = "48h"
13+separator = "--"
14+tag_patterns = ["preview-*", "pr-*"]
15+data_copy = true
16+```
17+
18+## Options
19+
20+| Option | Type | Default | Description |
21+|--------|------|---------|-------------|
22+| `enabled` | bool | `false` | Enable automatic preview environment creation |
23+| `ttl` | duration string | `"48h"` | How long a preview environment lives before automatic teardown |
24+| `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+| `data_copy` | bool | `true` | Clone the production route's volumes into the preview environment on creation |
27+
28+### `ttl` Format
29+
30+The `ttl` field accepts Go duration strings:
31+
32+| Value | Meaning |
33+|-------|---------|
34+| `"24h"` | 24 hours |
35+| `"48h"` | 48 hours (default) |
36+| `"168h"` | 7 days |
37+| `"0"` | Never expire (manual deletion only) |
38+
39+### `tag_patterns` Matching
40+
41+Patterns use standard glob syntax. A tag must match at least one pattern in the list for a preview environment to be created.
42+
43+| Pattern | Matches |
44+|---------|---------|
45+| `"preview-*"` | `preview-my-feature`, `preview-fix-123` |
46+| `"pr-*"` | `pr-42`, `pr-123` |
47+| `"feat/*"` | `feat/new-api`, `feat/dark-mode` |
48+
49+## Naming Scheme
50+
51+Preview domains are derived from the base route for the deployed image. Gordon combines the base domain, the separator, and a URL-safe slug of the image tag.
52+
53+```text
54+<app><separator><preview-name><rest-of-domain>
55+```
56+
57+For example, with `separator = "--"` and base route `myapp.example.com`:
58+
59+| Image Tag | Preview Domain |
60+|-----------|----------------|
61+| `preview-my-feature` | `myapp--my-feature.example.com` |
62+| `pr-42` | `myapp--42.example.com` |
63+| `preview-fix-login` | `myapp--fix-login.example.com` |
64+
65+The tag prefix matched by `tag_patterns` is stripped to keep domains short. Slashes in tags are replaced with hyphens.
66+
67+## CLI Usage
68+
69+### List Preview Environments
70+
71+```bash
72+gordon preview list
73+```
74+
75+Shows all active preview environments, their domains, TTL remaining, and source route.
76+
77+### Create or Refresh a Preview
78+
79+Previews are created automatically on push. To manually create or reset the TTL of an existing preview:
80+
81+```bash
82+gordon preview create app.example.com --tag preview-my-feature
83+```
84+
85+### Extend a Preview
86+
87+Reset the TTL on an existing preview without redeploying:
88+
89+```bash
90+gordon preview extend myapp--my-feature.example.com
91+gordon preview extend myapp--my-feature.example.com --ttl 24h
92+```
93+
94+Without `--ttl`, the configured default TTL is applied from the current time.
95+
96+### Delete a Preview
97+
98+```bash
99+gordon preview delete myapp--my-feature.example.com
100+```
101+
102+Stops the container, removes the route, and (unless `volumes.preserve = true`) removes any cloned volumes.
103+
104+## CI Usage
105+
106+### GitHub Actions
107+
108+Use tag-based deploys to trigger preview environments automatically.
109+
110+```yaml
111+name: Preview Environment
112+
113+on:
114+ pull_request:
115+ types: [opened, synchronize]
116+
117+jobs:
118+ deploy-preview:
119+ runs-on: ubuntu-latest
120+ steps:
121+ - uses: actions/checkout@v4
122+
123+ - name: Log in to Gordon registry
124+ run: echo "${{ secrets.GORDON_TOKEN }}" | docker login ${{ vars.GORDON_REGISTRY }} -u deploy --password-stdin
125+
126+ - name: Build and push preview image
127+ env:
128+ TAG: pr-${{ github.event.pull_request.number }}
129+ run: |
130+ docker build -t ${{ vars.GORDON_REGISTRY }}/myapp:$TAG .
131+ docker push ${{ vars.GORDON_REGISTRY }}/myapp:$TAG
132+
133+ - name: Comment preview URL
134+ uses: actions/github-script@v7
135+ with:
136+ script: |
137+ github.rest.issues.createComment({
138+ issue_number: context.issue.number,
139+ owner: context.repo.owner,
140+ repo: context.repo.repo,
141+ body: `Preview deployed: https://app--${{ github.event.pull_request.number }}.example.com`
142+ })
143+```
144+
145+Configure Gordon to match the `pr-*` tag convention:
146+
147+```toml
148+[auto.preview]
149+enabled = true
150+ttl = "72h"
151+tag_patterns = ["pr-*"]
152+```
153+
154+### Cleanup on PR Close
155+
156+```yaml
157+name: Teardown Preview
158+
159+on:
160+ pull_request:
161+ types: [closed]
162+
163+jobs:
164+ teardown:
165+ runs-on: ubuntu-latest
166+ steps:
167+ - name: Delete preview environment
168+ env:
169+ PR_NUMBER: ${{ github.event.pull_request.number }}
170+ run: |
171+ gordon --server ${{ vars.GORDON_SERVER }} preview delete app--${PR_NUMBER}.example.com
172+```
173+
174+## Lifecycle
175+
176+### Creation
177+
178+When a push matches a `tag_patterns` entry:
179+
180+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 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+
185+### TTL and Automatic Teardown
186+
187+Gordon checks preview TTLs on a background schedule. When a preview expires:
188+
189+1. In-flight connections are drained
190+2. The container is stopped and removed
191+3. The route is removed from the proxy
192+4. Cloned volumes are removed (unless `volumes.preserve = true`)
193+
194+Use `gordon preview extend` to reset the timer without redeploying.
195+
196+### Volume Cloning
197+
198+When `data_copy = true`, Gordon copies the named volumes attached to the base route into fresh volumes for the preview. This gives the preview a realistic dataset without sharing state with production.
199+
200+- Cloned volumes are prefixed with the preview slug
201+- 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)
203+
204+## Example Config
205+
206+```toml
207+[server]
208+gordon_domain = "registry.example.com"
209+
210+[auto_route]
211+enabled = true
212+
213+[auto.preview]
214+enabled = true
215+ttl = "48h"
216+separator = "--"
217+tag_patterns = ["preview-*", "pr-*"]
218+data_copy = true
219+
220+[routes]
221+"app.example.com" = "myapp:latest"
222+"api.example.com" = "myapi:latest"
223+
224+[volumes]
225+auto_create = true
226+preserve = false
227+```
228+
229+With this config:
230+- Pushing `myapp:pr-99` creates `myapp--99.example.com` with cloned volumes
231+- Pushing `myapi:preview-auth-refactor` creates `myapi--auth-refactor.example.com`
232+- Both previews expire after 48 hours and volumes are removed on teardown
233+
234+## Related
235+
236+- [Routes](./routes.md)
237+- [Auto Route](./auto-route.md)
238+- [Volumes](./volumes.md)
239+- [Configuration Overview](./index.md)
M docs/deployment/gitlab-ci.md +1 -1
@@ -181,7 +181,7 @@ Gordon automatically detects the version tag from GitLab CI environment variable
181181
182182 ### Authentication Failed
183183
184-```
184+```text
185185 Error: failed to verify token
186186 ```
187187
M docs/index.md +1 -0
@@ -50,6 +50,7 @@ Gordon runs on your VPS and provides:
5050 - [Authentication](./config/auth.md) - Registry auth plus remote CLI login/token workflows
5151 - [Secrets](./config/secrets.md) - Secure credential storage
5252 - [Network Isolation](./config/network-isolation.md) - Per-app network isolation
53+- [Preview Environments](./config/preview.md) - Ephemeral per-branch deployments
5354 - [Attachments](./config/attachments.md) - Service dependencies
5455 - [Logging](./config/logging.md) - Log collection and rotation
5556 - [Environment Variables](./config/env.md) - Per-route environment configuration
A internal/adapters/in/cli/preview.go +304 -0
@@ -0,0 +1,304 @@
1+package cli
2+
3+import (
4+ "context"
5+ "fmt"
6+ "io"
7+ "os"
8+ "os/exec"
9+ "strings"
10+ "text/tabwriter"
11+ "time"
12+
13+ "github.com/spf13/cobra"
14+
15+ "github.com/bnema/gordon/internal/domain"
16+ "github.com/bnema/gordon/internal/usecase/auto/preview"
17+)
18+
19+func newPreviewCmd() *cobra.Command {
20+ cmd := &cobra.Command{
21+ Use: "preview [name]",
22+ Short: "Create or manage preview environments",
23+ Long: "Build, push, and deploy an ephemeral preview environment. Defaults to current git branch name.",
24+ }
25+
26+ createCmd := newPreviewCreateCmd()
27+
28+ cmd.AddCommand(
29+ createCmd,
30+ newPreviewListCmd(),
31+ newPreviewDeleteCmd(),
32+ newPreviewExtendCmd(),
33+ )
34+
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 cmd
41+}
42+
43+func newPreviewCreateCmd() *cobra.Command {
44+ var (
45+ ttl string
46+ noBuild bool
47+ noData bool
48+ platform string
49+ )
50+
51+ cmd := &cobra.Command{
52+ Use: "create [name]",
53+ Short: "Create a preview environment",
54+ Args: cobra.MaximumNArgs(1),
55+ RunE: func(cmd *cobra.Command, args []string) error {
56+ ctx := cmd.Context()
57+ out := cmd.OutOrStdout()
58+
59+ // Resolve preview name from arg or git branch.
60+ name, err := resolvePreviewName(ctx, args)
61+ if err != nil {
62+ return err
63+ }
64+
65+ previewTag := "preview-" + name
66+
67+ if err := cliWriteLine(out, cliRenderTitle("Preview: "+name)); err != nil {
68+ return err
69+ }
70+
71+ // Resolve control plane (remote or local) — same as push.
72+ handle, err := resolveControlPlane(configPath)
73+ if err != nil {
74+ return err
75+ }
76+ defer handle.close()
77+
78+ previewRef, err := resolvePreviewImageRef(ctx, handle.plane, out, previewTag)
79+ if err != nil {
80+ return err
81+ }
82+
83+ if err := buildAndPushPreview(ctx, out, previewRef, previewTag, platform, noBuild); err != nil {
84+ return err
85+ }
86+
87+ return printPreviewCreateSummary(out, name, ttl, noData)
88+ },
89+ }
90+
91+ cmd.Flags().StringVar(&ttl, "ttl", "", "Override TTL (e.g., 72h)")
92+ cmd.Flags().BoolVar(&noBuild, "no-build", false, "Skip image build")
93+ cmd.Flags().BoolVar(&noData, "no-data", false, "Skip volume cloning")
94+ cmd.Flags().StringVar(&platform, "platform", "linux/amd64", "Target platform for build")
95+ return cmd
96+}
97+
98+func newPreviewListCmd() *cobra.Command {
99+ var jsonOutput bool
100+ cmd := &cobra.Command{
101+ Use: "list",
102+ Short: "List active preview environments",
103+ RunE: func(cmd *cobra.Command, args []string) error {
104+ ctx := cmd.Context()
105+ out := cmd.OutOrStdout()
106+
107+ client, isRemote := GetRemoteClient()
108+ if !isRemote {
109+ return fmt.Errorf("preview list requires --remote (local preview listing is not yet supported)")
110+ }
111+
112+ previews, err := client.ListPreviews(ctx)
113+ if err != nil {
114+ return fmt.Errorf("failed to list previews: %w", err)
115+ }
116+
117+ if jsonOutput {
118+ return writeJSON(out, previews)
119+ }
120+
121+ if len(previews) == 0 {
122+ return cliWriteLine(out, cliRenderEmptyState("No active previews"))
123+ }
124+
125+ return printPreviewTable(out, previews)
126+ },
127+ }
128+ cmd.Flags().BoolVar(&jsonOutput, "json", false, "Output as JSON")
129+ return cmd
130+}
131+
132+func newPreviewDeleteCmd() *cobra.Command {
133+ return &cobra.Command{
134+ Use: "delete <name>",
135+ Short: "Delete a preview environment",
136+ Args: cobra.ExactArgs(1),
137+ RunE: func(cmd *cobra.Command, args []string) error {
138+ ctx := cmd.Context()
139+ out := cmd.OutOrStdout()
140+ name := args[0]
141+
142+ client, isRemote := GetRemoteClient()
143+ if !isRemote {
144+ return fmt.Errorf("preview delete requires --remote (local preview deletion is not yet supported)")
145+ }
146+
147+ if err := client.DeletePreview(ctx, name); err != nil {
148+ return fmt.Errorf("failed to delete preview %q: %w", name, err)
149+ }
150+
151+ return cliWriteLine(out, cliRenderSuccess(fmt.Sprintf("Preview %q deleted", name)))
152+ },
153+ }
154+}
155+
156+func newPreviewExtendCmd() *cobra.Command {
157+ var ttl string
158+ cmd := &cobra.Command{
159+ Use: "extend <name>",
160+ Short: "Extend a preview environment's TTL",
161+ Args: cobra.ExactArgs(1),
162+ RunE: func(cmd *cobra.Command, args []string) error {
163+ ctx := cmd.Context()
164+ out := cmd.OutOrStdout()
165+ name := args[0]
166+
167+ client, isRemote := GetRemoteClient()
168+ if !isRemote {
169+ return fmt.Errorf("preview extend requires --remote (local preview extend is not yet supported)")
170+ }
171+
172+ if err := client.ExtendPreview(ctx, name, ttl); err != nil {
173+ return fmt.Errorf("failed to extend preview %q: %w", name, err)
174+ }
175+
176+ return cliWriteLine(out, cliRenderSuccess(fmt.Sprintf("Preview %q extended by %s", name, ttl)))
177+ },
178+ }
179+ cmd.Flags().StringVar(&ttl, "ttl", "24h", "Additional TTL duration")
180+ return cmd
181+}
182+
183+func printPreviewTable(out io.Writer, previews []domain.PreviewRoute) error {
184+ w := tabwriter.NewWriter(out, 0, 0, 2, ' ', 0)
185+ if _, err := fmt.Fprintln(w, "NAME\tDOMAIN\tIMAGE\tCREATED\tEXPIRES"); err != nil {
186+ return err
187+ }
188+ now := time.Now()
189+ for _, p := range previews {
190+ remaining := time.Until(p.ExpiresAt).Truncate(time.Minute)
191+ expiresStr := remaining.String()
192+ if p.IsExpired(now) {
193+ expiresStr = "expired"
194+ }
195+ if _, err := fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n",
196+ p.Name,
197+ p.Domain,
198+ truncateImage(p.Image, 40),
199+ p.CreatedAt.Format(time.DateTime),
200+ expiresStr,
201+ ); err != nil {
202+ return err
203+ }
204+ }
205+ return w.Flush()
206+}
207+
208+func resolvePreviewName(ctx context.Context, args []string) (string, error) {
209+ if len(args) > 0 {
210+ name := preview.SanitizeBranchName(args[0])
211+ if name == "" {
212+ return "", fmt.Errorf("invalid preview name after sanitization: %q", args[0])
213+ }
214+ return name, nil
215+ }
216+ branch, err := detectGitBranch(ctx)
217+ if err != nil {
218+ return "", fmt.Errorf("no preview name provided and could not detect git branch: %w", err)
219+ }
220+ name := preview.SanitizeBranchName(branch)
221+ if name == "" {
222+ return "", fmt.Errorf("git branch %q produced empty preview name after sanitization", branch)
223+ }
224+ return name, nil
225+}
226+
227+func resolvePreviewImageRef(ctx context.Context, cp ControlPlane, out io.Writer, previewTag string) (string, error) {
228+ dockerfile := "Dockerfile"
229+ imageName, err := detectImageName(dockerfile)
230+ if err != nil {
231+ return "", err
232+ }
233+ if err := cliWriteLine(out, cliRenderMeta("Image:", imageName)); err != nil {
234+ return "", err
235+ }
236+
237+ registry, _, _, err := resolveRoute(ctx, cp, "", "", dockerfile)
238+ if err != nil {
239+ return "", fmt.Errorf("failed to resolve registry: %w", err)
240+ }
241+
242+ previewRef := fmt.Sprintf("%s/%s:%s", registry, imageName, previewTag)
243+ if err := cliWriteLine(out, cliRenderMeta("Tag:", previewRef)); err != nil {
244+ return "", err
245+ }
246+ return previewRef, nil
247+}
248+
249+func buildAndPushPreview(ctx context.Context, out io.Writer, previewRef, previewTag, platform string, noBuild bool) error {
250+ imageOps, err := newImageOpsFn()
251+ if err != nil {
252+ return err
253+ }
254+
255+ if !noBuild {
256+ dockerfile := "Dockerfile"
257+ if _, statErr := os.Stat(dockerfile); os.IsNotExist(statErr) {
258+ return fmt.Errorf("dockerfile not found: %s", dockerfile)
259+ }
260+ buildArgs := buildImageArgs(ctx, previewTag, platform, dockerfile, nil, previewRef, previewRef)
261+ if err := cliWriteLine(out, "\nBuilding image..."); err != nil {
262+ return err
263+ }
264+ if err := imageOps.Build(ctx, buildArgs); err != nil {
265+ return err
266+ }
267+ }
268+
269+ if err := cliWriteLine(out, "Pushing..."); err != nil {
270+ return err
271+ }
272+ if err := imageOps.Push(ctx, previewRef); err != nil {
273+ return fmt.Errorf("failed to push %s: %w", previewRef, err)
274+ }
275+ return nil
276+}
277+
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+ }
285+ if ttl != "" {
286+ if err := cliWritef(out, "Requested TTL: %s (server config controls actual TTL)\n", ttl); err != nil {
287+ return err
288+ }
289+ }
290+ if noData {
291+ if err := cliWriteLine(out, cliRenderInfo("Data copy: skipped (--no-data)")); err != nil {
292+ return err
293+ }
294+ }
295+ return nil
296+}
297+
298+func detectGitBranch(ctx context.Context) (string, error) {
299+ out, err := exec.CommandContext(ctx, "git", "rev-parse", "--abbrev-ref", "HEAD").Output() // #nosec G204
300+ if err != nil {
301+ return "", fmt.Errorf("detect git branch: %w", err)
302+ }
303+ return strings.TrimSpace(string(out)), nil
304+}
A internal/adapters/in/cli/remote/preview.go +56 -0
@@ -0,0 +1,56 @@
1+package remote
2+
3+import (
4+ "context"
5+ "fmt"
6+ "net/http"
7+ "net/url"
8+
9+ "github.com/bnema/gordon/internal/domain"
10+)
11+
12+// ListPreviews returns all active preview environments.
13+func (c *Client) ListPreviews(ctx context.Context) ([]domain.PreviewRoute, error) {
14+ resp, err := c.request(ctx, http.MethodGet, "/previews", nil)
15+ if err != nil {
16+ return nil, err
17+ }
18+
19+ var result struct {
20+ Previews []domain.PreviewRoute `json:"previews"`
21+ }
22+ if err := parseResponse(resp, &result); err != nil {
23+ return nil, err
24+ }
25+
26+ return result.Previews, nil
27+}
28+
29+// DeletePreview tears down a preview environment by name.
30+func (c *Client) DeletePreview(ctx context.Context, name string) error {
31+ if name == "" {
32+ return fmt.Errorf("preview name cannot be empty")
33+ }
34+ resp, err := c.request(ctx, http.MethodDelete, "/preview/"+url.PathEscape(name), nil)
35+ if err != nil {
36+ return err
37+ }
38+ return parseResponse(resp, nil)
39+}
40+
41+// ExtendPreview extends the TTL of a preview environment.
42+func (c *Client) ExtendPreview(ctx context.Context, name string, ttl string) error {
43+ if name == "" {
44+ return fmt.Errorf("preview name cannot be empty")
45+ }
46+ if ttl == "" {
47+ return fmt.Errorf("ttl cannot be empty")
48+ }
49+ resp, err := c.request(ctx, http.MethodPatch, "/preview/"+url.PathEscape(name), struct {
50+ TTL string `json:"ttl"`
51+ }{TTL: ttl})
52+ if err != nil {
53+ return err
54+ }
55+ return parseResponse(resp, nil)
56+}
M internal/adapters/in/cli/root.go +4 -0
@@ -145,6 +145,10 @@ Commands are organized by where they run:
145145 autorouteCmd.GroupID = groupManage
146146 rootCmd.AddCommand(autorouteCmd)
147147
148+ previewCmd := newPreviewCmd()
149+ previewCmd.GroupID = groupManage
150+ rootCmd.AddCommand(previewCmd)
151+
148152 // Client-only commands (no server needed)
149153 remotesCmd := newRemotesCmd()
150154 remotesCmd.GroupID = groupClient
M internal/adapters/in/http/admin/bootstrap_test.go +43 -7
@@ -24,7 +24,13 @@ func TestBootstrap_FullWorkflow_HappyPath(t *testing.T) {
2424 secretSvc := inmocks.NewMockSecretService(t)
2525 registrySvc := inmocks.NewMockRegistryService(t)
2626
27- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, registrySvc, nil, testLogger(), nil)
27+ handler := newTestHandler(t, func(d *HandlerDeps) {
28+ d.ConfigSvc = configSvc
29+ d.AuthSvc = authSvc
30+ d.ContainerSvc = containerSvc
31+ d.SecretSvc = secretSvc
32+ d.RegistrySvc = registrySvc
33+ })
2834
2935 payload := dto.BootstrapRequest{
3036 Domain: "app.example.com",
@@ -73,7 +79,12 @@ func TestBootstrap_Idempotent_Rerun(t *testing.T) {
7379 containerSvc := inmocks.NewMockContainerService(t)
7480 secretSvc := inmocks.NewMockSecretService(t)
7581
76- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
82+ handler := newTestHandler(t, func(d *HandlerDeps) {
83+ d.ConfigSvc = configSvc
84+ d.AuthSvc = authSvc
85+ d.ContainerSvc = containerSvc
86+ d.SecretSvc = secretSvc
87+ })
7788
7889 payload := dto.BootstrapRequest{
7990 Domain: "app.example.com",
@@ -137,7 +148,12 @@ func TestBootstrap_MissingDomain(t *testing.T) {
137148 containerSvc := inmocks.NewMockContainerService(t)
138149 secretSvc := inmocks.NewMockSecretService(t)
139150
140- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
151+ handler := newTestHandler(t, func(d *HandlerDeps) {
152+ d.ConfigSvc = configSvc
153+ d.AuthSvc = authSvc
154+ d.ContainerSvc = containerSvc
155+ d.SecretSvc = secretSvc
156+ })
141157
142158 body, err := json.Marshal(dto.BootstrapRequest{Image: "myapp:latest"})
143159 require.NoError(t, err)
@@ -158,7 +174,12 @@ func TestBootstrap_MissingImage(t *testing.T) {
158174 containerSvc := inmocks.NewMockContainerService(t)
159175 secretSvc := inmocks.NewMockSecretService(t)
160176
161- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
177+ handler := newTestHandler(t, func(d *HandlerDeps) {
178+ d.ConfigSvc = configSvc
179+ d.AuthSvc = authSvc
180+ d.ContainerSvc = containerSvc
181+ d.SecretSvc = secretSvc
182+ })
162183
163184 body, err := json.Marshal(dto.BootstrapRequest{Domain: "app.example.com"})
164185 require.NoError(t, err)
@@ -179,7 +200,12 @@ func TestBootstrap_NormalizesImageBeforeStoring(t *testing.T) {
179200 containerSvc := inmocks.NewMockContainerService(t)
180201 secretSvc := inmocks.NewMockSecretService(t)
181202
182- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
203+ handler := newTestHandler(t, func(d *HandlerDeps) {
204+ d.ConfigSvc = configSvc
205+ d.AuthSvc = authSvc
206+ d.ContainerSvc = containerSvc
207+ d.SecretSvc = secretSvc
208+ })
183209
184210 payload := dto.BootstrapRequest{Domain: "app.example.com", Image: "pitlane:v1"}
185211 configSvc.EXPECT().GetRegistryDomain().Return("reg.bnema.dev").Once()
@@ -225,7 +251,12 @@ func TestBootstrap_AddRouteValidationErrors(t *testing.T) {
225251 containerSvc := inmocks.NewMockContainerService(t)
226252 secretSvc := inmocks.NewMockSecretService(t)
227253
228- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
254+ handler := newTestHandler(t, func(d *HandlerDeps) {
255+ d.ConfigSvc = configSvc
256+ d.AuthSvc = authSvc
257+ d.ContainerSvc = containerSvc
258+ d.SecretSvc = secretSvc
259+ })
229260
230261 payload := dto.BootstrapRequest{Domain: "app.example.com", Image: "myapp:latest"}
231262 configSvc.EXPECT().GetRegistryDomain().Return("reg.bnema.dev").Once()
@@ -252,7 +283,12 @@ func TestBootstrap_PartialFailure_AttachmentError(t *testing.T) {
252283 containerSvc := inmocks.NewMockContainerService(t)
253284 secretSvc := inmocks.NewMockSecretService(t)
254285
255- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
286+ handler := newTestHandler(t, func(d *HandlerDeps) {
287+ d.ConfigSvc = configSvc
288+ d.AuthSvc = authSvc
289+ d.ContainerSvc = containerSvc
290+ d.SecretSvc = secretSvc
291+ })
256292
257293 payload := dto.BootstrapRequest{
258294 Domain: "app.example.com",
M internal/adapters/in/http/admin/handler.go +34 -35
@@ -47,6 +47,7 @@ type Handler struct {
4747 logSvc in.LogService
4848 volumeSvc in.VolumeService
4949 registrySvc registryDeployService
50+ previewSvc previewService
5051 eventBus out.EventPublisher
5152 log zerowrap.Logger
5253 }
@@ -130,46 +131,42 @@ func toDatabaseInfoResponse(db domain.DBInfo) dto.DatabaseInfo {
130131 }
131132 }
132133
133-// NewHandler creates a new admin HTTP handler.
134-func NewHandler(
135- configSvc in.ConfigService,
136- authSvc in.AuthService,
137- containerSvc in.ContainerService,
138- healthSvc in.HealthService,
139- secretSvc in.SecretService,
140- logSvc in.LogService,
141- registrySvc registryDeployService,
142- eventBus out.EventPublisher,
143- log zerowrap.Logger,
144- backupSvc in.BackupService,
145- imageSvcs ...in.ImageService,
146-) *Handler {
147- var imageSvc in.ImageService
148- if len(imageSvcs) > 0 {
149- imageSvc = imageSvcs[0]
150- }
134+// HandlerDeps contains all dependencies for the admin HTTP handler.
135+type HandlerDeps struct {
136+ ConfigSvc in.ConfigService
137+ AuthSvc in.AuthService
138+ ContainerSvc in.ContainerService
139+ HealthSvc in.HealthService
140+ SecretSvc in.SecretService
141+ LogSvc in.LogService
142+ RegistrySvc registryDeployService
143+ EventBus out.EventPublisher
144+ Log zerowrap.Logger
145+ BackupSvc in.BackupService
146+ PreviewSvc previewService
147+ ImageSvc in.ImageService
148+ VolumeSvc in.VolumeService
149+}
151150
151+// NewHandler creates a new admin HTTP handler.
152+func NewHandler(deps HandlerDeps) *Handler {
152153 return &Handler{
153- configSvc: configSvc,
154- authSvc: authSvc,
155- containerSvc: containerSvc,
156- backupSvc: backupSvc,
157- imageSvc: imageSvc,
158- healthSvc: healthSvc,
159- secretSvc: secretSvc,
160- logSvc: logSvc,
161- registrySvc: registrySvc,
162- eventBus: eventBus,
163- log: log,
154+ configSvc: deps.ConfigSvc,
155+ authSvc: deps.AuthSvc,
156+ containerSvc: deps.ContainerSvc,
157+ backupSvc: deps.BackupSvc,
158+ imageSvc: deps.ImageSvc,
159+ healthSvc: deps.HealthSvc,
160+ secretSvc: deps.SecretSvc,
161+ logSvc: deps.LogSvc,
162+ volumeSvc: deps.VolumeSvc,
163+ registrySvc: deps.RegistrySvc,
164+ previewSvc: deps.PreviewSvc,
165+ eventBus: deps.EventBus,
166+ log: deps.Log,
164167 }
165168 }
166169
167-// WithVolumeService sets the volume service on the handler.
168-func (h *Handler) WithVolumeService(volumeSvc in.VolumeService) *Handler {
169- h.volumeSvc = volumeSvc
170- return h
171-}
172-
173170 // RegisterRoutes registers the admin routes on the given mux.
174171 func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
175172 mux.HandleFunc("/admin/", h.handleAdminRoutes)
@@ -239,6 +236,8 @@ func (h *Handler) matchRoute(path string) (routeHandler, bool) {
239236 {"/images", h.handleImages},
240237 {"/logs", h.handleLogs},
241238 {"/autoroute/allowed-domains", h.handleAutoRouteAllowedDomains},
239+ {"/previews", h.handlePreviewList},
240+ {"/preview", h.handlePreviewAction},
242241 }
243242 for _, route := range prefixRoutes {
244243 if path == route.prefix || strings.HasPrefix(path, route.prefix+"/") {
M internal/adapters/in/http/admin/handler_test.go +377 -59
@@ -50,6 +50,22 @@ func ctxWithScopes(scopes ...string) context.Context {
5050 return context.WithValue(ctx, domain.ContextKeyScopes, scopes)
5151 }
5252
53+func newTestHandler(t *testing.T, opts ...func(*HandlerDeps)) *Handler {
54+ t.Helper()
55+ deps := HandlerDeps{
56+ ConfigSvc: inmocks.NewMockConfigService(t),
57+ AuthSvc: inmocks.NewMockAuthService(t),
58+ ContainerSvc: inmocks.NewMockContainerService(t),
59+ HealthSvc: inmocks.NewMockHealthService(t),
60+ SecretSvc: inmocks.NewMockSecretService(t),
61+ Log: testLogger(),
62+ }
63+ for _, opt := range opts {
64+ opt(&deps)
65+ }
66+ return NewHandler(deps)
67+}
68+
5369 // Routes endpoint tests
5470
5571 func TestHandler_RoutesGet_RequiresReadScope(t *testing.T) {
@@ -58,7 +74,13 @@ func TestHandler_RoutesGet_RequiresReadScope(t *testing.T) {
5874 containerSvc := inmocks.NewMockContainerService(t)
5975 secretSvc := inmocks.NewMockSecretService(t)
6076
61- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
77+ handler := newTestHandler(t, func(d *HandlerDeps) {
78+ d.ConfigSvc = configSvc
79+ d.AuthSvc = authSvc
80+ d.ContainerSvc = containerSvc
81+ d.SecretSvc = secretSvc
82+ d.RegistrySvc = inmocks.NewMockRegistryService(t)
83+ })
6284
6385 tests := []struct {
6486 name string
@@ -119,9 +141,13 @@ func TestHandler_RoutesPost_RequiresWriteScope(t *testing.T) {
119141 authSvc := inmocks.NewMockAuthService(t)
120142 containerSvc := inmocks.NewMockContainerService(t)
121143 secretSvc := inmocks.NewMockSecretService(t)
122- registrySvc := inmocks.NewMockRegistryService(t)
123144
124- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, registrySvc, nil, testLogger(), nil)
145+ handler := newTestHandler(t, func(d *HandlerDeps) {
146+ d.ConfigSvc = configSvc
147+ d.AuthSvc = authSvc
148+ d.ContainerSvc = containerSvc
149+ d.SecretSvc = secretSvc
150+ })
125151
126152 routeJSON := `{"domain": "app.example.com", "image": "myapp:latest"}`
127153
@@ -179,9 +205,13 @@ func TestHandler_RoutesPut_RequiresWriteScope(t *testing.T) {
179205 authSvc := inmocks.NewMockAuthService(t)
180206 containerSvc := inmocks.NewMockContainerService(t)
181207 secretSvc := inmocks.NewMockSecretService(t)
182- registrySvc := inmocks.NewMockRegistryService(t)
183208
184- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, registrySvc, nil, testLogger(), nil)
209+ handler := newTestHandler(t, func(d *HandlerDeps) {
210+ d.ConfigSvc = configSvc
211+ d.AuthSvc = authSvc
212+ d.ContainerSvc = containerSvc
213+ d.SecretSvc = secretSvc
214+ })
185215
186216 routeJSON := `{"image": "myapp:v2"}`
187217
@@ -227,7 +257,13 @@ func TestHandler_RoutesPost_AllowsRouteCreationWithoutRegistryManifest(t *testin
227257 registrySvc := inmocks.NewMockRegistryService(t)
228258 route := domain.Route{Domain: "app.example.com", Image: "myapp:latest"}
229259
230- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, registrySvc, nil, testLogger(), nil)
260+ handler := newTestHandler(t, func(d *HandlerDeps) {
261+ d.ConfigSvc = configSvc
262+ d.AuthSvc = authSvc
263+ d.ContainerSvc = containerSvc
264+ d.SecretSvc = secretSvc
265+ d.RegistrySvc = registrySvc
266+ })
231267
232268 configSvc.EXPECT().AddRoute(mock.Anything, route).Return(nil).Once()
233269
@@ -250,7 +286,13 @@ func TestHandler_RoutesPut_AllowsRouteUpdateWithoutRegistryManifest(t *testing.T
250286 registrySvc := inmocks.NewMockRegistryService(t)
251287 route := domain.Route{Domain: "app.example.com", Image: "myapp:v2"}
252288
253- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, registrySvc, nil, testLogger(), nil)
289+ handler := newTestHandler(t, func(d *HandlerDeps) {
290+ d.ConfigSvc = configSvc
291+ d.AuthSvc = authSvc
292+ d.ContainerSvc = containerSvc
293+ d.SecretSvc = secretSvc
294+ d.RegistrySvc = registrySvc
295+ })
254296
255297 configSvc.EXPECT().UpdateRoute(mock.Anything, route).Return(nil).Once()
256298
@@ -271,7 +313,12 @@ func TestHandler_AttachmentsByImageGet_OneTarget(t *testing.T) {
271313 containerSvc := inmocks.NewMockContainerService(t)
272314 secretSvc := inmocks.NewMockSecretService(t)
273315
274- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
316+ handler := newTestHandler(t, func(d *HandlerDeps) {
317+ d.ConfigSvc = configSvc
318+ d.AuthSvc = authSvc
319+ d.ContainerSvc = containerSvc
320+ d.SecretSvc = secretSvc
321+ })
275322
276323 configSvc.EXPECT().FindAttachmentTargetsByImage(mock.Anything, "postgres:16").Return([]string{"app.example.com"}).Once()
277324
@@ -291,7 +338,12 @@ func TestHandler_AttachmentsByImageGet_MultipleTargets(t *testing.T) {
291338 containerSvc := inmocks.NewMockContainerService(t)
292339 secretSvc := inmocks.NewMockSecretService(t)
293340
294- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
341+ handler := newTestHandler(t, func(d *HandlerDeps) {
342+ d.ConfigSvc = configSvc
343+ d.AuthSvc = authSvc
344+ d.ContainerSvc = containerSvc
345+ d.SecretSvc = secretSvc
346+ })
295347
296348 configSvc.EXPECT().FindAttachmentTargetsByImage(mock.Anything, "postgres").Return([]string{"app.example.com", "workers"}).Once()
297349
@@ -311,7 +363,12 @@ func TestHandler_AttachmentsByImageGet_URLDecodedImage(t *testing.T) {
311363 containerSvc := inmocks.NewMockContainerService(t)
312364 secretSvc := inmocks.NewMockSecretService(t)
313365
314- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
366+ handler := newTestHandler(t, func(d *HandlerDeps) {
367+ d.ConfigSvc = configSvc
368+ d.AuthSvc = authSvc
369+ d.ContainerSvc = containerSvc
370+ d.SecretSvc = secretSvc
371+ })
315372
316373 configSvc.EXPECT().FindAttachmentTargetsByImage(mock.Anything, "registry/org/image:tag").Return([]string{"app.example.com"}).Once()
317374
@@ -331,7 +388,12 @@ func TestHandler_AttachmentsByImageGet_EmptyResultSet(t *testing.T) {
331388 containerSvc := inmocks.NewMockContainerService(t)
332389 secretSvc := inmocks.NewMockSecretService(t)
333390
334- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
391+ handler := newTestHandler(t, func(d *HandlerDeps) {
392+ d.ConfigSvc = configSvc
393+ d.AuthSvc = authSvc
394+ d.ContainerSvc = containerSvc
395+ d.SecretSvc = secretSvc
396+ })
335397
336398 configSvc.EXPECT().FindAttachmentTargetsByImage(mock.Anything, "missing:latest").Return([]string{}).Once()
337399
@@ -351,7 +413,12 @@ func TestHandler_AttachmentsByImageGet_MissingImagePath(t *testing.T) {
351413 containerSvc := inmocks.NewMockContainerService(t)
352414 secretSvc := inmocks.NewMockSecretService(t)
353415
354- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
416+ handler := newTestHandler(t, func(d *HandlerDeps) {
417+ d.ConfigSvc = configSvc
418+ d.AuthSvc = authSvc
419+ d.ContainerSvc = containerSvc
420+ d.SecretSvc = secretSvc
421+ })
355422
356423 req := httptest.NewRequest("GET", "/admin/attachments/by-image", nil)
357424 req = req.WithContext(ctxWithScopes("admin:config:read"))
@@ -369,7 +436,12 @@ func TestHandler_AttachmentsByImageGet_InvalidEncoding(t *testing.T) {
369436 containerSvc := inmocks.NewMockContainerService(t)
370437 secretSvc := inmocks.NewMockSecretService(t)
371438
372- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
439+ handler := newTestHandler(t, func(d *HandlerDeps) {
440+ d.ConfigSvc = configSvc
441+ d.AuthSvc = authSvc
442+ d.ContainerSvc = containerSvc
443+ d.SecretSvc = secretSvc
444+ })
373445
374446 req := httptest.NewRequest("GET", "/admin/attachments/by-image/placeholder", nil)
375447 req.URL = &url.URL{Path: "/admin/attachments/by-image/%zz"}
@@ -388,7 +460,12 @@ func TestHandler_AttachmentsByImageGet_RequiresReadScope(t *testing.T) {
388460 containerSvc := inmocks.NewMockContainerService(t)
389461 secretSvc := inmocks.NewMockSecretService(t)
390462
391- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
463+ handler := newTestHandler(t, func(d *HandlerDeps) {
464+ d.ConfigSvc = configSvc
465+ d.AuthSvc = authSvc
466+ d.ContainerSvc = containerSvc
467+ d.SecretSvc = secretSvc
468+ })
392469
393470 req := httptest.NewRequest("GET", "/admin/attachments/by-image/postgres:16", nil)
394471 req = req.WithContext(ctxWithScopes("admin:routes:read"))
@@ -406,7 +483,12 @@ func TestHandler_RoutesDelete_RequiresWriteScope(t *testing.T) {
406483 containerSvc := inmocks.NewMockContainerService(t)
407484 secretSvc := inmocks.NewMockSecretService(t)
408485
409- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
486+ handler := newTestHandler(t, func(d *HandlerDeps) {
487+ d.ConfigSvc = configSvc
488+ d.AuthSvc = authSvc
489+ d.ContainerSvc = containerSvc
490+ d.SecretSvc = secretSvc
491+ })
410492
411493 tests := []struct {
412494 name string
@@ -450,7 +532,12 @@ func TestHandler_SecretsGet_RequiresReadScope(t *testing.T) {
450532 containerSvc := inmocks.NewMockContainerService(t)
451533 secretSvc := inmocks.NewMockSecretService(t)
452534
453- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
535+ handler := newTestHandler(t, func(d *HandlerDeps) {
536+ d.ConfigSvc = configSvc
537+ d.AuthSvc = authSvc
538+ d.ContainerSvc = containerSvc
539+ d.SecretSvc = secretSvc
540+ })
454541
455542 tests := []struct {
456543 name string
@@ -507,7 +594,12 @@ func TestHandler_SecretsPost_RequiresWriteScope(t *testing.T) {
507594 containerSvc := inmocks.NewMockContainerService(t)
508595 secretSvc := inmocks.NewMockSecretService(t)
509596
510- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
597+ handler := newTestHandler(t, func(d *HandlerDeps) {
598+ d.ConfigSvc = configSvc
599+ d.AuthSvc = authSvc
600+ d.ContainerSvc = containerSvc
601+ d.SecretSvc = secretSvc
602+ })
511603
512604 secretsJSON := `{"API_KEY": "secret123"}`
513605
@@ -556,7 +648,12 @@ func TestHandler_SecretsDelete_RequiresWriteScope(t *testing.T) {
556648 containerSvc := inmocks.NewMockContainerService(t)
557649 secretSvc := inmocks.NewMockSecretService(t)
558650
559- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
651+ handler := newTestHandler(t, func(d *HandlerDeps) {
652+ d.ConfigSvc = configSvc
653+ d.AuthSvc = authSvc
654+ d.ContainerSvc = containerSvc
655+ d.SecretSvc = secretSvc
656+ })
560657
561658 tests := []struct {
562659 name string
@@ -600,7 +697,12 @@ func TestHandler_AttachmentSecretsPost(t *testing.T) {
600697 containerSvc := inmocks.NewMockContainerService(t)
601698 secretSvc := inmocks.NewMockSecretService(t)
602699
603- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
700+ handler := newTestHandler(t, func(d *HandlerDeps) {
701+ d.ConfigSvc = configSvc
702+ d.AuthSvc = authSvc
703+ d.ContainerSvc = containerSvc
704+ d.SecretSvc = secretSvc
705+ })
604706
605707 secretSvc.EXPECT().SetAttachment(mock.Anything, "app.example.com", "redis", mock.AnythingOfType("map[string]string")).Return(nil)
606708
@@ -626,7 +728,12 @@ func TestHandler_AttachmentSecretsDelete(t *testing.T) {
626728 containerSvc := inmocks.NewMockContainerService(t)
627729 secretSvc := inmocks.NewMockSecretService(t)
628730
629- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
731+ handler := newTestHandler(t, func(d *HandlerDeps) {
732+ d.ConfigSvc = configSvc
733+ d.AuthSvc = authSvc
734+ d.ContainerSvc = containerSvc
735+ d.SecretSvc = secretSvc
736+ })
630737
631738 secretSvc.EXPECT().DeleteAttachment(mock.Anything, "app.example.com", "redis", "REDIS_URL").Return(nil)
632739
@@ -652,7 +759,13 @@ func TestHandler_Bootstrap_HappyPath(t *testing.T) {
652759 secretSvc := inmocks.NewMockSecretService(t)
653760 registrySvc := inmocks.NewMockRegistryService(t)
654761
655- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, registrySvc, nil, testLogger(), nil)
762+ handler := newTestHandler(t, func(d *HandlerDeps) {
763+ d.ConfigSvc = configSvc
764+ d.AuthSvc = authSvc
765+ d.ContainerSvc = containerSvc
766+ d.SecretSvc = secretSvc
767+ d.RegistrySvc = registrySvc
768+ })
656769
657770 configSvc.EXPECT().GetRegistryDomain().Return("reg.bnema.dev").Once()
658771 configSvc.EXPECT().AddRoute(mock.Anything, domain.Route{Domain: "app.example.com", Image: "reg.bnema.dev/myapp"}).Return(nil).Once()
@@ -699,7 +812,12 @@ func TestHandler_Bootstrap_RequiresPermissions(t *testing.T) {
699812 containerSvc := inmocks.NewMockContainerService(t)
700813 secretSvc := inmocks.NewMockSecretService(t)
701814
702- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
815+ handler := newTestHandler(t, func(d *HandlerDeps) {
816+ d.ConfigSvc = configSvc
817+ d.AuthSvc = authSvc
818+ d.ContainerSvc = containerSvc
819+ d.SecretSvc = secretSvc
820+ })
703821
704822 tests := []struct {
705823 name string
@@ -741,7 +859,12 @@ func TestHandler_Status_RequiresReadScope(t *testing.T) {
741859 containerSvc := inmocks.NewMockContainerService(t)
742860 secretSvc := inmocks.NewMockSecretService(t)
743861
744- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
862+ handler := newTestHandler(t, func(d *HandlerDeps) {
863+ d.ConfigSvc = configSvc
864+ d.AuthSvc = authSvc
865+ d.ContainerSvc = containerSvc
866+ d.SecretSvc = secretSvc
867+ })
745868
746869 tests := []struct {
747870 name string
@@ -795,7 +918,12 @@ func TestHandler_Config_RequiresReadScope(t *testing.T) {
795918 containerSvc := inmocks.NewMockContainerService(t)
796919 secretSvc := inmocks.NewMockSecretService(t)
797920
798- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
921+ handler := newTestHandler(t, func(d *HandlerDeps) {
922+ d.ConfigSvc = configSvc
923+ d.AuthSvc = authSvc
924+ d.ContainerSvc = containerSvc
925+ d.SecretSvc = secretSvc
926+ })
799927
800928 tests := []struct {
801929 name string
@@ -852,7 +980,12 @@ func TestHandler_Reload_RequiresWriteScope(t *testing.T) {
852980 containerSvc := inmocks.NewMockContainerService(t)
853981 secretSvc := inmocks.NewMockSecretService(t)
854982
855- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
983+ handler := newTestHandler(t, func(d *HandlerDeps) {
984+ d.ConfigSvc = configSvc
985+ d.AuthSvc = authSvc
986+ d.ContainerSvc = containerSvc
987+ d.SecretSvc = secretSvc
988+ })
856989
857990 tests := []struct {
858991 name string
@@ -906,7 +1039,12 @@ func TestHandler_RoutesGet_ReturnsRoutes(t *testing.T) {
9061039 containerSvc := inmocks.NewMockContainerService(t)
9071040 secretSvc := inmocks.NewMockSecretService(t)
9081041
909- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
1042+ handler := newTestHandler(t, func(d *HandlerDeps) {
1043+ d.ConfigSvc = configSvc
1044+ d.AuthSvc = authSvc
1045+ d.ContainerSvc = containerSvc
1046+ d.SecretSvc = secretSvc
1047+ })
9101048
9111049 expectedRoutes := []domain.Route{
9121050 {Domain: "app1.example.com", Image: "app1:latest"},
@@ -941,7 +1079,12 @@ func TestHandler_RoutesGet_SingleRoute(t *testing.T) {
9411079 containerSvc := inmocks.NewMockContainerService(t)
9421080 secretSvc := inmocks.NewMockSecretService(t)
9431081
944- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
1082+ handler := newTestHandler(t, func(d *HandlerDeps) {
1083+ d.ConfigSvc = configSvc
1084+ d.AuthSvc = authSvc
1085+ d.ContainerSvc = containerSvc
1086+ d.SecretSvc = secretSvc
1087+ })
9451088
9461089 configSvc.EXPECT().GetRoute(mock.Anything, "app.example.com").Return(&domain.Route{
9471090 Domain: "app.example.com", Image: "app:latest",
@@ -972,7 +1115,12 @@ func TestHandler_RoutesGet_NotFound(t *testing.T) {
9721115 containerSvc := inmocks.NewMockContainerService(t)
9731116 secretSvc := inmocks.NewMockSecretService(t)
9741117
975- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
1118+ handler := newTestHandler(t, func(d *HandlerDeps) {
1119+ d.ConfigSvc = configSvc
1120+ d.AuthSvc = authSvc
1121+ d.ContainerSvc = containerSvc
1122+ d.SecretSvc = secretSvc
1123+ })
9761124
9771125 configSvc.EXPECT().GetRoute(mock.Anything, "nonexistent.example.com").Return(nil, domain.ErrRouteNotFound)
9781126
@@ -1016,7 +1164,13 @@ func TestHandler_RoutesPost_MissingFields(t *testing.T) {
10161164 secretSvc := inmocks.NewMockSecretService(t)
10171165 registrySvc := inmocks.NewMockRegistryService(t)
10181166
1019- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, registrySvc, nil, testLogger(), nil)
1167+ handler := newTestHandler(t, func(d *HandlerDeps) {
1168+ d.ConfigSvc = configSvc
1169+ d.AuthSvc = authSvc
1170+ d.ContainerSvc = containerSvc
1171+ d.SecretSvc = secretSvc
1172+ d.RegistrySvc = registrySvc
1173+ })
10201174
10211175 configSvc.EXPECT().AddRoute(mock.Anything, mock.Anything).Return(tt.mockErr)
10221176
@@ -1037,7 +1191,12 @@ func TestHandler_RoutesPut_MissingDomain(t *testing.T) {
10371191 containerSvc := inmocks.NewMockContainerService(t)
10381192 secretSvc := inmocks.NewMockSecretService(t)
10391193
1040- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
1194+ handler := newTestHandler(t, func(d *HandlerDeps) {
1195+ d.ConfigSvc = configSvc
1196+ d.AuthSvc = authSvc
1197+ d.ContainerSvc = containerSvc
1198+ d.SecretSvc = secretSvc
1199+ })
10411200
10421201 req := httptest.NewRequest("PUT", "/admin/routes/", bytes.NewBufferString(`{"image": "app:latest"}`))
10431202 req = req.WithContext(ctxWithScopes("admin:routes:write"))
@@ -1054,7 +1213,12 @@ func TestHandler_RoutesDelete_MissingDomain(t *testing.T) {
10541213 containerSvc := inmocks.NewMockContainerService(t)
10551214 secretSvc := inmocks.NewMockSecretService(t)
10561215
1057- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
1216+ handler := newTestHandler(t, func(d *HandlerDeps) {
1217+ d.ConfigSvc = configSvc
1218+ d.AuthSvc = authSvc
1219+ d.ContainerSvc = containerSvc
1220+ d.SecretSvc = secretSvc
1221+ })
10581222
10591223 req := httptest.NewRequest("DELETE", "/admin/routes/", nil)
10601224 req = req.WithContext(ctxWithScopes("admin:routes:write"))
@@ -1071,7 +1235,12 @@ func TestHandler_Secrets_MissingDomain(t *testing.T) {
10711235 containerSvc := inmocks.NewMockContainerService(t)
10721236 secretSvc := inmocks.NewMockSecretService(t)
10731237
1074- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
1238+ handler := newTestHandler(t, func(d *HandlerDeps) {
1239+ d.ConfigSvc = configSvc
1240+ d.AuthSvc = authSvc
1241+ d.ContainerSvc = containerSvc
1242+ d.SecretSvc = secretSvc
1243+ })
10751244
10761245 req := httptest.NewRequest("GET", "/admin/secrets/", nil)
10771246 req = req.WithContext(ctxWithScopes("admin:secrets:read"))
@@ -1088,7 +1257,12 @@ func TestHandler_SecretsDelete_MissingKey(t *testing.T) {
10881257 containerSvc := inmocks.NewMockContainerService(t)
10891258 secretSvc := inmocks.NewMockSecretService(t)
10901259
1091- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
1260+ handler := newTestHandler(t, func(d *HandlerDeps) {
1261+ d.ConfigSvc = configSvc
1262+ d.AuthSvc = authSvc
1263+ d.ContainerSvc = containerSvc
1264+ d.SecretSvc = secretSvc
1265+ })
10921266
10931267 req := httptest.NewRequest("DELETE", "/admin/secrets/app.example.com", nil)
10941268 req = req.WithContext(ctxWithScopes("admin:secrets:write"))
@@ -1105,7 +1279,12 @@ func TestHandler_MethodNotAllowed(t *testing.T) {
11051279 containerSvc := inmocks.NewMockContainerService(t)
11061280 secretSvc := inmocks.NewMockSecretService(t)
11071281
1108- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
1282+ handler := newTestHandler(t, func(d *HandlerDeps) {
1283+ d.ConfigSvc = configSvc
1284+ d.AuthSvc = authSvc
1285+ d.ContainerSvc = containerSvc
1286+ d.SecretSvc = secretSvc
1287+ })
11091288
11101289 tests := []struct {
11111290 method string
@@ -1138,7 +1317,12 @@ func TestHandler_Restart_InvalidDomain(t *testing.T) {
11381317 containerSvc := inmocks.NewMockContainerService(t)
11391318 secretSvc := inmocks.NewMockSecretService(t)
11401319
1141- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
1320+ handler := newTestHandler(t, func(d *HandlerDeps) {
1321+ d.ConfigSvc = configSvc
1322+ d.AuthSvc = authSvc
1323+ d.ContainerSvc = containerSvc
1324+ d.SecretSvc = secretSvc
1325+ })
11421326
11431327 tests := []struct {
11441328 name string
@@ -1168,7 +1352,12 @@ func TestHandler_Restart_ContainerNotFound(t *testing.T) {
11681352 containerSvc := inmocks.NewMockContainerService(t)
11691353 secretSvc := inmocks.NewMockSecretService(t)
11701354
1171- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
1355+ handler := newTestHandler(t, func(d *HandlerDeps) {
1356+ d.ConfigSvc = configSvc
1357+ d.AuthSvc = authSvc
1358+ d.ContainerSvc = containerSvc
1359+ d.SecretSvc = secretSvc
1360+ })
11721361
11731362 containerSvc.EXPECT().Restart(mock.Anything, "nonexistent.example.com", false).Return(domain.ErrContainerNotFound)
11741363
@@ -1187,7 +1376,12 @@ func TestHandler_Restart_GenericError(t *testing.T) {
11871376 containerSvc := inmocks.NewMockContainerService(t)
11881377 secretSvc := inmocks.NewMockSecretService(t)
11891378
1190- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
1379+ handler := newTestHandler(t, func(d *HandlerDeps) {
1380+ d.ConfigSvc = configSvc
1381+ d.AuthSvc = authSvc
1382+ d.ContainerSvc = containerSvc
1383+ d.SecretSvc = secretSvc
1384+ })
11911385
11921386 containerSvc.EXPECT().Restart(mock.Anything, "test.example.com", false).Return(fmt.Errorf("docker daemon error: secret details"))
11931387
@@ -1209,7 +1403,12 @@ func TestHandler_Restart_Success(t *testing.T) {
12091403 containerSvc := inmocks.NewMockContainerService(t)
12101404 secretSvc := inmocks.NewMockSecretService(t)
12111405
1212- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
1406+ handler := newTestHandler(t, func(d *HandlerDeps) {
1407+ d.ConfigSvc = configSvc
1408+ d.AuthSvc = authSvc
1409+ d.ContainerSvc = containerSvc
1410+ d.SecretSvc = secretSvc
1411+ })
12131412
12141413 tests := []struct {
12151414 name string
@@ -1256,7 +1455,12 @@ func TestHandler_Tags_InvalidRepository(t *testing.T) {
12561455 containerSvc := inmocks.NewMockContainerService(t)
12571456 secretSvc := inmocks.NewMockSecretService(t)
12581457
1259- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
1458+ handler := newTestHandler(t, func(d *HandlerDeps) {
1459+ d.ConfigSvc = configSvc
1460+ d.AuthSvc = authSvc
1461+ d.ContainerSvc = containerSvc
1462+ d.SecretSvc = secretSvc
1463+ })
12601464
12611465 tests := []struct {
12621466 name string
@@ -1287,7 +1491,13 @@ func TestHandler_Tags_Success(t *testing.T) {
12871491 secretSvc := inmocks.NewMockSecretService(t)
12881492 registrySvc := inmocks.NewMockRegistryService(t)
12891493
1290- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, registrySvc, nil, testLogger(), nil)
1494+ handler := newTestHandler(t, func(d *HandlerDeps) {
1495+ d.ConfigSvc = configSvc
1496+ d.AuthSvc = authSvc
1497+ d.ContainerSvc = containerSvc
1498+ d.SecretSvc = secretSvc
1499+ d.RegistrySvc = registrySvc
1500+ })
12911501
12921502 registrySvc.EXPECT().ListTags(mock.Anything, "myapp").Return([]string{"latest", "v1.0"}, nil)
12931503
@@ -1314,7 +1524,13 @@ func TestHandler_Tags_Error(t *testing.T) {
13141524 secretSvc := inmocks.NewMockSecretService(t)
13151525 registrySvc := inmocks.NewMockRegistryService(t)
13161526
1317- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, registrySvc, nil, testLogger(), nil)
1527+ handler := newTestHandler(t, func(d *HandlerDeps) {
1528+ d.ConfigSvc = configSvc
1529+ d.AuthSvc = authSvc
1530+ d.ContainerSvc = containerSvc
1531+ d.SecretSvc = secretSvc
1532+ d.RegistrySvc = registrySvc
1533+ })
13181534
13191535 registrySvc.EXPECT().ListTags(mock.Anything, "myapp").Return(nil, fmt.Errorf("registry error"))
13201536
@@ -1334,7 +1550,12 @@ func TestHandler_AttachmentSecretsPost_RequiresWriteScope(t *testing.T) {
13341550 containerSvc := inmocks.NewMockContainerService(t)
13351551 secretSvc := inmocks.NewMockSecretService(t)
13361552
1337- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
1553+ handler := newTestHandler(t, func(d *HandlerDeps) {
1554+ d.ConfigSvc = configSvc
1555+ d.AuthSvc = authSvc
1556+ d.ContainerSvc = containerSvc
1557+ d.SecretSvc = secretSvc
1558+ })
13381559
13391560 body := `{"REDIS_URL": "redis://localhost:6379"}`
13401561 req := httptest.NewRequest("POST", "/admin/secrets/app.example.com/attachments/redis", bytes.NewBufferString(body))
@@ -1352,7 +1573,12 @@ func TestHandler_AttachmentSecretsDelete_RequiresWriteScope(t *testing.T) {
13521573 containerSvc := inmocks.NewMockContainerService(t)
13531574 secretSvc := inmocks.NewMockSecretService(t)
13541575
1355- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
1576+ handler := newTestHandler(t, func(d *HandlerDeps) {
1577+ d.ConfigSvc = configSvc
1578+ d.AuthSvc = authSvc
1579+ d.ContainerSvc = containerSvc
1580+ d.SecretSvc = secretSvc
1581+ })
13561582
13571583 req := httptest.NewRequest("DELETE", "/admin/secrets/app.example.com/attachments/redis/REDIS_URL", nil)
13581584 req = req.WithContext(ctxWithScopes("admin:secrets:read"))
@@ -1369,7 +1595,12 @@ func TestHandler_AttachmentSecretsDelete_RequiresKey(t *testing.T) {
13691595 containerSvc := inmocks.NewMockContainerService(t)
13701596 secretSvc := inmocks.NewMockSecretService(t)
13711597
1372- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
1598+ handler := newTestHandler(t, func(d *HandlerDeps) {
1599+ d.ConfigSvc = configSvc
1600+ d.AuthSvc = authSvc
1601+ d.ContainerSvc = containerSvc
1602+ d.SecretSvc = secretSvc
1603+ })
13731604
13741605 req := httptest.NewRequest("DELETE", "/admin/secrets/app.example.com/attachments/redis", nil)
13751606 req = req.WithContext(ctxWithScopes("admin:secrets:write"))
@@ -1387,7 +1618,12 @@ func TestHandler_AttachmentSecretsPost_InvalidJSON(t *testing.T) {
13871618 containerSvc := inmocks.NewMockContainerService(t)
13881619 secretSvc := inmocks.NewMockSecretService(t)
13891620
1390- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
1621+ handler := newTestHandler(t, func(d *HandlerDeps) {
1622+ d.ConfigSvc = configSvc
1623+ d.AuthSvc = authSvc
1624+ d.ContainerSvc = containerSvc
1625+ d.SecretSvc = secretSvc
1626+ })
13911627
13921628 body := `{not valid json`
13931629 req := httptest.NewRequest("POST", "/admin/secrets/app.example.com/attachments/redis", bytes.NewBufferString(body))
@@ -1406,7 +1642,12 @@ func TestHandler_NotFound(t *testing.T) {
14061642 containerSvc := inmocks.NewMockContainerService(t)
14071643 secretSvc := inmocks.NewMockSecretService(t)
14081644
1409- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
1645+ handler := newTestHandler(t, func(d *HandlerDeps) {
1646+ d.ConfigSvc = configSvc
1647+ d.AuthSvc = authSvc
1648+ d.ContainerSvc = containerSvc
1649+ d.SecretSvc = secretSvc
1650+ })
14101651
14111652 req := httptest.NewRequest("GET", "/admin/unknown", nil)
14121653 req = req.WithContext(ctxWithScopes("admin:*:*"))
@@ -1426,7 +1667,13 @@ func TestHandler_BackupsStatus(t *testing.T) {
14261667
14271668 backupSvc.EXPECT().Status(mock.Anything).Return([]domain.BackupJob{{Domain: "app.example.com", DBName: "postgres", Status: domain.BackupStatusCompleted, FilePath: "/var/lib/gordon/backups/private.bak"}}, nil)
14281669
1429- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), backupSvc)
1670+ handler := newTestHandler(t, func(d *HandlerDeps) {
1671+ d.ConfigSvc = configSvc
1672+ d.AuthSvc = authSvc
1673+ d.ContainerSvc = containerSvc
1674+ d.SecretSvc = secretSvc
1675+ d.BackupSvc = backupSvc
1676+ })
14301677
14311678 req := httptest.NewRequest("GET", "/admin/backups/status", nil)
14321679 req = req.WithContext(ctxWithScopes("admin:status:read"))
@@ -1448,7 +1695,13 @@ func TestHandler_BackupsListDomain(t *testing.T) {
14481695
14491696 backupSvc.EXPECT().ListBackups(mock.Anything, "app.example.com").Return([]domain.BackupJob{{Domain: "app.example.com", DBName: "postgres", Status: domain.BackupStatusCompleted}}, nil)
14501697
1451- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), backupSvc)
1698+ handler := newTestHandler(t, func(d *HandlerDeps) {
1699+ d.ConfigSvc = configSvc
1700+ d.AuthSvc = authSvc
1701+ d.ContainerSvc = containerSvc
1702+ d.SecretSvc = secretSvc
1703+ d.BackupSvc = backupSvc
1704+ })
14521705
14531706 req := httptest.NewRequest("GET", "/admin/backups/app.example.com", nil)
14541707 req = req.WithContext(ctxWithScopes("admin:status:read"))
@@ -1469,7 +1722,13 @@ func TestHandler_BackupsRunDomain(t *testing.T) {
14691722
14701723 backupSvc.EXPECT().RunBackup(mock.Anything, "app.example.com", "postgres").Return(&domain.BackupResult{Job: domain.BackupJob{Domain: "app.example.com", DBName: "postgres", Status: domain.BackupStatusCompleted}}, nil)
14711724
1472- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), backupSvc)
1725+ handler := newTestHandler(t, func(d *HandlerDeps) {
1726+ d.ConfigSvc = configSvc
1727+ d.AuthSvc = authSvc
1728+ d.ContainerSvc = containerSvc
1729+ d.SecretSvc = secretSvc
1730+ d.BackupSvc = backupSvc
1731+ })
14731732
14741733 body := bytes.NewBufferString(`{"db":"postgres"}`)
14751734 req := httptest.NewRequest("POST", "/admin/backups/app.example.com", body)
@@ -1491,7 +1750,13 @@ func TestHandler_BackupsRunDomain_ChunkedBodyIsDecoded(t *testing.T) {
14911750
14921751 backupSvc.EXPECT().RunBackup(mock.Anything, "app.example.com", "postgres").Return(&domain.BackupResult{Job: domain.BackupJob{Domain: "app.example.com", DBName: "postgres", Status: domain.BackupStatusCompleted}}, nil)
14931752
1494- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), backupSvc)
1753+ handler := newTestHandler(t, func(d *HandlerDeps) {
1754+ d.ConfigSvc = configSvc
1755+ d.AuthSvc = authSvc
1756+ d.ContainerSvc = containerSvc
1757+ d.SecretSvc = secretSvc
1758+ d.BackupSvc = backupSvc
1759+ })
14951760
14961761 body := bytes.NewBufferString(`{"db":"postgres"}`)
14971762 req := httptest.NewRequest("POST", "/admin/backups/app.example.com", body)
@@ -1514,7 +1779,13 @@ func TestHandler_BackupsDetectDomain(t *testing.T) {
15141779
15151780 backupSvc.EXPECT().DetectDatabases(mock.Anything, "app.example.com").Return([]domain.DBInfo{{Type: domain.DBTypePostgreSQL, Name: "postgres", Port: 5432}}, nil)
15161781
1517- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), backupSvc)
1782+ handler := newTestHandler(t, func(d *HandlerDeps) {
1783+ d.ConfigSvc = configSvc
1784+ d.AuthSvc = authSvc
1785+ d.ContainerSvc = containerSvc
1786+ d.SecretSvc = secretSvc
1787+ d.BackupSvc = backupSvc
1788+ })
15181789
15191790 req := httptest.NewRequest("GET", "/admin/backups/app.example.com/detect", nil)
15201791 req = req.WithContext(ctxWithScopes("admin:status:read"))
@@ -1556,7 +1827,13 @@ func TestHandler_ImagesGet_ReturnsMappedList(t *testing.T) {
15561827 },
15571828 }
15581829
1559- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil, imageSvc)
1830+ handler := newTestHandler(t, func(d *HandlerDeps) {
1831+ d.ConfigSvc = configSvc
1832+ d.AuthSvc = authSvc
1833+ d.ContainerSvc = containerSvc
1834+ d.SecretSvc = secretSvc
1835+ d.ImageSvc = imageSvc
1836+ })
15601837
15611838 req := httptest.NewRequest("GET", "/admin/images", nil)
15621839 req = req.WithContext(ctxWithScopes("admin:status:read"))
@@ -1617,7 +1894,13 @@ func TestHandler_ImagesPrune_AcceptsOptionalKeepLast(t *testing.T) {
16171894 },
16181895 }
16191896
1620- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil, imageSvc)
1897+ handler := newTestHandler(t, func(d *HandlerDeps) {
1898+ d.ConfigSvc = configSvc
1899+ d.AuthSvc = authSvc
1900+ d.ContainerSvc = containerSvc
1901+ d.SecretSvc = secretSvc
1902+ d.ImageSvc = imageSvc
1903+ })
16211904
16221905 req := httptest.NewRequest("POST", "/admin/images/prune", bytes.NewBufferString(tt.body))
16231906 req = req.WithContext(ctxWithScopes("admin:config:write"))
@@ -1646,7 +1929,13 @@ func TestHandler_Images_ErrorMappingForServiceFailures(t *testing.T) {
16461929 },
16471930 }
16481931
1649- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil, imageSvc)
1932+ handler := newTestHandler(t, func(d *HandlerDeps) {
1933+ d.ConfigSvc = configSvc
1934+ d.AuthSvc = authSvc
1935+ d.ContainerSvc = containerSvc
1936+ d.SecretSvc = secretSvc
1937+ d.ImageSvc = imageSvc
1938+ })
16501939
16511940 req := httptest.NewRequest("GET", "/admin/images", nil)
16521941 req = req.WithContext(ctxWithScopes("admin:status:read"))
@@ -1671,7 +1960,13 @@ func TestHandler_Images_ErrorMappingForServiceFailures(t *testing.T) {
16711960 },
16721961 }
16731962
1674- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil, imageSvc)
1963+ handler := newTestHandler(t, func(d *HandlerDeps) {
1964+ d.ConfigSvc = configSvc
1965+ d.AuthSvc = authSvc
1966+ d.ContainerSvc = containerSvc
1967+ d.SecretSvc = secretSvc
1968+ d.ImageSvc = imageSvc
1969+ })
16751970
16761971 req := httptest.NewRequest("POST", "/admin/images/prune", bytes.NewBufferString(`{"keep_last": 2}`))
16771972 req = req.WithContext(ctxWithScopes("admin:config:write"))
@@ -1692,7 +1987,13 @@ func TestHandler_ImagesPrune_ValidationAndAvailability(t *testing.T) {
16921987 containerSvc := inmocks.NewMockContainerService(t)
16931988 secretSvc := inmocks.NewMockSecretService(t)
16941989
1695- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil, &stubImageService{})
1990+ handler := newTestHandler(t, func(d *HandlerDeps) {
1991+ d.ConfigSvc = configSvc
1992+ d.AuthSvc = authSvc
1993+ d.ContainerSvc = containerSvc
1994+ d.SecretSvc = secretSvc
1995+ d.ImageSvc = &stubImageService{}
1996+ })
16961997
16971998 req := httptest.NewRequest("POST", "/admin/images/prune", bytes.NewBufferString(`{"keep_last": -1}`))
16981999 req = req.WithContext(ctxWithScopes("admin:config:write"))
@@ -1710,7 +2011,12 @@ func TestHandler_ImagesPrune_ValidationAndAvailability(t *testing.T) {
17102011 containerSvc := inmocks.NewMockContainerService(t)
17112012 secretSvc := inmocks.NewMockSecretService(t)
17122013
1713- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
2014+ handler := newTestHandler(t, func(d *HandlerDeps) {
2015+ d.ConfigSvc = configSvc
2016+ d.AuthSvc = authSvc
2017+ d.ContainerSvc = containerSvc
2018+ d.SecretSvc = secretSvc
2019+ })
17142020
17152021 req := httptest.NewRequest("GET", "/admin/images", nil)
17162022 req = req.WithContext(ctxWithScopes("admin:status:read"))
@@ -1781,7 +2087,13 @@ func TestHandler_ImagesPrune_ScopeDefaults(t *testing.T) {
17812087 },
17822088 }
17832089
1784- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil, imageSvc)
2090+ handler := newTestHandler(t, func(d *HandlerDeps) {
2091+ d.ConfigSvc = configSvc
2092+ d.AuthSvc = authSvc
2093+ d.ContainerSvc = containerSvc
2094+ d.SecretSvc = secretSvc
2095+ d.ImageSvc = imageSvc
2096+ })
17852097
17862098 req := httptest.NewRequest("POST", "/admin/images/prune", bytes.NewBufferString(tt.body))
17872099 req = req.WithContext(ctxWithScopes("admin:config:write"))
@@ -1806,7 +2118,13 @@ func TestHandler_Images_Authorization(t *testing.T) {
18062118 containerSvc := inmocks.NewMockContainerService(t)
18072119 secretSvc := inmocks.NewMockSecretService(t)
18082120
1809- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil, &stubImageService{})
2121+ handler := newTestHandler(t, func(d *HandlerDeps) {
2122+ d.ConfigSvc = configSvc
2123+ d.AuthSvc = authSvc
2124+ d.ContainerSvc = containerSvc
2125+ d.SecretSvc = secretSvc
2126+ d.ImageSvc = &stubImageService{}
2127+ })
18102128
18112129 t.Run("images list requires status:read", func(t *testing.T) {
18122130 req := httptest.NewRequest("GET", "/admin/images", nil)
A internal/adapters/in/http/admin/preview_handler.go +149 -0
@@ -0,0 +1,149 @@
1+package admin
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "errors"
7+ "net/http"
8+ "strings"
9+ "time"
10+
11+ "github.com/bnema/zerowrap"
12+
13+ "github.com/bnema/gordon/internal/domain"
14+)
15+
16+// previewService is a minimal interface for preview management used by the admin handler.
17+type previewService interface {
18+ List(ctx context.Context) ([]domain.PreviewRoute, error)
19+ Delete(ctx context.Context, name string) error
20+ Extend(ctx context.Context, name string, ttl time.Duration) error
21+}
22+
23+// previewListResponse is the JSON payload returned by GET /admin/previews.
24+type previewListResponse struct {
25+ Previews []domain.PreviewRoute `json:"previews"`
26+}
27+
28+// previewExtendRequest is the JSON body for PATCH /admin/preview/{name}.
29+type previewExtendRequest struct {
30+ TTL string `json:"ttl"` // e.g. "24h", "30m"
31+}
32+
33+// handlePreviewList handles GET /admin/previews.
34+// Returns a JSON array of all active preview environments.
35+func (h *Handler) handlePreviewList(w http.ResponseWriter, r *http.Request, _ string) {
36+ if r.Method != http.MethodGet {
37+ h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
38+ return
39+ }
40+
41+ ctx := r.Context()
42+
43+ if !HasAccess(ctx, domain.AdminResourceStatus, domain.AdminActionRead) {
44+ h.sendError(w, http.StatusForbidden, "insufficient permissions for status:read")
45+ return
46+ }
47+
48+ if h.previewSvc == nil {
49+ h.sendError(w, http.StatusServiceUnavailable, "preview service not available")
50+ return
51+ }
52+
53+ previews, err := h.previewSvc.List(ctx)
54+ if err != nil {
55+ log := zerowrap.FromCtx(ctx)
56+ log.Error().Err(err).Msg("failed to list previews")
57+ h.sendError(w, http.StatusInternalServerError, "failed to list previews")
58+ return
59+ }
60+
61+ if previews == nil {
62+ previews = []domain.PreviewRoute{}
63+ }
64+
65+ h.sendJSON(w, http.StatusOK, previewListResponse{Previews: previews})
66+}
67+
68+// handlePreviewAction handles DELETE and PATCH on /admin/preview/{name}.
69+//
70+// DELETE /admin/preview/{name} — tear down the preview
71+// PATCH /admin/preview/{name} — extend TTL (body: {"ttl":"24h"})
72+func (h *Handler) handlePreviewAction(w http.ResponseWriter, r *http.Request, path string) {
73+ ctx := r.Context()
74+ log := zerowrap.FromCtx(ctx)
75+
76+ if h.previewSvc == nil {
77+ h.sendError(w, http.StatusServiceUnavailable, "preview service not available")
78+ return
79+ }
80+
81+ name := strings.TrimPrefix(path, "/preview/")
82+ if name == "" || name == "/preview" {
83+ h.sendError(w, http.StatusBadRequest, "preview name required in path")
84+ return
85+ }
86+
87+ switch r.Method {
88+ case http.MethodDelete:
89+ if !HasAccess(ctx, domain.AdminResourceConfig, domain.AdminActionWrite) {
90+ h.sendError(w, http.StatusForbidden, "insufficient permissions for config:write")
91+ return
92+ }
93+
94+ if err := h.previewSvc.Delete(ctx, name); err != nil {
95+ log.Error().Err(err).Str("name", name).Msg("failed to delete preview")
96+ if errors.Is(err, domain.ErrPreviewNotFound) {
97+ h.sendError(w, http.StatusNotFound, "preview not found")
98+ } else {
99+ h.sendError(w, http.StatusInternalServerError, "failed to delete preview")
100+ }
101+ return
102+ }
103+
104+ log.Info().Str("name", name).Msg("preview deleted via admin API")
105+ h.sendJSON(w, http.StatusOK, map[string]string{"status": "deleted", "name": name})
106+
107+ case http.MethodPatch:
108+ if !HasAccess(ctx, domain.AdminResourceConfig, domain.AdminActionWrite) {
109+ h.sendError(w, http.StatusForbidden, "insufficient permissions for config:write")
110+ return
111+ }
112+
113+ r.Body = http.MaxBytesReader(w, r.Body, maxAdminRequestSize)
114+
115+ var req previewExtendRequest
116+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
117+ log.Warn().Err(err).Msg("invalid preview extend JSON")
118+ h.sendError(w, http.StatusBadRequest, "invalid JSON")
119+ return
120+ }
121+
122+ if req.TTL == "" {
123+ h.sendError(w, http.StatusBadRequest, "ttl is required")
124+ return
125+ }
126+
127+ ttl, err := time.ParseDuration(req.TTL)
128+ if err != nil {
129+ h.sendError(w, http.StatusBadRequest, "invalid ttl: use Go duration format, e.g. 24h or 30m")
130+ return
131+ }
132+
133+ if err := h.previewSvc.Extend(ctx, name, ttl); err != nil {
134+ log.Error().Err(err).Str("name", name).Dur("ttl", ttl).Msg("failed to extend preview TTL")
135+ if errors.Is(err, domain.ErrPreviewNotFound) {
136+ h.sendError(w, http.StatusNotFound, "preview not found")
137+ } else {
138+ h.sendError(w, http.StatusInternalServerError, "failed to extend preview")
139+ }
140+ return
141+ }
142+
143+ log.Info().Str("name", name).Dur("ttl", ttl).Msg("preview TTL extended via admin API")
144+ h.sendJSON(w, http.StatusOK, map[string]string{"status": "extended", "name": name, "ttl": req.TTL})
145+
146+ default:
147+ h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
148+ }
149+}
M internal/adapters/out/docker/runtime.go +30 -0
@@ -142,6 +142,13 @@ func (r *Runtime) CreateContainer(ctx context.Context, config *domain.ContainerC
142142 log.Debug().Str("volume", volumeName).Str("mount_path", containerPath).Msg("adding volume mount")
143143 }
144144 }
145+ if config.ReadOnlyVolumes != nil {
146+ for containerPath, volumeName := range config.ReadOnlyVolumes {
147+ bind := fmt.Sprintf("%s:%s:ro", volumeName, containerPath)
148+ binds = append(binds, bind)
149+ log.Debug().Str("volume", volumeName).Str("mount_path", containerPath).Msg("adding read-only volume mount")
150+ }
151+ }
145152
146153 // Create container configuration
147154 containerConfig := &container.Config{
@@ -207,6 +214,29 @@ func (r *Runtime) StartContainer(ctx context.Context, containerID string) error
207214 return nil
208215 }
209216
217+// WaitForContainer waits for a container to stop running.
218+func (r *Runtime) WaitForContainer(ctx context.Context, containerID string) error {
219+ ctx = zerowrap.CtxWithFields(ctx, map[string]any{
220+ zerowrap.FieldLayer: "adapter",
221+ zerowrap.FieldAdapter: "docker",
222+ zerowrap.FieldAction: "WaitForContainer",
223+ zerowrap.FieldEntityID: containerID,
224+ })
225+ log := zerowrap.FromCtx(ctx)
226+
227+ statusCh, errCh := r.client.ContainerWait(ctx, containerID, container.WaitConditionNotRunning)
228+ select {
229+ case err := <-errCh:
230+ if err != nil {
231+ return log.WrapErr(err, "failed to wait for container")
232+ }
233+ case <-statusCh:
234+ }
235+
236+ log.Debug().Msg("container wait completed")
237+ return nil
238+}
239+
210240 // StopContainer stops a container.
211241 func (r *Runtime) StopContainer(ctx context.Context, containerID string) error {
212242 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
A internal/adapters/out/filesystem/previewstore.go +92 -0
@@ -0,0 +1,92 @@
1+package filesystem
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "errors"
7+ "io/fs"
8+ "os"
9+ "path/filepath"
10+ "sync"
11+
12+ "github.com/bnema/gordon/internal/domain"
13+)
14+
15+type previewStoreData struct {
16+ Previews []domain.PreviewRoute `json:"previews"`
17+}
18+
19+// PreviewStore persists preview routes to a JSON file.
20+type PreviewStore struct {
21+ path string
22+ mu sync.Mutex
23+}
24+
25+// NewPreviewStore creates a new filesystem-backed preview store.
26+func NewPreviewStore(path string) *PreviewStore {
27+ return &PreviewStore{path: path}
28+}
29+
30+func (s *PreviewStore) Load(_ context.Context) ([]domain.PreviewRoute, error) {
31+ s.mu.Lock()
32+ defer s.mu.Unlock()
33+
34+ data, err := os.ReadFile(s.path)
35+ if err != nil {
36+ if errors.Is(err, fs.ErrNotExist) {
37+ return nil, nil
38+ }
39+ return nil, err
40+ }
41+
42+ var store previewStoreData
43+ if err := json.Unmarshal(data, &store); err != nil {
44+ return nil, err
45+ }
46+ return store.Previews, nil
47+}
48+
49+func (s *PreviewStore) Save(_ context.Context, previews []domain.PreviewRoute) error {
50+ s.mu.Lock()
51+ defer s.mu.Unlock()
52+
53+ if len(previews) == 0 {
54+ err := os.Remove(s.path)
55+ if errors.Is(err, fs.ErrNotExist) {
56+ return nil
57+ }
58+ return err
59+ }
60+
61+ data, err := json.MarshalIndent(previewStoreData{Previews: previews}, "", " ")
62+ if err != nil {
63+ return err
64+ }
65+
66+ // Atomic write: temp file → rename
67+ dir := filepath.Dir(s.path)
68+ if err := os.MkdirAll(dir, 0o750); err != nil {
69+ return err
70+ }
71+ tmp, err := os.CreateTemp(dir, ".previews-*.json.tmp")
72+ if err != nil {
73+ return err
74+ }
75+ tmpName := tmp.Name()
76+
77+ if _, err := tmp.Write(data); err != nil {
78+ tmp.Close()
79+ os.Remove(tmpName)
80+ return err
81+ }
82+ if err := tmp.Sync(); err != nil {
83+ tmp.Close()
84+ os.Remove(tmpName)
85+ return err
86+ }
87+ if err := tmp.Close(); err != nil {
88+ os.Remove(tmpName)
89+ return err
90+ }
91+ return os.Rename(tmpName, s.path)
92+}
A internal/adapters/out/filesystem/previewstore_test.go +61 -0
@@ -0,0 +1,61 @@
1+package filesystem
2+
3+import (
4+ "context"
5+ "os"
6+ "path/filepath"
7+ "testing"
8+ "time"
9+
10+ "github.com/bnema/gordon/internal/domain"
11+ "github.com/stretchr/testify/assert"
12+ "github.com/stretchr/testify/require"
13+)
14+
15+func TestPreviewStore_SaveAndLoad(t *testing.T) {
16+ dir := t.TempDir()
17+ store := NewPreviewStore(filepath.Join(dir, "previews.json"))
18+
19+ previews := []domain.PreviewRoute{
20+ {
21+ Domain: "myapp--feat.example.com",
22+ Image: "myapp:preview-feat",
23+ BaseRoute: "myapp.example.com",
24+ Name: "feat",
25+ CreatedAt: time.Now().UTC().Truncate(time.Second),
26+ ExpiresAt: time.Now().UTC().Add(48 * time.Hour).Truncate(time.Second),
27+ HTTPS: true,
28+ Volumes: []string{"preview-feat-pgdata"},
29+ Containers: []string{"preview-feat-postgres"},
30+ },
31+ }
32+
33+ ctx := context.Background()
34+ err := store.Save(ctx, previews)
35+ require.NoError(t, err)
36+
37+ loaded, err := store.Load(ctx)
38+ require.NoError(t, err)
39+ assert.Equal(t, previews, loaded)
40+}
41+
42+func TestPreviewStore_LoadEmpty(t *testing.T) {
43+ dir := t.TempDir()
44+ store := NewPreviewStore(filepath.Join(dir, "previews.json"))
45+
46+ loaded, err := store.Load(context.Background())
47+ require.NoError(t, err)
48+ assert.Empty(t, loaded)
49+}
50+
51+func TestPreviewStore_SaveEmpty(t *testing.T) {
52+ dir := t.TempDir()
53+ path := filepath.Join(dir, "previews.json")
54+ store := NewPreviewStore(path)
55+
56+ err := store.Save(context.Background(), []domain.PreviewRoute{})
57+ require.NoError(t, err)
58+
59+ _, err = os.Stat(path)
60+ assert.True(t, os.IsNotExist(err))
61+}
M internal/app/run.go +63 -15
@@ -64,6 +64,8 @@ import (
6464
6565 // Use cases
6666 "github.com/bnema/gordon/internal/usecase/auth"
67+ "github.com/bnema/gordon/internal/usecase/auto"
68+ "github.com/bnema/gordon/internal/usecase/auto/preview"
6769 "github.com/bnema/gordon/internal/usecase/backup"
6870 "github.com/bnema/gordon/internal/usecase/config"
6971 "github.com/bnema/gordon/internal/usecase/container"
@@ -192,6 +194,8 @@ type services struct {
192194 adminHandler *admin.Handler
193195 internalRegUser string
194196 internalRegPass string
197+ previewStore *filesystem.PreviewStore
198+ previewService *preview.Service
195199 envDir string
196200 maxBlobChunkSize int64
197201 }
@@ -541,24 +545,57 @@ func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowra
541545 // Create log service for accessing logs via admin API
542546 svc.logSvc = logs.NewService(resolveLogFilePath(cfg), cfg.Logging.File.Enabled, svc.containerSvc, svc.runtime, log)
543547
548+ initPreviewService(ctx, cfg, svc, log)
549+
544550 // Create admin handler for admin API
545- svc.adminHandler = admin.NewHandler(
546- svc.configSvc,
547- svc.authSvc,
548- svc.containerSvc,
549- svc.healthSvc,
550- svc.secretSvc,
551- svc.logSvc,
552- svc.registrySvc,
553- svc.eventBus,
554- log,
555- svc.backupSvc,
556- svc.imageSvc,
557- ).WithVolumeService(svc.volumeSvc)
551+ svc.adminHandler = admin.NewHandler(admin.HandlerDeps{
552+ ConfigSvc: svc.configSvc,
553+ AuthSvc: svc.authSvc,
554+ ContainerSvc: svc.containerSvc,
555+ HealthSvc: svc.healthSvc,
556+ SecretSvc: svc.secretSvc,
557+ LogSvc: svc.logSvc,
558+ RegistrySvc: svc.registrySvc,
559+ EventBus: svc.eventBus,
560+ Log: log,
561+ BackupSvc: svc.backupSvc,
562+ PreviewSvc: svc.previewService,
563+ ImageSvc: svc.imageSvc,
564+ VolumeSvc: svc.volumeSvc,
565+ })
558566
559567 return svc, nil
560568 }
561569
570+// initPreviewService sets up the preview store, service, and TTL ticker.
571+func initPreviewService(ctx context.Context, cfg Config, svc *services, log zerowrap.Logger) {
572+ previewStorePath := filepath.Join(resolveDataDir(cfg.Server.DataDir), "previews.json")
573+ svc.previewStore = filesystem.NewPreviewStore(previewStorePath)
574+ previewConfig := svc.configSvc.GetPreviewConfig()
575+ svc.previewService = preview.NewService(svc.previewStore, previewConfig.TTL)
576+ if err := svc.previewService.Load(ctx); err != nil {
577+ log.Warn().Err(err).Msg("failed to load previews")
578+ }
579+
580+ svc.previewService.StartTicker(ctx, 60*time.Second, func(ctx context.Context, p domain.PreviewRoute) {
581+ log := zerowrap.FromCtx(ctx)
582+ for _, containerName := range p.Containers {
583+ if err := svc.runtime.StopContainer(ctx, containerName); err != nil {
584+ log.Warn().Err(err).Str("container", containerName).Str("preview", p.Domain).Msg("failed to stop preview container")
585+ }
586+ if err := svc.runtime.RemoveContainer(ctx, containerName, true); err != nil {
587+ log.Warn().Err(err).Str("container", containerName).Str("preview", p.Domain).Msg("failed to remove preview container")
588+ }
589+ }
590+ for _, volName := range p.Volumes {
591+ if err := svc.runtime.RemoveVolume(ctx, volName, true); err != nil {
592+ log.Warn().Err(err).Str("volume", volName).Str("preview", p.Domain).Msg("failed to remove preview volume")
593+ }
594+ }
595+ svc.proxySvc.InvalidateTarget(ctx, p.Domain)
596+ })
597+}
598+
562599 // injectTelemetryMetrics creates and injects OTel metrics into services when
563600 // telemetry is enabled. Skipped otherwise to avoid unnecessary allocations.
564601 func injectTelemetryMetrics(cfg Config, svc *services, log zerowrap.Logger) {
@@ -1349,8 +1386,19 @@ func registerEventHandlers(ctx context.Context, svc *services, cfg Config) (func
13491386 // Auto-route handler for creating routes from image labels
13501387 autoRouteHandler := container.NewAutoRouteHandler(ctx, svc.configSvc, svc.containerSvc, svc.blobStorage, cfg.Server.RegistryDomain).
13511388 WithEnvExtractor(svc.runtime, svc.envDir)
1352- if err := svc.eventBus.Subscribe(autoRouteHandler); err != nil {
1353- return nil, fmt.Errorf("failed to subscribe auto-route handler: %w", err)
1389+
1390+ // Preview handler for creating preview environments from tagged images
1391+ autoPreviewHandler := preview.NewAutoPreviewHandler(
1392+ ctx,
1393+ svc.configSvc,
1394+ svc.blobStorage,
1395+ svc.previewService,
1396+ )
1397+
1398+ // Dispatcher routes image push events to either auto-route or preview handler
1399+ dispatcher := auto.NewImagePushDispatcher(svc.configSvc, autoRouteHandler, autoPreviewHandler)
1400+ if err := svc.eventBus.Subscribe(dispatcher); err != nil {
1401+ return nil, fmt.Errorf("subscribe image push dispatcher: %w", err)
13541402 }
13551403
13561404 configReloadHandler := container.NewConfigReloadHandler(ctx, svc.containerSvc, svc.configSvc)
M internal/boundaries/in/config.go +7 -0
@@ -84,4 +84,11 @@ type ConfigService interface {
8484 GetAutoRouteAllowedDomains(ctx context.Context) ([]string, error)
8585 AddAutoRouteAllowedDomain(ctx context.Context, pattern string) error
8686 RemoveAutoRouteAllowedDomain(ctx context.Context, pattern string) error
87+
88+ // Preview and auto config
89+ GetPreviewConfig() domain.PreviewConfig
90+ IsPreviewEnabled() bool
91+ IsAutoEnabled() bool
92+ GetPreviewTagPatterns() []string
93+ GetAllowedDomains() []string
8794 }
M internal/boundaries/in/mocks/mock_config_service.go +224 -0
@@ -386,6 +386,52 @@ func (_c *MockConfigService_GetAllAttachments_Call) RunAndReturn(run func(ctx co
386386 return _c
387387 }
388388
389+// GetAllowedDomains provides a mock function for the type MockConfigService
390+func (_mock *MockConfigService) GetAllowedDomains() []string {
391+ ret := _mock.Called()
392+
393+ if len(ret) == 0 {
394+ panic("no return value specified for GetAllowedDomains")
395+ }
396+
397+ var r0 []string
398+ if returnFunc, ok := ret.Get(0).(func() []string); ok {
399+ r0 = returnFunc()
400+ } else {
401+ if ret.Get(0) != nil {
402+ r0 = ret.Get(0).([]string)
403+ }
404+ }
405+ return r0
406+}
407+
408+// MockConfigService_GetAllowedDomains_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetAllowedDomains'
409+type MockConfigService_GetAllowedDomains_Call struct {
410+ *mock.Call
411+}
412+
413+// GetAllowedDomains is a helper method to define mock.On call
414+func (_e *MockConfigService_Expecter) GetAllowedDomains() *MockConfigService_GetAllowedDomains_Call {
415+ return &MockConfigService_GetAllowedDomains_Call{Call: _e.mock.On("GetAllowedDomains")}
416+}
417+
418+func (_c *MockConfigService_GetAllowedDomains_Call) Run(run func()) *MockConfigService_GetAllowedDomains_Call {
419+ _c.Call.Run(func(args mock.Arguments) {
420+ run()
421+ })
422+ return _c
423+}
424+
425+func (_c *MockConfigService_GetAllowedDomains_Call) Return(strings []string) *MockConfigService_GetAllowedDomains_Call {
426+ _c.Call.Return(strings)
427+ return _c
428+}
429+
430+func (_c *MockConfigService_GetAllowedDomains_Call) RunAndReturn(run func() []string) *MockConfigService_GetAllowedDomains_Call {
431+ _c.Call.Return(run)
432+ return _c
433+}
434+
389435 // GetAttachmentsFor provides a mock function for the type MockConfigService
390436 func (_mock *MockConfigService) GetAttachmentsFor(ctx context.Context, domainOrGroup string) ([]string, error) {
391437 ret := _mock.Called(ctx, domainOrGroup)
@@ -650,6 +696,96 @@ func (_c *MockConfigService_GetNetworkPrefix_Call) RunAndReturn(run func() strin
650696 return _c
651697 }
652698
699+// GetPreviewConfig provides a mock function for the type MockConfigService
700+func (_mock *MockConfigService) GetPreviewConfig() domain.PreviewConfig {
701+ ret := _mock.Called()
702+
703+ if len(ret) == 0 {
704+ panic("no return value specified for GetPreviewConfig")
705+ }
706+
707+ var r0 domain.PreviewConfig
708+ if returnFunc, ok := ret.Get(0).(func() domain.PreviewConfig); ok {
709+ r0 = returnFunc()
710+ } else {
711+ r0 = ret.Get(0).(domain.PreviewConfig)
712+ }
713+ return r0
714+}
715+
716+// MockConfigService_GetPreviewConfig_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPreviewConfig'
717+type MockConfigService_GetPreviewConfig_Call struct {
718+ *mock.Call
719+}
720+
721+// GetPreviewConfig is a helper method to define mock.On call
722+func (_e *MockConfigService_Expecter) GetPreviewConfig() *MockConfigService_GetPreviewConfig_Call {
723+ return &MockConfigService_GetPreviewConfig_Call{Call: _e.mock.On("GetPreviewConfig")}
724+}
725+
726+func (_c *MockConfigService_GetPreviewConfig_Call) Run(run func()) *MockConfigService_GetPreviewConfig_Call {
727+ _c.Call.Run(func(args mock.Arguments) {
728+ run()
729+ })
730+ return _c
731+}
732+
733+func (_c *MockConfigService_GetPreviewConfig_Call) Return(previewConfig domain.PreviewConfig) *MockConfigService_GetPreviewConfig_Call {
734+ _c.Call.Return(previewConfig)
735+ return _c
736+}
737+
738+func (_c *MockConfigService_GetPreviewConfig_Call) RunAndReturn(run func() domain.PreviewConfig) *MockConfigService_GetPreviewConfig_Call {
739+ _c.Call.Return(run)
740+ return _c
741+}
742+
743+// GetPreviewTagPatterns provides a mock function for the type MockConfigService
744+func (_mock *MockConfigService) GetPreviewTagPatterns() []string {
745+ ret := _mock.Called()
746+
747+ if len(ret) == 0 {
748+ panic("no return value specified for GetPreviewTagPatterns")
749+ }
750+
751+ var r0 []string
752+ if returnFunc, ok := ret.Get(0).(func() []string); ok {
753+ r0 = returnFunc()
754+ } else {
755+ if ret.Get(0) != nil {
756+ r0 = ret.Get(0).([]string)
757+ }
758+ }
759+ return r0
760+}
761+
762+// MockConfigService_GetPreviewTagPatterns_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPreviewTagPatterns'
763+type MockConfigService_GetPreviewTagPatterns_Call struct {
764+ *mock.Call
765+}
766+
767+// GetPreviewTagPatterns is a helper method to define mock.On call
768+func (_e *MockConfigService_Expecter) GetPreviewTagPatterns() *MockConfigService_GetPreviewTagPatterns_Call {
769+ return &MockConfigService_GetPreviewTagPatterns_Call{Call: _e.mock.On("GetPreviewTagPatterns")}
770+}
771+
772+func (_c *MockConfigService_GetPreviewTagPatterns_Call) Run(run func()) *MockConfigService_GetPreviewTagPatterns_Call {
773+ _c.Call.Run(func(args mock.Arguments) {
774+ run()
775+ })
776+ return _c
777+}
778+
779+func (_c *MockConfigService_GetPreviewTagPatterns_Call) Return(strings []string) *MockConfigService_GetPreviewTagPatterns_Call {
780+ _c.Call.Return(strings)
781+ return _c
782+}
783+
784+func (_c *MockConfigService_GetPreviewTagPatterns_Call) RunAndReturn(run func() []string) *MockConfigService_GetPreviewTagPatterns_Call {
785+ _c.Call.Return(run)
786+ return _c
787+}
788+
653789 // GetRegistryDomain provides a mock function for the type MockConfigService
654790 func (_mock *MockConfigService) GetRegistryDomain() string {
655791 ret := _mock.Called()
@@ -903,6 +1039,50 @@ func (_c *MockConfigService_GetServerPort_Call) RunAndReturn(run func() int) *Mo
9031039 return _c
9041040 }
9051041
1042+// IsAutoEnabled provides a mock function for the type MockConfigService
1043+func (_mock *MockConfigService) IsAutoEnabled() bool {
1044+ ret := _mock.Called()
1045+
1046+ if len(ret) == 0 {
1047+ panic("no return value specified for IsAutoEnabled")
1048+ }
1049+
1050+ var r0 bool
1051+ if returnFunc, ok := ret.Get(0).(func() bool); ok {
1052+ r0 = returnFunc()
1053+ } else {
1054+ r0 = ret.Get(0).(bool)
1055+ }
1056+ return r0
1057+}
1058+
1059+// MockConfigService_IsAutoEnabled_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'IsAutoEnabled'
1060+type MockConfigService_IsAutoEnabled_Call struct {
1061+ *mock.Call
1062+}
1063+
1064+// IsAutoEnabled is a helper method to define mock.On call
1065+func (_e *MockConfigService_Expecter) IsAutoEnabled() *MockConfigService_IsAutoEnabled_Call {
1066+ return &MockConfigService_IsAutoEnabled_Call{Call: _e.mock.On("IsAutoEnabled")}
1067+}
1068+
1069+func (_c *MockConfigService_IsAutoEnabled_Call) Run(run func()) *MockConfigService_IsAutoEnabled_Call {
1070+ _c.Call.Run(func(args mock.Arguments) {
1071+ run()
1072+ })
1073+ return _c
1074+}
1075+
1076+func (_c *MockConfigService_IsAutoEnabled_Call) Return(b bool) *MockConfigService_IsAutoEnabled_Call {
1077+ _c.Call.Return(b)
1078+ return _c
1079+}
1080+
1081+func (_c *MockConfigService_IsAutoEnabled_Call) RunAndReturn(run func() bool) *MockConfigService_IsAutoEnabled_Call {
1082+ _c.Call.Return(run)
1083+ return _c
1084+}
1085+
9061086 // IsAutoRouteEnabled provides a mock function for the type MockConfigService
9071087 func (_mock *MockConfigService) IsAutoRouteEnabled() bool {
9081088 ret := _mock.Called()
@@ -991,6 +1171,50 @@ func (_c *MockConfigService_IsNetworkIsolationEnabled_Call) RunAndReturn(run fun
9911171 return _c
9921172 }
9931173
1174+// IsPreviewEnabled provides a mock function for the type MockConfigService
1175+func (_mock *MockConfigService) IsPreviewEnabled() bool {
1176+ ret := _mock.Called()
1177+
1178+ if len(ret) == 0 {
1179+ panic("no return value specified for IsPreviewEnabled")
1180+ }
1181+
1182+ var r0 bool
1183+ if returnFunc, ok := ret.Get(0).(func() bool); ok {
1184+ r0 = returnFunc()
1185+ } else {
1186+ r0 = ret.Get(0).(bool)
1187+ }
1188+ return r0
1189+}
1190+
1191+// MockConfigService_IsPreviewEnabled_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'IsPreviewEnabled'
1192+type MockConfigService_IsPreviewEnabled_Call struct {
1193+ *mock.Call
1194+}
1195+
1196+// IsPreviewEnabled is a helper method to define mock.On call
1197+func (_e *MockConfigService_Expecter) IsPreviewEnabled() *MockConfigService_IsPreviewEnabled_Call {
1198+ return &MockConfigService_IsPreviewEnabled_Call{Call: _e.mock.On("IsPreviewEnabled")}
1199+}
1200+
1201+func (_c *MockConfigService_IsPreviewEnabled_Call) Run(run func()) *MockConfigService_IsPreviewEnabled_Call {
1202+ _c.Call.Run(func(args mock.Arguments) {
1203+ run()
1204+ })
1205+ return _c
1206+}
1207+
1208+func (_c *MockConfigService_IsPreviewEnabled_Call) Return(b bool) *MockConfigService_IsPreviewEnabled_Call {
1209+ _c.Call.Return(b)
1210+ return _c
1211+}
1212+
1213+func (_c *MockConfigService_IsPreviewEnabled_Call) RunAndReturn(run func() bool) *MockConfigService_IsPreviewEnabled_Call {
1214+ _c.Call.Return(run)
1215+ return _c
1216+}
1217+
9941218 // Load provides a mock function for the type MockConfigService
9951219 func (_mock *MockConfigService) Load(ctx context.Context) error {
9961220 ret := _mock.Called(ctx)
A internal/boundaries/out/previewstore.go +13 -0
@@ -0,0 +1,13 @@
1+package out
2+
3+import (
4+ "context"
5+
6+ "github.com/bnema/gordon/internal/domain"
7+)
8+
9+// PreviewStore persists active preview routes.
10+type PreviewStore interface {
11+ Load(ctx context.Context) ([]domain.PreviewRoute, error)
12+ Save(ctx context.Context, previews []domain.PreviewRoute) error
13+}
A internal/domain/auto.go +23 -0
@@ -0,0 +1,23 @@
1+package domain
2+
3+import "time"
4+
5+// AutoConfig holds the unified auto-route and auto-preview configuration.
6+type AutoConfig struct {
7+ Enabled bool
8+ AllowedDomains []string
9+ Route AutoRouteConfig
10+ Preview PreviewConfig
11+}
12+
13+// AutoRouteConfig holds auto-route specific settings (reserved for future use).
14+type AutoRouteConfig struct{}
15+
16+// PreviewConfig holds auto-preview specific settings.
17+type PreviewConfig struct {
18+ Enabled bool
19+ TTL time.Duration
20+ Separator string
21+ TagPatterns []string
22+ DataCopy bool
23+}
M internal/domain/container.go +13 -12
@@ -45,18 +45,19 @@ type RouteInfo struct {
4545
4646 // ContainerConfig holds configuration for creating a container.
4747 type ContainerConfig struct {
48- Image string
49- Name string
50- Env []string
51- Ports []int
52- Labels map[string]string
53- WorkingDir string
54- Cmd []string
55- AutoRemove bool
56- Volumes map[string]string // map[containerPath]volumeName
57- NetworkMode string // Network to join
58- Hostname string // Container hostname for DNS
59- Aliases []string // Additional network aliases
48+ Image string
49+ Name string
50+ Env []string
51+ Ports []int
52+ Labels map[string]string
53+ WorkingDir string
54+ Cmd []string
55+ AutoRemove bool
56+ Volumes map[string]string // map[containerPath]volumeName
57+ ReadOnlyVolumes map[string]string // containerPath -> volumeName (mounted read-only)
58+ NetworkMode string // Network to join
59+ Hostname string // Container hostname for DNS
60+ Aliases []string // Additional network aliases
6061 }
6162
6263 // ContainerStatus represents the current state of a container.
M internal/domain/errors.go +3 -0
@@ -40,6 +40,9 @@ var (
4040 ErrVolumeNotFound = errors.New("volume not found")
4141 ErrVolumeExists = errors.New("volume already exists")
4242
43+ // Preview errors
44+ ErrPreviewNotFound = errors.New("preview not found")
45+
4346 // Config errors
4447 ErrConfigNotFound = errors.New("configuration not found")
4548 ErrInvalidConfig = errors.New("invalid configuration")
A internal/domain/preview.go +21 -0
@@ -0,0 +1,21 @@
1+package domain
2+
3+import "time"
4+
5+// PreviewRoute represents an ephemeral preview environment.
6+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"`
16+}
17+
18+// IsExpired returns true if the preview has exceeded its TTL.
19+func (p PreviewRoute) IsExpired(now time.Time) bool {
20+ return now.After(p.ExpiresAt)
21+}
A internal/domain/preview_test.go +27 -0
@@ -0,0 +1,27 @@
1+package domain
2+
3+import (
4+ "testing"
5+ "time"
6+
7+ "github.com/stretchr/testify/assert"
8+)
9+
10+func TestPreviewRoute_IsExpired(t *testing.T) {
11+ now := time.Now()
12+ tests := []struct {
13+ name string
14+ expiresAt time.Time
15+ want bool
16+ }{
17+ {"expired", now.Add(-1 * time.Hour), true},
18+ {"not expired", now.Add(1 * time.Hour), false},
19+ {"just expired", now.Add(-1 * time.Second), true},
20+ }
21+ for _, tt := range tests {
22+ t.Run(tt.name, func(t *testing.T) {
23+ p := PreviewRoute{ExpiresAt: tt.expiresAt}
24+ assert.Equal(t, tt.want, p.IsExpired(now))
25+ })
26+ }
27+}
A internal/usecase/auto/dispatcher.go +68 -0
@@ -0,0 +1,68 @@
1+package auto
2+
3+import (
4+ "context"
5+ "path/filepath"
6+
7+ "github.com/bnema/zerowrap"
8+
9+ "github.com/bnema/gordon/internal/boundaries/out"
10+ "github.com/bnema/gordon/internal/domain"
11+)
12+
13+// AutoConfigProvider exposes unified auto config to the dispatcher.
14+type AutoConfigProvider interface {
15+ IsAutoEnabled() bool
16+ IsPreviewEnabled() bool
17+ GetPreviewTagPatterns() []string
18+ GetPreviewConfig() domain.PreviewConfig
19+ GetAllowedDomains() []string
20+}
21+
22+// ImagePushDispatcher classifies image push events and delegates
23+// to either the auto-route handler or the auto-preview handler.
24+type ImagePushDispatcher struct {
25+ config AutoConfigProvider
26+ routeHandler out.EventHandler
27+ previewHandler out.EventHandler
28+}
29+
30+func NewImagePushDispatcher(config AutoConfigProvider, routeHandler, previewHandler out.EventHandler) *ImagePushDispatcher {
31+ return &ImagePushDispatcher{
32+ config: config,
33+ routeHandler: routeHandler,
34+ previewHandler: previewHandler,
35+ }
36+}
37+
38+func (d *ImagePushDispatcher) CanHandle(t domain.EventType) bool {
39+ return t == domain.EventImagePushed
40+}
41+
42+func (d *ImagePushDispatcher) Handle(ctx context.Context, event domain.Event) error {
43+ log := zerowrap.FromCtx(ctx)
44+
45+ payload, ok := event.Data.(domain.ImagePushedPayload)
46+ if !ok {
47+ log.Warn().Str("event_type", string(event.Type)).Msg("dispatcher received event with unexpected payload type; forwarding to route handler")
48+ return d.routeHandler.Handle(ctx, event)
49+ }
50+
51+ if d.config.IsPreviewEnabled() && d.matchesTagPatterns(payload.Reference) {
52+ return d.previewHandler.Handle(ctx, event)
53+ }
54+ return d.routeHandler.Handle(ctx, event)
55+}
56+
57+func (d *ImagePushDispatcher) matchesTagPatterns(reference string) bool {
58+ for _, pattern := range d.config.GetPreviewTagPatterns() {
59+ matched, err := filepath.Match(pattern, reference)
60+ if err != nil {
61+ continue // skip malformed patterns
62+ }
63+ if matched {
64+ return true
65+ }
66+ }
67+ return false
68+}
A internal/usecase/auto/dispatcher_test.go +91 -0
@@ -0,0 +1,91 @@
1+package auto
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ "github.com/stretchr/testify/assert"
8+
9+ "github.com/bnema/gordon/internal/domain"
10+)
11+
12+type mockHandler struct {
13+ called bool
14+}
15+
16+func (m *mockHandler) Handle(_ context.Context, _ domain.Event) error {
17+ m.called = true
18+ return nil
19+}
20+
21+func (m *mockHandler) CanHandle(t domain.EventType) bool {
22+ return t == domain.EventImagePushed
23+}
24+
25+type mockAutoConfig struct {
26+ previewEnabled bool
27+ tagPatterns []string
28+}
29+
30+func (m *mockAutoConfig) IsAutoEnabled() bool { return true }
31+func (m *mockAutoConfig) IsPreviewEnabled() bool { return m.previewEnabled }
32+func (m *mockAutoConfig) GetPreviewTagPatterns() []string { return m.tagPatterns }
33+func (m *mockAutoConfig) GetPreviewConfig() domain.PreviewConfig { return domain.PreviewConfig{} }
34+func (m *mockAutoConfig) GetAllowedDomains() []string { return nil }
35+
36+func TestDispatcher_PreviewTag_DelegatesToPreview(t *testing.T) {
37+ route := &mockHandler{}
38+ preview := &mockHandler{}
39+ d := NewImagePushDispatcher(
40+ &mockAutoConfig{previewEnabled: true, tagPatterns: []string{"preview-*"}},
41+ route, preview,
42+ )
43+ event := domain.Event{
44+ Type: domain.EventImagePushed,
45+ Data: domain.ImagePushedPayload{Name: "myapp", Reference: "preview-feat"},
46+ }
47+ err := d.Handle(context.Background(), event)
48+ assert.NoError(t, err)
49+ assert.True(t, preview.called)
50+ assert.False(t, route.called)
51+}
52+
53+func TestDispatcher_NormalTag_DelegatesToRoute(t *testing.T) {
54+ route := &mockHandler{}
55+ preview := &mockHandler{}
56+ d := NewImagePushDispatcher(
57+ &mockAutoConfig{previewEnabled: true, tagPatterns: []string{"preview-*"}},
58+ route, preview,
59+ )
60+ event := domain.Event{
61+ Type: domain.EventImagePushed,
62+ Data: domain.ImagePushedPayload{Name: "myapp", Reference: "v1.0.0"},
63+ }
64+ err := d.Handle(context.Background(), event)
65+ assert.NoError(t, err)
66+ assert.True(t, route.called)
67+ assert.False(t, preview.called)
68+}
69+
70+func TestDispatcher_PreviewDisabled_AlwaysRoute(t *testing.T) {
71+ route := &mockHandler{}
72+ preview := &mockHandler{}
73+ d := NewImagePushDispatcher(
74+ &mockAutoConfig{previewEnabled: false, tagPatterns: []string{"preview-*"}},
75+ route, preview,
76+ )
77+ event := domain.Event{
78+ Type: domain.EventImagePushed,
79+ Data: domain.ImagePushedPayload{Name: "myapp", Reference: "preview-feat"},
80+ }
81+ err := d.Handle(context.Background(), event)
82+ assert.NoError(t, err)
83+ assert.True(t, route.called)
84+ assert.False(t, preview.called)
85+}
86+
87+func TestDispatcher_CanHandle(t *testing.T) {
88+ d := &ImagePushDispatcher{}
89+ assert.True(t, d.CanHandle(domain.EventImagePushed))
90+ assert.False(t, d.CanHandle(domain.EventConfigReload))
91+}
A internal/usecase/auto/labels.go +119 -0
@@ -0,0 +1,119 @@
1+package auto
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "fmt"
7+ "io"
8+ "strings"
9+
10+ "github.com/bnema/gordon/internal/boundaries/out"
11+ "github.com/bnema/gordon/internal/domain"
12+)
13+
14+// manifestSchema represents the relevant parts of an OCI/Docker manifest.
15+type manifestSchema struct {
16+ SchemaVersion int `json:"schemaVersion"`
17+ MediaType string `json:"mediaType"`
18+ Config struct {
19+ MediaType string `json:"mediaType"`
20+ Digest string `json:"digest"`
21+ Size int64 `json:"size"`
22+ } `json:"config"`
23+}
24+
25+// ParseConfigDigest extracts the config digest from a manifest.
26+func ParseConfigDigest(manifestData []byte) (string, error) {
27+ var manifest manifestSchema
28+ if err := json.Unmarshal(manifestData, &manifest); err != nil {
29+ return "", err
30+ }
31+
32+ return manifest.Config.Digest, nil
33+}
34+
35+// imageConfig represents the relevant parts of an OCI/Docker image config.
36+type imageConfig struct {
37+ Config struct {
38+ Labels map[string]string `json:"Labels"`
39+ } `json:"config"`
40+}
41+
42+// ParseImageLabels extracts Gordon labels from an image config blob.
43+func ParseImageLabels(configData []byte) (*domain.ImageLabels, error) {
44+ var config imageConfig
45+ if err := json.Unmarshal(configData, &config); err != nil {
46+ return nil, err
47+ }
48+
49+ labels := &domain.ImageLabels{}
50+
51+ if config.Config.Labels == nil {
52+ return labels, nil
53+ }
54+
55+ // Extract gordon.* labels
56+ if v, ok := config.Config.Labels[domain.LabelDomain]; ok {
57+ labels.Domain = strings.TrimSpace(v)
58+ }
59+
60+ if v, ok := config.Config.Labels[domain.LabelDomains]; ok {
61+ // Parse comma-separated domains
62+ for _, d := range strings.Split(v, ",") {
63+ d = strings.TrimSpace(d)
64+ if d != "" {
65+ labels.Domains = append(labels.Domains, d)
66+ }
67+ }
68+ }
69+
70+ if v, ok := config.Config.Labels[domain.LabelHealth]; ok {
71+ labels.Health = strings.TrimSpace(v)
72+ }
73+
74+ for _, key := range []string{domain.LabelProxyPort, domain.LabelPort} {
75+ if v, ok := config.Config.Labels[key]; ok {
76+ labels.Port = strings.TrimSpace(v)
77+ break
78+ }
79+ }
80+
81+ if v, ok := config.Config.Labels[domain.LabelEnvFile]; ok {
82+ labels.EnvFile = strings.TrimSpace(v)
83+ }
84+
85+ return labels, nil
86+}
87+
88+// ExtractLabels extracts Gordon labels from an image manifest by fetching the
89+// config blob from blobStorage and parsing it. It is the shared implementation
90+// used by both AutoRouteHandler and AutoPreviewHandler.
91+// ctx is accepted for future use when BlobStorage gains context support.
92+func ExtractLabels(ctx context.Context, manifestData []byte, blobStorage out.BlobStorage) (*domain.ImageLabels, error) {
93+ configDigest, err := ParseConfigDigest(manifestData)
94+ if err != nil {
95+ return nil, fmt.Errorf("failed to parse manifest: %w", err)
96+ }
97+
98+ if configDigest == "" {
99+ return nil, fmt.Errorf("no config digest found in manifest")
100+ }
101+
102+ reader, err := blobStorage.GetBlob(configDigest)
103+ if err != nil {
104+ return nil, fmt.Errorf("failed to get config blob: %w", err)
105+ }
106+ defer reader.Close()
107+
108+ configData, err := io.ReadAll(reader)
109+ if err != nil {
110+ return nil, fmt.Errorf("failed to read config blob: %w", err)
111+ }
112+
113+ labels, err := ParseImageLabels(configData)
114+ if err != nil {
115+ return nil, fmt.Errorf("failed to parse config: %w", err)
116+ }
117+
118+ return labels, nil
119+}
A internal/usecase/auto/labels_test.go +277 -0
@@ -0,0 +1,277 @@
1+package auto
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "io"
7+ "strings"
8+ "testing"
9+
10+ "github.com/stretchr/testify/assert"
11+ "github.com/stretchr/testify/require"
12+
13+ "github.com/bnema/gordon/internal/boundaries/out/mocks"
14+ "github.com/bnema/gordon/internal/domain"
15+)
16+
17+func TestParseConfigDigest(t *testing.T) {
18+ tests := []struct {
19+ name string
20+ manifest map[string]any
21+ expected string
22+ wantErr bool
23+ }{
24+ {
25+ name: "valid manifest v2",
26+ manifest: map[string]any{
27+ "schemaVersion": 2,
28+ "mediaType": "application/vnd.docker.distribution.manifest.v2+json",
29+ "config": map[string]any{
30+ "mediaType": "application/vnd.docker.container.image.v1+json",
31+ "digest": "sha256:abc123",
32+ "size": 1234,
33+ },
34+ },
35+ expected: "sha256:abc123",
36+ },
37+ {
38+ name: "OCI manifest",
39+ manifest: map[string]any{
40+ "schemaVersion": 2,
41+ "mediaType": "application/vnd.oci.image.manifest.v1+json",
42+ "config": map[string]any{
43+ "mediaType": "application/vnd.oci.image.config.v1+json",
44+ "digest": "sha256:def456",
45+ "size": 5678,
46+ },
47+ },
48+ expected: "sha256:def456",
49+ },
50+ {
51+ name: "empty config digest",
52+ manifest: map[string]any{
53+ "schemaVersion": 2,
54+ "config": map[string]any{
55+ "digest": "",
56+ },
57+ },
58+ expected: "",
59+ },
60+ }
61+
62+ for _, tt := range tests {
63+ t.Run(tt.name, func(t *testing.T) {
64+ data, err := json.Marshal(tt.manifest)
65+ require.NoError(t, err)
66+
67+ result, err := ParseConfigDigest(data)
68+
69+ if tt.wantErr {
70+ assert.Error(t, err)
71+ return
72+ }
73+
74+ require.NoError(t, err)
75+ assert.Equal(t, tt.expected, result)
76+ })
77+ }
78+}
79+
80+func TestParseConfigDigest_InvalidJSON(t *testing.T) {
81+ _, err := ParseConfigDigest([]byte("invalid json"))
82+ assert.Error(t, err)
83+}
84+
85+func TestParseImageLabels(t *testing.T) {
86+ tests := []struct {
87+ name string
88+ config map[string]any
89+ expected *domain.ImageLabels
90+ }{
91+ {
92+ name: "single domain label",
93+ config: map[string]any{
94+ "config": map[string]any{
95+ "Labels": map[string]any{
96+ "gordon.domain": "app.example.com",
97+ },
98+ },
99+ },
100+ expected: &domain.ImageLabels{
101+ Domain: "app.example.com",
102+ },
103+ },
104+ {
105+ name: "multiple domains label",
106+ config: map[string]any{
107+ "config": map[string]any{
108+ "Labels": map[string]any{
109+ "gordon.domains": "app1.example.com, app2.example.com",
110+ },
111+ },
112+ },
113+ expected: &domain.ImageLabels{
114+ Domains: []string{"app1.example.com", "app2.example.com"},
115+ },
116+ },
117+ {
118+ name: "all gordon labels",
119+ config: map[string]any{
120+ "config": map[string]any{
121+ "Labels": map[string]any{
122+ "gordon.domain": "app.example.com",
123+ "gordon.health": "/healthz",
124+ "gordon.proxy.port": "8080",
125+ "gordon.env-file": ".env.prod",
126+ },
127+ },
128+ },
129+ expected: &domain.ImageLabels{
130+ Domain: "app.example.com",
131+ Health: "/healthz",
132+ Port: "8080",
133+ EnvFile: ".env.prod",
134+ },
135+ },
136+ {
137+ name: "no labels",
138+ config: map[string]any{
139+ "config": map[string]any{
140+ "Labels": nil,
141+ },
142+ },
143+ expected: &domain.ImageLabels{},
144+ },
145+ {
146+ name: "no gordon labels",
147+ config: map[string]any{
148+ "config": map[string]any{
149+ "Labels": map[string]any{
150+ "other.label": "value",
151+ },
152+ },
153+ },
154+ expected: &domain.ImageLabels{},
155+ },
156+ {
157+ name: "whitespace in labels",
158+ config: map[string]any{
159+ "config": map[string]any{
160+ "Labels": map[string]any{
161+ "gordon.domain": " app.example.com ",
162+ },
163+ },
164+ },
165+ expected: &domain.ImageLabels{
166+ Domain: "app.example.com",
167+ },
168+ },
169+ {
170+ name: "deprecated port label fallback",
171+ config: map[string]any{
172+ "config": map[string]any{
173+ "Labels": map[string]any{
174+ "gordon.port": "9090",
175+ },
176+ },
177+ },
178+ expected: &domain.ImageLabels{
179+ Port: "9090",
180+ },
181+ },
182+ {
183+ name: "proxy.port takes precedence over port",
184+ config: map[string]any{
185+ "config": map[string]any{
186+ "Labels": map[string]any{
187+ "gordon.proxy.port": "8080",
188+ "gordon.port": "9090",
189+ },
190+ },
191+ },
192+ expected: &domain.ImageLabels{
193+ Port: "8080",
194+ },
195+ },
196+ }
197+
198+ for _, tt := range tests {
199+ t.Run(tt.name, func(t *testing.T) {
200+ data, err := json.Marshal(tt.config)
201+ require.NoError(t, err)
202+
203+ result, err := ParseImageLabels(data)
204+ require.NoError(t, err)
205+
206+ assert.Equal(t, tt.expected.Domain, result.Domain)
207+ assert.Equal(t, tt.expected.Domains, result.Domains)
208+ assert.Equal(t, tt.expected.Health, result.Health)
209+ assert.Equal(t, tt.expected.Port, result.Port)
210+ assert.Equal(t, tt.expected.EnvFile, result.EnvFile)
211+ })
212+ }
213+}
214+
215+func TestParseImageLabels_InvalidJSON(t *testing.T) {
216+ _, err := ParseImageLabels([]byte("invalid json"))
217+ assert.Error(t, err)
218+}
219+
220+func TestExtractLabels(t *testing.T) {
221+ ctx := context.Background()
222+
223+ manifest := map[string]any{
224+ "schemaVersion": 2,
225+ "config": map[string]any{
226+ "digest": "sha256:configdigest123",
227+ },
228+ }
229+ manifestData, err := json.Marshal(manifest)
230+ require.NoError(t, err)
231+
232+ imageConf := map[string]any{
233+ "config": map[string]any{
234+ "Labels": map[string]any{
235+ "gordon.domain": "app.example.com",
236+ "gordon.health": "/healthz",
237+ },
238+ },
239+ }
240+ configData, err := json.Marshal(imageConf)
241+ require.NoError(t, err)
242+
243+ blobStorage := mocks.NewMockBlobStorage(t)
244+ blobStorage.EXPECT().GetBlob("sha256:configdigest123").Return(io.NopCloser(strings.NewReader(string(configData))), nil)
245+
246+ labels, err := ExtractLabels(ctx, manifestData, blobStorage)
247+ require.NoError(t, err)
248+ assert.Equal(t, "app.example.com", labels.Domain)
249+ assert.Equal(t, "/healthz", labels.Health)
250+}
251+
252+func TestExtractLabels_InvalidManifest(t *testing.T) {
253+ ctx := context.Background()
254+ blobStorage := mocks.NewMockBlobStorage(t)
255+
256+ _, err := ExtractLabels(ctx, []byte("bad json"), blobStorage)
257+ assert.Error(t, err)
258+}
259+
260+func TestExtractLabels_EmptyConfigDigest(t *testing.T) {
261+ ctx := context.Background()
262+
263+ manifest := map[string]any{
264+ "schemaVersion": 2,
265+ "config": map[string]any{
266+ "digest": "",
267+ },
268+ }
269+ manifestData, err := json.Marshal(manifest)
270+ require.NoError(t, err)
271+
272+ blobStorage := mocks.NewMockBlobStorage(t)
273+
274+ _, err = ExtractLabels(ctx, manifestData, blobStorage)
275+ assert.Error(t, err)
276+ assert.Contains(t, err.Error(), "no config digest")
277+}
A internal/usecase/auto/preview/handler.go +111 -0
@@ -0,0 +1,111 @@
1+package preview
2+
3+import (
4+ "context"
5+ "fmt"
6+
7+ "github.com/bnema/zerowrap"
8+
9+ "github.com/bnema/gordon/internal/boundaries/out"
10+ "github.com/bnema/gordon/internal/domain"
11+ "github.com/bnema/gordon/internal/usecase/auto"
12+)
13+
14+// AutoPreviewHandler processes EventImagePushed for preview tag patterns.
15+type AutoPreviewHandler struct {
16+ serviceCtx context.Context
17+ config auto.AutoConfigProvider
18+ blobStorage out.BlobStorage
19+ previewService *Service
20+}
21+
22+func NewAutoPreviewHandler(
23+ serviceCtx context.Context,
24+ config auto.AutoConfigProvider,
25+ blobStorage out.BlobStorage,
26+ previewService *Service,
27+) *AutoPreviewHandler {
28+ return &AutoPreviewHandler{
29+ serviceCtx: serviceCtx,
30+ config: config,
31+ blobStorage: blobStorage,
32+ previewService: previewService,
33+ }
34+}
35+
36+func (h *AutoPreviewHandler) CanHandle(t domain.EventType) bool {
37+ return t == domain.EventImagePushed
38+}
39+
40+func (h *AutoPreviewHandler) Handle(ctx context.Context, event domain.Event) error {
41+ ctx = zerowrap.CtxWithFields(ctx, map[string]any{
42+ zerowrap.FieldLayer: "usecase",
43+ zerowrap.FieldHandler: "AutoPreviewHandler",
44+ zerowrap.FieldEvent: string(event.Type),
45+ "event_id": event.ID,
46+ })
47+ log := zerowrap.FromCtx(ctx)
48+
49+ payload, ok := event.Data.(domain.ImagePushedPayload)
50+ if !ok {
51+ log.Debug().Str("actual_type", fmt.Sprintf("%T", event.Data)).Msg("unexpected event data type, skipping")
52+ return nil
53+ }
54+
55+ // Extract labels from manifest
56+ labels, err := auto.ExtractLabels(ctx, payload.Manifest, h.blobStorage)
57+ if err != nil {
58+ log.Debug().Err(err).Msg("failed to extract labels, skipping preview handler")
59+ return nil
60+ }
61+
62+ previewConfig := h.config.GetPreviewConfig()
63+ previewName := ExtractPreviewName(payload.Reference, previewConfig.TagPatterns)
64+
65+ // Resolve base route from labels
66+ domains := collectDomains(labels)
67+ if len(domains) == 0 {
68+ log.Debug().Str("image", payload.Name).Msg("no gordon.domain label found, skipping preview handler")
69+ return nil
70+ }
71+ baseRouteDomain := domains[0]
72+
73+ // Validate allowlist
74+ allowedDomains := h.config.GetAllowedDomains()
75+ previewDomain, err := GeneratePreviewDomain(baseRouteDomain, previewName, previewConfig.Separator)
76+ if err != nil {
77+ log.Debug().Err(err).Str("base_domain", baseRouteDomain).Msg("failed to generate preview domain")
78+ return nil
79+ }
80+ if !auto.MatchesDomainAllowlist(previewDomain, allowedDomains) {
81+ log.Debug().Str("domain", previewDomain).Strs("patterns", allowedDomains).Msg("preview domain not in allowlist, skipping")
82+ return nil
83+ }
84+
85+ imageName := payload.Name + ":" + payload.Reference
86+
87+ // Launch async — volume cloning may exceed event bus timeout
88+ go func() {
89+ if err := h.previewService.CreatePreview(h.serviceCtx, CreatePreviewRequest{
90+ Name: previewName,
91+ Domain: previewDomain,
92+ BaseRoute: baseRouteDomain,
93+ Image: imageName,
94+ HTTPS: true,
95+ PreviewConfig: previewConfig,
96+ }); err != nil {
97+ log.Error().Err(err).Str("preview", previewName).Msg("failed to create preview")
98+ }
99+ }()
100+
101+ return nil
102+}
103+
104+func collectDomains(labels *domain.ImageLabels) []string {
105+ var domains []string
106+ if labels.Domain != "" {
107+ domains = append(domains, labels.Domain)
108+ }
109+ domains = append(domains, labels.Domains...)
110+ return domains
111+}
A internal/usecase/auto/preview/handler_test.go +32 -0
@@ -0,0 +1,32 @@
1+package preview
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/gordon/internal/domain"
7+ "github.com/stretchr/testify/assert"
8+)
9+
10+func TestAutoPreviewHandler_CanHandle(t *testing.T) {
11+ h := &AutoPreviewHandler{}
12+ assert.True(t, h.CanHandle(domain.EventImagePushed))
13+ assert.False(t, h.CanHandle(domain.EventConfigReload))
14+}
15+
16+func TestCollectDomains(t *testing.T) {
17+ tests := []struct {
18+ name string
19+ labels *domain.ImageLabels
20+ want []string
21+ }{
22+ {"single domain", &domain.ImageLabels{Domain: "app.example.com"}, []string{"app.example.com"}},
23+ {"multiple domains", &domain.ImageLabels{Domains: []string{"a.com", "b.com"}}, []string{"a.com", "b.com"}},
24+ {"both", &domain.ImageLabels{Domain: "main.com", Domains: []string{"alt.com"}}, []string{"main.com", "alt.com"}},
25+ {"empty", &domain.ImageLabels{}, nil},
26+ }
27+ for _, tt := range tests {
28+ t.Run(tt.name, func(t *testing.T) {
29+ assert.Equal(t, tt.want, collectDomains(tt.labels))
30+ })
31+ }
32+}
A internal/usecase/auto/preview/integration_test.go +93 -0
@@ -0,0 +1,93 @@
1+package preview
2+
3+import (
4+ "context"
5+ "testing"
6+ "time"
7+
8+ "github.com/stretchr/testify/assert"
9+ "github.com/stretchr/testify/require"
10+
11+ "github.com/bnema/gordon/internal/domain"
12+)
13+
14+func TestPreviewLifecycle_Integration(t *testing.T) {
15+ store := &fakeStore{}
16+ svc := NewService(store, 2*time.Second)
17+
18+ ctx := context.Background()
19+
20+ // Create
21+ err := svc.CreatePreview(ctx, CreatePreviewRequest{
22+ Name: "test-feat",
23+ Domain: "myapp--test-feat.example.com",
24+ BaseRoute: "myapp.example.com",
25+ Image: "myapp:preview-test-feat",
26+ HTTPS: true,
27+ PreviewConfig: domain.PreviewConfig{
28+ TTL: 2 * time.Second,
29+ Separator: "--",
30+ DataCopy: false,
31+ },
32+ })
33+ require.NoError(t, err)
34+
35+ // Verify created
36+ all, err := svc.List(ctx)
37+ require.NoError(t, err)
38+ require.Len(t, all, 1)
39+ assert.Equal(t, "test-feat", all[0].Name)
40+ assert.Equal(t, "myapp--test-feat.example.com", all[0].Domain)
41+ assert.Equal(t, "myapp.example.com", all[0].BaseRoute)
42+
43+ // Extend
44+ err = svc.Extend(ctx, "test-feat", 1*time.Hour)
45+ require.NoError(t, err)
46+
47+ p, err := svc.Get(ctx, "test-feat")
48+ require.NoError(t, err)
49+ assert.True(t, p.ExpiresAt.After(time.Now().Add(55*time.Minute)))
50+
51+ // Delete
52+ err = svc.Delete(ctx, "test-feat")
53+ require.NoError(t, err)
54+
55+ all, err = svc.List(ctx)
56+ require.NoError(t, err)
57+ assert.Empty(t, all)
58+}
59+
60+func TestPreviewLifecycle_CleanupExpired(t *testing.T) {
61+ store := &fakeStore{}
62+ svc := NewService(store, 1*time.Millisecond)
63+
64+ ctx := context.Background()
65+
66+ // Create with very short TTL
67+ err := svc.CreatePreview(ctx, CreatePreviewRequest{
68+ Name: "short-lived",
69+ Domain: "myapp--short-lived.example.com",
70+ BaseRoute: "myapp.example.com",
71+ Image: "myapp:preview-short-lived",
72+ HTTPS: true,
73+ PreviewConfig: domain.PreviewConfig{
74+ TTL: 1 * time.Millisecond,
75+ Separator: "--",
76+ DataCopy: false,
77+ },
78+ })
79+ require.NoError(t, err)
80+
81+ // Wait for expiry
82+ time.Sleep(10 * time.Millisecond)
83+
84+ // Cleanup should find it
85+ expired := svc.CleanupExpired(ctx)
86+ assert.Len(t, expired, 1)
87+ assert.Equal(t, "short-lived", expired[0].Name)
88+
89+ // Should be gone
90+ all, err := svc.List(ctx)
91+ require.NoError(t, err)
92+ assert.Empty(t, all)
93+}
A internal/usecase/auto/preview/naming.go +75 -0
@@ -0,0 +1,75 @@
1+package preview
2+
3+import (
4+ "fmt"
5+ "path/filepath"
6+ "strings"
7+)
8+
9+// ExtractPreviewName strips the matched pattern prefix from a tag.
10+// Only simple trailing-wildcard patterns like "preview-*" or "pr-*" are supported.
11+// Complex globs (e.g., "pr-*-test", "*-preview") are not supported.
12+// "preview-login-redesign" with pattern "preview-*" → "login-redesign"
13+func ExtractPreviewName(tag string, patterns []string) string {
14+ for _, pattern := range patterns {
15+ matched, err := filepath.Match(pattern, tag)
16+ if err != nil {
17+ continue // skip malformed patterns
18+ }
19+ if matched {
20+ prefix := strings.TrimSuffix(pattern, "*")
21+ return strings.TrimPrefix(tag, prefix)
22+ }
23+ }
24+ return tag
25+}
26+
27+// GeneratePreviewDomain creates the preview domain from a base route domain.
28+// "myapp.example.com" + "login" + "--" → "myapp--login.example.com"
29+func GeneratePreviewDomain(baseRoute, previewName, separator string) (string, error) {
30+ dot := strings.Index(baseRoute, ".")
31+ if dot < 0 {
32+ return "", fmt.Errorf("invalid base route domain: %s", baseRoute)
33+ }
34+ app := baseRoute[:dot]
35+ rest := baseRoute[dot:] // includes leading dot
36+ return app + separator + previewName + rest, nil
37+}
38+
39+// SanitizeBranchName converts a git branch name to a valid DNS-safe preview name.
40+// "feat/login-redesign" → "login-redesign"
41+func SanitizeBranchName(branch string) string {
42+ branch = strings.ToLower(branch)
43+ for _, prefix := range []string{"feat/", "fix/", "feature/", "hotfix/", "release/", "chore/"} {
44+ if strings.HasPrefix(branch, prefix) {
45+ branch = strings.TrimPrefix(branch, prefix)
46+ break
47+ }
48+ }
49+ // Replace disallowed characters with dashes
50+ branch = strings.ReplaceAll(branch, "/", "-")
51+ branch = strings.ReplaceAll(branch, "_", "-")
52+ branch = strings.ReplaceAll(branch, ".", "-")
53+
54+ // Remove any remaining non DNS-safe characters
55+ var result strings.Builder
56+ for _, c := range branch {
57+ if (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-' {
58+ result.WriteRune(c)
59+ }
60+ }
61+ branch = result.String()
62+
63+ // Collapse consecutive dashes and trim
64+ for strings.Contains(branch, "--") {
65+ branch = strings.ReplaceAll(branch, "--", "-")
66+ }
67+ branch = strings.Trim(branch, "-")
68+
69+ // Truncate to 63 chars (DNS label limit)
70+ if len(branch) > 63 {
71+ branch = branch[:63]
72+ branch = strings.TrimRight(branch, "-")
73+ }
74+ return branch
75+}
A internal/usecase/auto/preview/naming_test.go +80 -0
@@ -0,0 +1,80 @@
1+package preview
2+
3+import (
4+ "strings"
5+ "testing"
6+
7+ "github.com/stretchr/testify/assert"
8+)
9+
10+func TestExtractPreviewName(t *testing.T) {
11+ tests := []struct {
12+ name string
13+ tag string
14+ patterns []string
15+ want string
16+ }{
17+ {"simple prefix", "preview-login", []string{"preview-*"}, "login"},
18+ {"pr prefix", "pr-42", []string{"pr-*"}, "42"},
19+ {"multi-word", "preview-login-redesign", []string{"preview-*"}, "login-redesign"},
20+ {"second pattern matches", "pr-99", []string{"preview-*", "pr-*"}, "99"},
21+ {"no match", "v1.0.0", []string{"preview-*"}, "v1.0.0"},
22+ }
23+ for _, tt := range tests {
24+ t.Run(tt.name, func(t *testing.T) {
25+ assert.Equal(t, tt.want, ExtractPreviewName(tt.tag, tt.patterns))
26+ })
27+ }
28+}
29+
30+func TestGeneratePreviewDomain(t *testing.T) {
31+ tests := []struct {
32+ name string
33+ baseRoute string
34+ previewName string
35+ separator string
36+ want string
37+ wantErr bool
38+ }{
39+ {"flat default", "myapp.example.com", "login", "--", "myapp--login.example.com", false},
40+ {"flat with dashes", "my-app.example.com", "feat-123", "--", "my-app--feat-123.example.com", false},
41+ {"multi-level base", "myapp.sub.example.com", "feat", "--", "myapp--feat.sub.example.com", false},
42+ {"invalid domain", "nodot", "feat", "--", "", true},
43+ }
44+ for _, tt := range tests {
45+ t.Run(tt.name, func(t *testing.T) {
46+ got, err := GeneratePreviewDomain(tt.baseRoute, tt.previewName, tt.separator)
47+ if tt.wantErr {
48+ assert.Error(t, err)
49+ } else {
50+ assert.NoError(t, err)
51+ assert.Equal(t, tt.want, got)
52+ }
53+ })
54+ }
55+}
56+
57+func TestSanitizeBranchName(t *testing.T) {
58+ tests := []struct {
59+ name string
60+ branch string
61+ want string
62+ }{
63+ {"feature branch", "feat/login-redesign", "login-redesign"},
64+ {"fix branch", "fix/bug-123", "bug-123"},
65+ {"simple", "main", "main"},
66+ {"nested", "feat/ui/button", "ui-button"},
67+ {"uppercase", "Feat/MyBranch", "mybranch"},
68+ {"dots and underscores", "feat/my_branch.name", "my-branch-name"},
69+ {"special chars", "feat/hello@world!", "helloworld"},
70+ {"long branch", strings.Repeat("a", 70), strings.Repeat("a", 63)},
71+ {"consecutive dashes", "feat/a--b--c", "a-b-c"},
72+ {"empty branch", "", ""},
73+ {"whitespace branch", " ", ""},
74+ }
75+ for _, tt := range tests {
76+ t.Run(tt.name, func(t *testing.T) {
77+ assert.Equal(t, tt.want, SanitizeBranchName(tt.branch))
78+ })
79+ }
80+}
A internal/usecase/auto/preview/service.go +236 -0
@@ -0,0 +1,236 @@
1+package preview
2+
3+import (
4+ "context"
5+ "fmt"
6+ "sync"
7+ "time"
8+
9+ "github.com/bnema/zerowrap"
10+
11+ "github.com/bnema/gordon/internal/boundaries/out"
12+ "github.com/bnema/gordon/internal/domain"
13+)
14+
15+type nameLockEntry struct {
16+ mu *sync.Mutex
17+ refs int
18+}
19+
20+// Service manages preview lifecycle: CRUD, TTL, persistence.
21+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
28+}
29+
30+func NewService(store out.PreviewStore, defaultTTL time.Duration) *Service {
31+ return &Service{
32+ store: store,
33+ defaultTTL: defaultTTL,
34+ nameLocks: make(map[string]*nameLockEntry),
35+ }
36+}
37+
38+func (s *Service) Load(ctx context.Context) error {
39+ previews, err := s.store.Load(ctx)
40+ if err != nil {
41+ return err
42+ }
43+ s.mu.Lock()
44+ s.previews = previews
45+ s.mu.Unlock()
46+ return nil
47+}
48+
49+func (s *Service) Add(ctx context.Context, p domain.PreviewRoute) error {
50+ s.mu.Lock()
51+ s.previews = append(s.previews, p)
52+ cp := make([]domain.PreviewRoute, len(s.previews))
53+ copy(cp, s.previews)
54+ s.mu.Unlock()
55+ return s.store.Save(ctx, cp)
56+}
57+
58+func (s *Service) Delete(ctx context.Context, name string) error {
59+ s.mu.Lock()
60+ found := false
61+ filtered := make([]domain.PreviewRoute, 0, len(s.previews))
62+ for _, p := range s.previews {
63+ if p.Name == name {
64+ found = true
65+ } else {
66+ filtered = append(filtered, p)
67+ }
68+ }
69+ if !found {
70+ s.mu.Unlock()
71+ return fmt.Errorf("preview %q: %w", name, domain.ErrPreviewNotFound)
72+ }
73+ s.previews = filtered
74+ cp := make([]domain.PreviewRoute, len(s.previews))
75+ copy(cp, s.previews)
76+ s.mu.Unlock()
77+ return s.store.Save(ctx, cp)
78+}
79+
80+func (s *Service) Get(_ context.Context, name string) (*domain.PreviewRoute, error) {
81+ s.mu.RLock()
82+ defer s.mu.RUnlock()
83+ for i := range s.previews {
84+ if s.previews[i].Name == name {
85+ p := s.previews[i]
86+ return &p, nil
87+ }
88+ }
89+ return nil, fmt.Errorf("preview %q: %w", name, domain.ErrPreviewNotFound)
90+}
91+
92+func (s *Service) List(_ context.Context) ([]domain.PreviewRoute, error) {
93+ s.mu.RLock()
94+ defer s.mu.RUnlock()
95+ cp := make([]domain.PreviewRoute, len(s.previews))
96+ copy(cp, s.previews)
97+ return cp, nil
98+}
99+
100+func (s *Service) Extend(ctx context.Context, name string, ttl time.Duration) error {
101+ s.mu.Lock()
102+ for i := range s.previews {
103+ if s.previews[i].Name == name {
104+ s.previews[i].ExpiresAt = time.Now().Add(ttl)
105+ cp := make([]domain.PreviewRoute, len(s.previews))
106+ copy(cp, s.previews)
107+ s.mu.Unlock()
108+ return s.store.Save(ctx, cp)
109+ }
110+ }
111+ s.mu.Unlock()
112+ return fmt.Errorf("preview %q: %w", name, domain.ErrPreviewNotFound)
113+}
114+
115+func (s *Service) GetExpired() []domain.PreviewRoute {
116+ s.mu.RLock()
117+ defer s.mu.RUnlock()
118+ var expired []domain.PreviewRoute
119+ for _, p := range s.previews {
120+ if p.IsExpired(time.Now()) {
121+ expired = append(expired, p)
122+ }
123+ }
124+ return expired
125+}
126+
127+// CleanupExpired removes expired previews from the service and persists.
128+// Returns the removed previews so the caller can teardown their resources.
129+func (s *Service) CleanupExpired(ctx context.Context) []domain.PreviewRoute {
130+ s.mu.Lock()
131+ var expired []domain.PreviewRoute
132+ var active []domain.PreviewRoute
133+ for _, p := range s.previews {
134+ if p.IsExpired(time.Now()) {
135+ expired = append(expired, p)
136+ } else {
137+ active = append(active, p)
138+ }
139+ }
140+ s.previews = active
141+ cp := make([]domain.PreviewRoute, len(active))
142+ copy(cp, active)
143+ s.mu.Unlock()
144+
145+ if len(expired) > 0 {
146+ if err := s.store.Save(ctx, cp); err != nil {
147+ // Log but don't fail — expired previews are already removed from memory
148+ log := zerowrap.FromCtx(ctx)
149+ log.Warn().Err(err).Msg("failed to persist preview state after cleanup")
150+ }
151+ }
152+ return expired
153+}
154+
155+// StartTicker starts a background goroutine that checks for expired previews.
156+// The teardownFn is called for each expired preview to clean up containers/volumes.
157+func (s *Service) StartTicker(ctx context.Context, interval time.Duration, teardownFn func(context.Context, domain.PreviewRoute)) {
158+ go func() {
159+ ticker := time.NewTicker(interval)
160+ defer ticker.Stop()
161+ for {
162+ select {
163+ case <-ctx.Done():
164+ return
165+ case <-ticker.C:
166+ expired := s.CleanupExpired(ctx)
167+ for _, p := range expired {
168+ teardownFn(ctx, p)
169+ }
170+ }
171+ }
172+ }()
173+}
174+
175+func (s *Service) AcquireNameLock(name string) *sync.Mutex {
176+ s.nameLockMu.Lock()
177+ defer s.nameLockMu.Unlock()
178+ entry, ok := s.nameLocks[name]
179+ if !ok {
180+ entry = &nameLockEntry{mu: &sync.Mutex{}}
181+ s.nameLocks[name] = entry
182+ }
183+ entry.refs++
184+ return entry.mu
185+}
186+
187+// ReleaseNameLock decrements the refcount and removes the per-name mutex when no longer in use.
188+func (s *Service) ReleaseNameLock(name string) {
189+ s.nameLockMu.Lock()
190+ defer s.nameLockMu.Unlock()
191+ entry, ok := s.nameLocks[name]
192+ if !ok {
193+ return
194+ }
195+ entry.refs--
196+ if entry.refs <= 0 {
197+ delete(s.nameLocks, name)
198+ }
199+}
200+
201+// CreatePreviewRequest contains parameters for creating a preview.
202+type CreatePreviewRequest struct {
203+ Name string
204+ Domain string
205+ BaseRoute string
206+ Image string
207+ HTTPS bool
208+ PreviewConfig domain.PreviewConfig
209+}
210+
211+// CreatePreview orchestrates the full preview creation: lock, clone volumes, start containers, register.
212+func (s *Service) CreatePreview(ctx context.Context, req CreatePreviewRequest) error {
213+ lock := s.AcquireNameLock(req.Name)
214+ lock.Lock()
215+ defer func() {
216+ lock.Unlock()
217+ s.ReleaseNameLock(req.Name)
218+ }()
219+
220+ now := time.Now()
221+ preview := domain.PreviewRoute{
222+ Domain: req.Domain,
223+ Image: req.Image,
224+ BaseRoute: req.BaseRoute,
225+ Name: req.Name,
226+ CreatedAt: now,
227+ ExpiresAt: now.Add(req.PreviewConfig.TTL),
228+ HTTPS: req.HTTPS,
229+ }
230+
231+ // TODO: Volume cloning and container startup will be wired in Task 13
232+ if err := s.Add(ctx, preview); err != nil {
233+ return fmt.Errorf("register preview %q: %w", req.Name, err)
234+ }
235+ return nil
236+}
A internal/usecase/auto/preview/service_test.go +109 -0
@@ -0,0 +1,109 @@
1+package preview
2+
3+import (
4+ "context"
5+ "testing"
6+ "time"
7+
8+ "github.com/stretchr/testify/assert"
9+ "github.com/stretchr/testify/require"
10+
11+ "github.com/bnema/gordon/internal/domain"
12+)
13+
14+type fakeStore struct {
15+ previews []domain.PreviewRoute
16+}
17+
18+func (f *fakeStore) Load(_ context.Context) ([]domain.PreviewRoute, error) {
19+ return f.previews, nil
20+}
21+func (f *fakeStore) Save(_ context.Context, p []domain.PreviewRoute) error {
22+ f.previews = p
23+ return nil
24+}
25+
26+func TestPreviewService_Add(t *testing.T) {
27+ store := &fakeStore{}
28+ svc := NewService(store, 48*time.Hour)
29+
30+ p := domain.PreviewRoute{
31+ Domain: "myapp--feat.example.com",
32+ Name: "feat",
33+ BaseRoute: "myapp.example.com",
34+ Image: "myapp:preview-feat",
35+ }
36+ err := svc.Add(context.Background(), p)
37+ require.NoError(t, err)
38+
39+ all, err := svc.List(context.Background())
40+ require.NoError(t, err)
41+ assert.Len(t, all, 1)
42+ assert.Equal(t, "feat", all[0].Name)
43+}
44+
45+func TestPreviewService_Delete(t *testing.T) {
46+ store := &fakeStore{
47+ previews: []domain.PreviewRoute{
48+ {Name: "feat", Domain: "myapp--feat.example.com"},
49+ },
50+ }
51+ svc := NewService(store, 48*time.Hour)
52+ require.NoError(t, svc.Load(context.Background()))
53+
54+ err := svc.Delete(context.Background(), "feat")
55+ require.NoError(t, err)
56+
57+ all, err := svc.List(context.Background())
58+ require.NoError(t, err)
59+ assert.Empty(t, all)
60+}
61+
62+func TestPreviewService_Get(t *testing.T) {
63+ store := &fakeStore{
64+ previews: []domain.PreviewRoute{
65+ {Name: "feat", Domain: "myapp--feat.example.com"},
66+ },
67+ }
68+ svc := NewService(store, 48*time.Hour)
69+ require.NoError(t, svc.Load(context.Background()))
70+
71+ p, err := svc.Get(context.Background(), "feat")
72+ require.NoError(t, err)
73+ assert.Equal(t, "myapp--feat.example.com", p.Domain)
74+
75+ _, err = svc.Get(context.Background(), "nonexistent")
76+ assert.Error(t, err)
77+}
78+
79+func TestPreviewService_Extend(t *testing.T) {
80+ now := time.Now()
81+ store := &fakeStore{
82+ previews: []domain.PreviewRoute{
83+ {Name: "feat", ExpiresAt: now.Add(1 * time.Hour)},
84+ },
85+ }
86+ svc := NewService(store, 48*time.Hour)
87+ require.NoError(t, svc.Load(context.Background()))
88+
89+ err := svc.Extend(context.Background(), "feat", 24*time.Hour)
90+ require.NoError(t, err)
91+
92+ p, _ := svc.Get(context.Background(), "feat")
93+ assert.True(t, p.ExpiresAt.After(now.Add(23*time.Hour)))
94+}
95+
96+func TestPreviewService_GetExpired(t *testing.T) {
97+ store := &fakeStore{
98+ previews: []domain.PreviewRoute{
99+ {Name: "expired", ExpiresAt: time.Now().Add(-1 * time.Hour)},
100+ {Name: "active", ExpiresAt: time.Now().Add(1 * time.Hour)},
101+ },
102+ }
103+ svc := NewService(store, 48*time.Hour)
104+ require.NoError(t, svc.Load(context.Background()))
105+
106+ expired := svc.GetExpired()
107+ assert.Len(t, expired, 1)
108+ assert.Equal(t, "expired", expired[0].Name)
109+}
A internal/usecase/auto/preview/ticker_test.go +32 -0
@@ -0,0 +1,32 @@
1+package preview
2+
3+import (
4+ "context"
5+ "testing"
6+ "time"
7+
8+ "github.com/stretchr/testify/assert"
9+ "github.com/stretchr/testify/require"
10+
11+ "github.com/bnema/gordon/internal/domain"
12+)
13+
14+func TestPreviewService_CleanupExpired(t *testing.T) {
15+ store := &fakeStore{
16+ previews: []domain.PreviewRoute{
17+ {Name: "expired", Domain: "a--expired.example.com", ExpiresAt: time.Now().Add(-1 * time.Hour)},
18+ {Name: "active", Domain: "a--active.example.com", ExpiresAt: time.Now().Add(24 * time.Hour)},
19+ },
20+ }
21+ svc := NewService(store, 48*time.Hour)
22+ require.NoError(t, svc.Load(context.Background()))
23+
24+ expired := svc.CleanupExpired(context.Background())
25+ assert.Len(t, expired, 1)
26+ assert.Equal(t, "expired", expired[0].Name)
27+
28+ all, err := svc.List(context.Background())
29+ require.NoError(t, err)
30+ assert.Len(t, all, 1)
31+ assert.Equal(t, "active", all[0].Name)
32+}
A internal/usecase/auto/preview/volumes.go +93 -0
@@ -0,0 +1,93 @@
1+package preview
2+
3+import (
4+ "context"
5+ "fmt"
6+ "strings"
7+
8+ "github.com/bnema/gordon/internal/domain"
9+)
10+
11+// VolumeCloner abstracts container runtime operations needed for volume cloning.
12+type VolumeCloner interface {
13+ CreateVolume(ctx context.Context, name string) error
14+ RemoveVolume(ctx context.Context, name string, force bool) error
15+ CreateContainer(ctx context.Context, config *domain.ContainerConfig) (*domain.Container, error)
16+ StartContainer(ctx context.Context, containerID string) error
17+ WaitForContainer(ctx context.Context, containerID string) error
18+ RemoveContainer(ctx context.Context, containerID string, force bool) error
19+}
20+
21+// BuildCloneVolumeName generates a preview volume name.
22+func BuildCloneVolumeName(previewName, volumeName string) string {
23+ return "preview-" + previewName + "-" + volumeName
24+}
25+
26+// BuildCloneContainerName generates a preview attachment container name from an image reference.
27+func BuildCloneContainerName(previewName, image string) string {
28+ name := image
29+ if i := strings.LastIndex(name, "/"); i >= 0 {
30+ name = name[i+1:]
31+ }
32+ if i := strings.Index(name, ":"); i >= 0 {
33+ name = name[:i]
34+ }
35+ return "preview-" + previewName + "-" + name
36+}
37+
38+// 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) {
41+ var clonedNames []string
42+ for volName, sourceVolName := range sourceVolumes {
43+ destName := BuildCloneVolumeName(previewName, volName)
44+
45+ if err := cloner.CreateVolume(ctx, destName); err != nil {
46+ cleanupVolumes(ctx, cloner, clonedNames)
47+ return nil, fmt.Errorf("create volume %s: %w", destName, err)
48+ }
49+
50+ helperConfig := &domain.ContainerConfig{
51+ Image: "busybox:1.37",
52+ Name: "gordon-vol-copy-" + destName,
53+ Cmd: []string{"cp", "-a", "/src/.", "/dst/"},
54+ Volumes: map[string]string{
55+ "/dst": destName,
56+ },
57+ ReadOnlyVolumes: map[string]string{
58+ "/src": sourceVolName,
59+ },
60+ }
61+
62+ created, err := cloner.CreateContainer(ctx, helperConfig)
63+ if err != nil {
64+ _ = cloner.RemoveVolume(ctx, destName, true)
65+ cleanupVolumes(ctx, cloner, clonedNames)
66+ return nil, fmt.Errorf("create copy helper for %s: %w", destName, err)
67+ }
68+
69+ if err := cloner.StartContainer(ctx, created.ID); err != nil {
70+ _ = cloner.RemoveContainer(ctx, created.ID, true)
71+ _ = cloner.RemoveVolume(ctx, destName, true)
72+ cleanupVolumes(ctx, cloner, clonedNames)
73+ return nil, fmt.Errorf("start copy helper for %s: %w", destName, err)
74+ }
75+
76+ if err := cloner.WaitForContainer(ctx, created.ID); err != nil {
77+ _ = cloner.RemoveContainer(ctx, created.ID, true)
78+ _ = cloner.RemoveVolume(ctx, destName, true)
79+ cleanupVolumes(ctx, cloner, clonedNames)
80+ return nil, fmt.Errorf("wait for copy helper %s: %w", destName, err)
81+ }
82+
83+ _ = cloner.RemoveContainer(ctx, created.ID, true)
84+ clonedNames = append(clonedNames, destName)
85+ }
86+ return clonedNames, nil
87+}
88+
89+func cleanupVolumes(ctx context.Context, cloner VolumeCloner, names []string) {
90+ for _, name := range names {
91+ _ = cloner.RemoveVolume(ctx, name, true)
92+ }
93+}
A internal/usecase/auto/preview/volumes_test.go +18 -0
@@ -0,0 +1,18 @@
1+package preview
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/assert"
7+)
8+
9+func TestBuildCloneVolumeName(t *testing.T) {
10+ assert.Equal(t, "preview-feat-pgdata", BuildCloneVolumeName("feat", "pgdata"))
11+ assert.Equal(t, "preview-pr-42-redis-data", BuildCloneVolumeName("pr-42", "redis-data"))
12+}
13+
14+func TestBuildCloneContainerName(t *testing.T) {
15+ assert.Equal(t, "preview-feat-postgres", BuildCloneContainerName("feat", "postgres:17"))
16+ assert.Equal(t, "preview-feat-redis", BuildCloneContainerName("feat", "redis:7"))
17+ assert.Equal(t, "preview-feat-myapp", BuildCloneContainerName("feat", "org/myapp:latest"))
18+}
A internal/usecase/auto/validation.go +58 -0
@@ -0,0 +1,58 @@
1+package auto
2+
3+import "strings"
4+
5+// MatchesDomainAllowlist reports whether domain matches any of the given patterns.
6+// Patterns may be exact domains, wildcard subdomains (*.example.com), or "*" to
7+// allow everything. Wildcard patterns match a single subdomain level only, per
8+// DNS/TLS conventions.
9+func MatchesDomainAllowlist(domain string, patterns []string) bool {
10+ domain = strings.ToLower(domain)
11+ for _, pattern := range patterns {
12+ pattern = strings.ToLower(strings.TrimSpace(pattern))
13+ if pattern == "" {
14+ continue
15+ }
16+ if pattern == "*" {
17+ return true
18+ }
19+ if pattern == domain {
20+ return true
21+ }
22+ if !strings.HasPrefix(pattern, "*.") {
23+ continue
24+ }
25+ // Wildcard patterns match single-level subdomains only (per DNS/TLS conventions).
26+ // e.g., "*.example.com" matches "foo.example.com" but not "bar.foo.example.com"
27+ suffix := strings.TrimPrefix(pattern, "*.")
28+ if !strings.HasSuffix(domain, "."+suffix) {
29+ continue
30+ }
31+ prefix := strings.TrimSuffix(domain, "."+suffix)
32+ if prefix != "" && !strings.Contains(prefix, ".") {
33+ return true
34+ }
35+ }
36+ return false
37+}
38+
39+// ExtractRepoName strips the registry domain, tag, and digest from an image
40+// reference and returns the bare repository name (e.g. "org/myapp").
41+func ExtractRepoName(imageRef, registryDomain string) string {
42+ imageRef = strings.ToLower(imageRef)
43+ if idx := strings.Index(imageRef, "@"); idx != -1 {
44+ imageRef = imageRef[:idx]
45+ }
46+ if idx := strings.LastIndex(imageRef, ":"); idx != -1 {
47+ slashIdx := strings.LastIndex(imageRef, "/")
48+ if idx > slashIdx {
49+ imageRef = imageRef[:idx]
50+ }
51+ }
52+ registryPrefix := strings.ToLower(strings.TrimSuffix(registryDomain, "/"))
53+ if registryPrefix != "" {
54+ prefix := registryPrefix + "/"
55+ imageRef = strings.TrimPrefix(imageRef, prefix)
56+ }
57+ return imageRef
58+}
A internal/usecase/auto/validation_test.go +59 -0
@@ -0,0 +1,59 @@
1+package auto
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/assert"
7+)
8+
9+func TestMatchesDomainAllowlist(t *testing.T) {
10+ tests := []struct {
11+ name string
12+ domain string
13+ patterns []string
14+ want bool
15+ }{
16+ {name: "exact match", domain: "app.example.com", patterns: []string{"app.example.com"}, want: true},
17+ {name: "no match", domain: "app.example.com", patterns: []string{"api.example.com"}, want: false},
18+ {name: "wildcard match", domain: "app.example.com", patterns: []string{"*.example.com"}, want: true},
19+ {name: "wildcard no match on root", domain: "example.com", patterns: []string{"*.example.com"}, want: false},
20+ {name: "wildcard matches one level only", domain: "api.app.example.com", patterns: []string{"*.example.com"}, want: false},
21+ {name: "empty allowlist", domain: "app.example.com", patterns: nil, want: false},
22+ {name: "case insensitive", domain: "App.Example.Com", patterns: []string{"APP.EXAMPLE.COM"}, want: true},
23+ {name: "multiple patterns", domain: "api.example.com", patterns: []string{"foo.com", "*.example.com"}, want: true},
24+ {name: "star allows all", domain: "anything.example.com", patterns: []string{"*"}, want: true},
25+ {name: "empty pattern skipped", domain: "app.example.com", patterns: []string{"", "app.example.com"}, want: true},
26+ {name: "whitespace trimmed in pattern", domain: "app.example.com", patterns: []string{" app.example.com "}, want: true},
27+ }
28+
29+ for _, tt := range tests {
30+ t.Run(tt.name, func(t *testing.T) {
31+ assert.Equal(t, tt.want, MatchesDomainAllowlist(tt.domain, tt.patterns))
32+ })
33+ }
34+}
35+
36+func TestExtractRepoName(t *testing.T) {
37+ tests := []struct {
38+ name string
39+ imageRef string
40+ registryDomain string
41+ want string
42+ }{
43+ {name: "simple tag", imageRef: "myapp:latest", want: "myapp"},
44+ {name: "version tag", imageRef: "myapp:v2.0.0", want: "myapp"},
45+ {name: "digest", imageRef: "myapp@sha256:abc123", want: "myapp"},
46+ {name: "strip registry", imageRef: "registry.example.com/myapp:latest", registryDomain: "registry.example.com", want: "myapp"},
47+ {name: "org image", imageRef: "org/myapp:latest", want: "org/myapp"},
48+ {name: "registry org image", imageRef: "registry.example.com/org/myapp:latest", registryDomain: "registry.example.com", want: "org/myapp"},
49+ {name: "lowercase", imageRef: "MyApp:Latest", want: "myapp"},
50+ {name: "no tag no digest", imageRef: "myapp", want: "myapp"},
51+ {name: "empty registry domain", imageRef: "myapp:latest", registryDomain: "", want: "myapp"},
52+ }
53+
54+ for _, tt := range tests {
55+ t.Run(tt.name, func(t *testing.T) {
56+ assert.Equal(t, tt.want, ExtractRepoName(tt.imageRef, tt.registryDomain))
57+ })
58+ }
59+}
M internal/usecase/config/service.go +87 -4
@@ -33,6 +33,11 @@ type Config struct {
3333 DataDir string
3434 AutoRouteEnabled bool
3535 AutoRouteAllowedDomains []string `mapstructure:"auto_route_allowed_domains" json:"auto_route_allowed_domains,omitempty"`
36+ PreviewEnabled bool
37+ PreviewTTL time.Duration
38+ PreviewSeparator string
39+ PreviewTagPatterns []string
40+ PreviewDataCopy bool
3641 NetworkIsolation bool
3742 NetworkPrefix string
3843 Routes map[string]string
@@ -122,13 +127,43 @@ func (s *Service) loadConfigValues() Config {
122127 registryDomain = s.viper.GetString("server.registry_domain")
123128 }
124129
130+ // Backward compat: try new keys first, fall back to old
131+ autoEnabled := s.viper.GetBool("auto.enabled")
132+ if !autoEnabled {
133+ autoEnabled = s.viper.GetBool("auto_route.enabled")
134+ }
135+ allowedDomains := s.viper.GetStringSlice("auto.allowed_domains")
136+ if len(allowedDomains) == 0 {
137+ allowedDomains = s.viper.GetStringSlice("auto_route_allowed_domains")
138+ }
139+
140+ // Preview config
141+ previewTTLStr := s.viper.GetString("auto.preview.ttl")
142+ previewTTL, err := time.ParseDuration(previewTTLStr)
143+ if err != nil && previewTTLStr != "" {
144+ log := zerowrap.FromCtx(context.Background())
145+ log.Warn().Err(err).Str("ttl", previewTTLStr).Msg("invalid preview TTL format, using default 48h (use Go duration strings, e.g. \"168h\" for 7 days)")
146+ }
147+ if previewTTL == 0 {
148+ previewTTL = 48 * time.Hour
149+ }
150+ previewSep := s.viper.GetString("auto.preview.separator")
151+ if previewSep == "" {
152+ previewSep = "--"
153+ }
154+
125155 return Config{
126156 ServerPort: s.viper.GetInt("server.port"),
127157 RegistryPort: s.viper.GetInt("server.registry_port"),
128158 RegistryDomain: registryDomain,
129159 DataDir: s.viper.GetString("server.data_dir"),
130- AutoRouteEnabled: s.viper.GetBool("auto_route.enabled"),
131- AutoRouteAllowedDomains: append([]string{}, s.viper.GetStringSlice("auto_route_allowed_domains")...),
160+ AutoRouteEnabled: autoEnabled,
161+ AutoRouteAllowedDomains: append([]string{}, allowedDomains...),
162+ PreviewEnabled: s.viper.GetBool("auto.preview.enabled"),
163+ PreviewTTL: previewTTL,
164+ PreviewSeparator: previewSep,
165+ PreviewTagPatterns: append([]string{}, s.viper.GetStringSlice("auto.preview.tag_patterns")...),
166+ PreviewDataCopy: s.viper.GetBool("auto.preview.data_copy"),
132167 NetworkIsolation: s.viper.GetBool("network_isolation.enabled"),
133168 NetworkPrefix: s.viper.GetString("network_isolation.network_prefix"),
134169 RegistryAuthEnabled: s.viper.GetBool("auth.enabled"),
@@ -519,7 +554,7 @@ type configSnapshot struct {
519554 }
520555
521556 // mutableSections lists config sections that are intentionally modified by service operations.
522-var mutableSections = []string{"routes.", "external_routes.", "attachments.", "network_groups.", "auto_route_allowed_domains"}
557+var mutableSections = []string{"routes.", "external_routes.", "attachments.", "network_groups.", "auto_route_allowed_domains", "auto."}
523558
524559 func isMutableKey(key string) bool {
525560 for _, prefix := range mutableSections {
@@ -529,7 +564,7 @@ func isMutableKey(key string) bool {
529564 }
530565
531566 switch key {
532- case "routes", "external_routes", "attachments", "network_groups", "auto_route_allowed_domains":
567+ case "routes", "external_routes", "attachments", "network_groups", "auto_route_allowed_domains", "auto":
533568 return true
534569 }
535570
@@ -570,6 +605,7 @@ func (s *Service) writeConfigSurgical(configFile string) error {
570605 s.viper.Set("attachments", s.config.Attachments)
571606 s.viper.Set("network_groups", s.config.NetworkGroups)
572607 s.viper.Set("auto_route_allowed_domains", s.config.AutoRouteAllowedDomains)
608+ s.viper.Set("auto.allowed_domains", s.config.AutoRouteAllowedDomains)
573609 return s.viper.WriteConfigAs(configFile)
574610 }
575611 return fmt.Errorf("failed to read config file: %w", err)
@@ -589,7 +625,15 @@ func (s *Service) writeConfigSurgical(configFile string) error {
589625 config["external_routes"] = s.config.ExternalRoutes
590626 config["attachments"] = s.config.Attachments
591627 config["network_groups"] = s.config.NetworkGroups
628+ // Write to both old and new keys for backward compat
592629 config["auto_route_allowed_domains"] = s.config.AutoRouteAllowedDomains
630+ if autoSection, ok := config["auto"].(map[string]any); ok {
631+ autoSection["allowed_domains"] = s.config.AutoRouteAllowedDomains
632+ } else {
633+ config["auto"] = map[string]any{
634+ "allowed_domains": s.config.AutoRouteAllowedDomains,
635+ }
636+ }
593637
594638 out, err := toml.Marshal(config)
595639 if err != nil {
@@ -1050,6 +1094,45 @@ func (s *Service) RemoveAttachment(ctx context.Context, domainOrGroup, image str
10501094 return nil
10511095 }
10521096
1097+// GetPreviewConfig returns the preview environment configuration.
1098+func (s *Service) GetPreviewConfig() domain.PreviewConfig {
1099+ s.mu.RLock()
1100+ defer s.mu.RUnlock()
1101+ return domain.PreviewConfig{
1102+ Enabled: s.config.PreviewEnabled,
1103+ TTL: s.config.PreviewTTL,
1104+ Separator: s.config.PreviewSeparator,
1105+ TagPatterns: s.config.PreviewTagPatterns,
1106+ DataCopy: s.config.PreviewDataCopy,
1107+ }
1108+}
1109+
1110+// IsPreviewEnabled returns whether preview environments are enabled.
1111+func (s *Service) IsPreviewEnabled() bool {
1112+ s.mu.RLock()
1113+ defer s.mu.RUnlock()
1114+ return s.config.PreviewEnabled
1115+}
1116+
1117+// IsAutoEnabled returns whether the auto feature is enabled (alias for IsAutoRouteEnabled).
1118+func (s *Service) IsAutoEnabled() bool {
1119+ return s.IsAutoRouteEnabled()
1120+}
1121+
1122+// GetPreviewTagPatterns returns the configured tag patterns for preview environments.
1123+func (s *Service) GetPreviewTagPatterns() []string {
1124+ s.mu.RLock()
1125+ defer s.mu.RUnlock()
1126+ return append([]string{}, s.config.PreviewTagPatterns...)
1127+}
1128+
1129+// GetAllowedDomains returns the auto-route allowed domain patterns.
1130+func (s *Service) GetAllowedDomains() []string {
1131+ s.mu.RLock()
1132+ defer s.mu.RUnlock()
1133+ return append([]string{}, s.config.AutoRouteAllowedDomains...)
1134+}
1135+
10531136 // GetAutoRouteAllowedDomains returns the configured auto-route domain allowlist.
10541137 func (s *Service) GetAutoRouteAllowedDomains(_ context.Context) ([]string, error) {
10551138 s.mu.RLock()
M internal/usecase/container/autoroute.go +7 -151
@@ -4,9 +4,7 @@ import (
44 "bufio"
55 "bytes"
66 "context"
7- "encoding/json"
87 "fmt"
9- "io"
108 "os"
119 "path/filepath"
1210 "sort"
@@ -17,6 +15,7 @@ import (
1715 "github.com/bnema/gordon/internal/boundaries/in"
1816 "github.com/bnema/gordon/internal/boundaries/out"
1917 "github.com/bnema/gordon/internal/domain"
18+ "github.com/bnema/gordon/internal/usecase/auto"
2019 )
2120
2221 // EnvFileExtractor defines the interface for extracting env files from images.
@@ -205,7 +204,7 @@ func (h *AutoRouteHandler) createOrUpdateRoute(ctx context.Context, routeDomain,
205204 existingRoutes := h.configSvc.GetRoutes(ctx)
206205 for _, existing := range existingRoutes {
207206 if existing.Domain == routeDomain {
208- if extractRepoName(existing.Image, h.registryDomain) != extractRepoName(imageName, h.registryDomain) {
207+ if auto.ExtractRepoName(existing.Image, h.registryDomain) != auto.ExtractRepoName(imageName, h.registryDomain) {
209208 log.Warn().Str("domain", routeDomain).Str("existing_image", existing.Image).Str("image", imageName).Msg("auto-route update rejected due to repository ownership mismatch")
210209 return false
211210 }
@@ -222,7 +221,7 @@ func (h *AutoRouteHandler) createOrUpdateRoute(ctx context.Context, routeDomain,
222221 }
223222 }
224223
225- if !matchesDomainAllowlist(routeDomain, allowedDomains) {
224+ if !auto.MatchesDomainAllowlist(routeDomain, allowedDomains) {
226225 log.Warn().Str("domain", routeDomain).Strs("allowed_domains", allowedDomains).Msg("auto-route create rejected due to domain allowlist")
227226 return false
228227 }
@@ -239,55 +238,6 @@ func (h *AutoRouteHandler) createOrUpdateRoute(ctx context.Context, routeDomain,
239238 return false
240239 }
241240
242-func matchesDomainAllowlist(domain string, patterns []string) bool {
243- domain = strings.ToLower(domain)
244- for _, pattern := range patterns {
245- pattern = strings.ToLower(strings.TrimSpace(pattern))
246- if pattern == "" {
247- continue
248- }
249- if pattern == "*" {
250- return true
251- }
252- if pattern == domain {
253- return true
254- }
255- if !strings.HasPrefix(pattern, "*.") {
256- continue
257- }
258- // Wildcard patterns match single-level subdomains only (per DNS/TLS conventions).
259- // e.g., "*.example.com" matches "foo.example.com" but not "bar.foo.example.com"
260- suffix := strings.TrimPrefix(pattern, "*.")
261- if !strings.HasSuffix(domain, "."+suffix) {
262- continue
263- }
264- prefix := strings.TrimSuffix(domain, "."+suffix)
265- if prefix != "" && !strings.Contains(prefix, ".") {
266- return true
267- }
268- }
269- return false
270-}
271-
272-func extractRepoName(imageRef, registryDomain string) string {
273- imageRef = strings.ToLower(imageRef)
274- if idx := strings.Index(imageRef, "@"); idx != -1 {
275- imageRef = imageRef[:idx]
276- }
277- if idx := strings.LastIndex(imageRef, ":"); idx != -1 {
278- slashIdx := strings.LastIndex(imageRef, "/")
279- if idx > slashIdx {
280- imageRef = imageRef[:idx]
281- }
282- }
283- registryPrefix := strings.ToLower(strings.TrimSuffix(registryDomain, "/"))
284- if registryPrefix != "" {
285- prefix := registryPrefix + "/"
286- imageRef = strings.TrimPrefix(imageRef, prefix)
287- }
288- return imageRef
289-}
290-
291241 // triggerDeploy initiates deployment for a route.
292242 func (h *AutoRouteHandler) triggerDeploy(ctx context.Context, route domain.Route) {
293243 log := zerowrap.FromCtx(ctx)
@@ -389,108 +339,14 @@ func (h *AutoRouteHandler) CanHandle(eventType domain.EventType) bool {
389339 func (h *AutoRouteHandler) extractLabels(ctx context.Context, manifestData []byte) (*domain.ImageLabels, error) {
390340 log := zerowrap.FromCtx(ctx)
391341
392- // Parse manifest to get config digest
393- configDigest, err := parseConfigDigest(manifestData)
342+ labels, err := auto.ExtractLabels(ctx, manifestData, h.blobStorage)
394343 if err != nil {
395- return nil, fmt.Errorf("failed to parse manifest: %w", err)
396- }
397-
398- if configDigest == "" {
399- return nil, fmt.Errorf("no config digest found in manifest")
400- }
401-
402- log.Debug().Str("config_digest", configDigest).Msg("found config digest")
403-
404- // Read config blob
405- reader, err := h.blobStorage.GetBlob(configDigest)
406- if err != nil {
407- return nil, fmt.Errorf("failed to get config blob: %w", err)
408- }
409- defer reader.Close()
410-
411- configData, err := io.ReadAll(reader)
412- if err != nil {
413- return nil, fmt.Errorf("failed to read config blob: %w", err)
414- }
415-
416- // Parse config to extract labels
417- labels, err := parseImageLabels(configData)
418- if err != nil {
419- return nil, fmt.Errorf("failed to parse config: %w", err)
420- }
421-
422- return labels, nil
423-}
424-
425-// manifestSchema represents the relevant parts of an OCI/Docker manifest.
426-type manifestSchema struct {
427- SchemaVersion int `json:"schemaVersion"`
428- MediaType string `json:"mediaType"`
429- Config struct {
430- MediaType string `json:"mediaType"`
431- Digest string `json:"digest"`
432- Size int64 `json:"size"`
433- } `json:"config"`
434-}
435-
436-// parseConfigDigest extracts the config digest from a manifest.
437-func parseConfigDigest(manifestData []byte) (string, error) {
438- var manifest manifestSchema
439- if err := json.Unmarshal(manifestData, &manifest); err != nil {
440- return "", err
441- }
442-
443- return manifest.Config.Digest, nil
444-}
445-
446-// imageConfig represents the relevant parts of an OCI/Docker image config.
447-type imageConfig struct {
448- Config struct {
449- Labels map[string]string `json:"Labels"`
450- } `json:"config"`
451-}
452-
453-// parseImageLabels extracts Gordon labels from an image config.
454-func parseImageLabels(configData []byte) (*domain.ImageLabels, error) {
455- var config imageConfig
456- if err := json.Unmarshal(configData, &config); err != nil {
457344 return nil, err
458345 }
459346
460- labels := &domain.ImageLabels{}
461-
462- if config.Config.Labels == nil {
463- return labels, nil
464- }
465-
466- // Extract gordon.* labels
467- if v, ok := config.Config.Labels[domain.LabelDomain]; ok {
468- labels.Domain = strings.TrimSpace(v)
469- }
470-
471- if v, ok := config.Config.Labels[domain.LabelDomains]; ok {
472- // Parse comma-separated domains
473- for _, d := range strings.Split(v, ",") {
474- d = strings.TrimSpace(d)
475- if d != "" {
476- labels.Domains = append(labels.Domains, d)
477- }
478- }
479- }
480-
481- if v, ok := config.Config.Labels[domain.LabelHealth]; ok {
482- labels.Health = strings.TrimSpace(v)
483- }
484-
485- for _, key := range []string{domain.LabelProxyPort, domain.LabelPort} {
486- if v, ok := config.Config.Labels[key]; ok {
487- labels.Port = strings.TrimSpace(v)
488- break
489- }
490- }
491-
492- if v, ok := config.Config.Labels[domain.LabelEnvFile]; ok {
493- labels.EnvFile = strings.TrimSpace(v)
347+ // Log the config digest for observability (best-effort parse for logging only)
348+ if digest, parseErr := auto.ParseConfigDigest(manifestData); parseErr == nil && digest != "" {
349+ log.Debug().Str("config_digest", digest).Msg("found config digest")
494350 }
495351
496352 return labels, nil
M internal/usecase/container/autoroute_test.go +10 -8
@@ -15,6 +15,7 @@ import (
1515 inmocks "github.com/bnema/gordon/internal/boundaries/in/mocks"
1616 "github.com/bnema/gordon/internal/boundaries/out/mocks"
1717 "github.com/bnema/gordon/internal/domain"
18+ "github.com/bnema/gordon/internal/usecase/auto"
1819 )
1920
2021 // domainToEnvFileName tests
@@ -154,7 +155,8 @@ func TestParseEnvFile(t *testing.T) {
154155 }
155156 }
156157
157-// parseConfigDigest tests
158+// parseConfigDigest tests — now delegated to auto package; tested there directly.
159+// These tests call auto.ParseConfigDigest to maintain coverage for this package.
158160
159161 func TestParseConfigDigest(t *testing.T) {
160162 tests := []struct {
@@ -206,7 +208,7 @@ func TestParseConfigDigest(t *testing.T) {
206208 data, err := json.Marshal(tt.manifest)
207209 require.NoError(t, err)
208210
209- result, err := parseConfigDigest(data)
211+ result, err := auto.ParseConfigDigest(data)
210212
211213 if tt.wantErr {
212214 assert.Error(t, err)
@@ -220,11 +222,11 @@ func TestParseConfigDigest(t *testing.T) {
220222 }
221223
222224 func TestParseConfigDigest_InvalidJSON(t *testing.T) {
223- _, err := parseConfigDigest([]byte("invalid json"))
225+ _, err := auto.ParseConfigDigest([]byte("invalid json"))
224226 assert.Error(t, err)
225227 }
226228
227-// parseImageLabels tests
229+// parseImageLabels tests — now delegated to auto package; tested there directly.
228230
229231 func TestParseImageLabels(t *testing.T) {
230232 tests := []struct {
@@ -317,7 +319,7 @@ func TestParseImageLabels(t *testing.T) {
317319 data, err := json.Marshal(tt.config)
318320 require.NoError(t, err)
319321
320- result, err := parseImageLabels(data)
322+ result, err := auto.ParseImageLabels(data)
321323 require.NoError(t, err)
322324
323325 assert.Equal(t, tt.expected.Domain, result.Domain)
@@ -330,7 +332,7 @@ func TestParseImageLabels(t *testing.T) {
330332 }
331333
332334 func TestParseImageLabels_InvalidJSON(t *testing.T) {
333- _, err := parseImageLabels([]byte("invalid json"))
335+ _, err := auto.ParseImageLabels([]byte("invalid json"))
334336 assert.Error(t, err)
335337 }
336338
@@ -353,7 +355,7 @@ func TestMatchesDomainAllowlist(t *testing.T) {
353355
354356 for _, tt := range tests {
355357 t.Run(tt.name, func(t *testing.T) {
356- assert.Equal(t, tt.want, matchesDomainAllowlist(tt.domain, tt.patterns))
358+ assert.Equal(t, tt.want, auto.MatchesDomainAllowlist(tt.domain, tt.patterns))
357359 })
358360 }
359361 }
@@ -376,7 +378,7 @@ func TestExtractRepoName(t *testing.T) {
376378
377379 for _, tt := range tests {
378380 t.Run(tt.name, func(t *testing.T) {
379- assert.Equal(t, tt.want, extractRepoName(tt.imageRef, tt.registryDomain))
381+ assert.Equal(t, tt.want, auto.ExtractRepoName(tt.imageRef, tt.registryDomain))
380382 })
381383 }
382384 }
M pkg/runtime/interface.go +1 -0
@@ -67,6 +67,7 @@ type Runtime interface {
6767 CreateContainer(ctx context.Context, config *ContainerConfig) (*Container, error)
6868 StartContainer(ctx context.Context, containerID string) error
6969 StopContainer(ctx context.Context, containerID string) error
70+ WaitForContainer(ctx context.Context, containerID string) error
7071 RestartContainer(ctx context.Context, containerID string) error
7172 RemoveContainer(ctx context.Context, containerID string, force bool) error
7273

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