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
181 181
182 ### Authentication Failed182 ### Authentication Failed
183 183
184-```184+```text
185 Error: failed to verify token185 Error: failed to verify token
186 ```186 ```
187 187
M docs/index.md +1 -0
@@ -50,6 +50,7 @@ Gordon runs on your VPS and provides:
50 - [Authentication](./config/auth.md) - Registry auth plus remote CLI login/token workflows50 - [Authentication](./config/auth.md) - Registry auth plus remote CLI login/token workflows
51 - [Secrets](./config/secrets.md) - Secure credential storage51 - [Secrets](./config/secrets.md) - Secure credential storage
52 - [Network Isolation](./config/network-isolation.md) - Per-app network isolation52 - [Network Isolation](./config/network-isolation.md) - Per-app network isolation
53+- [Preview Environments](./config/preview.md) - Ephemeral per-branch deployments
53 - [Attachments](./config/attachments.md) - Service dependencies54 - [Attachments](./config/attachments.md) - Service dependencies
54 - [Logging](./config/logging.md) - Log collection and rotation55 - [Logging](./config/logging.md) - Log collection and rotation
55 - [Environment Variables](./config/env.md) - Per-route environment configuration56 - [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:
145 autorouteCmd.GroupID = groupManage145 autorouteCmd.GroupID = groupManage
146 rootCmd.AddCommand(autorouteCmd)146 rootCmd.AddCommand(autorouteCmd)
147 147
148+ previewCmd := newPreviewCmd()
149+ previewCmd.GroupID = groupManage
150+ rootCmd.AddCommand(previewCmd)
151+
148 // Client-only commands (no server needed)152 // Client-only commands (no server needed)
149 remotesCmd := newRemotesCmd()153 remotesCmd := newRemotesCmd()
150 remotesCmd.GroupID = groupClient154 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) {
24 secretSvc := inmocks.NewMockSecretService(t)24 secretSvc := inmocks.NewMockSecretService(t)
25 registrySvc := inmocks.NewMockRegistryService(t)25 registrySvc := inmocks.NewMockRegistryService(t)
26 26
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+ })
28 34
29 payload := dto.BootstrapRequest{35 payload := dto.BootstrapRequest{
30 Domain: "app.example.com",36 Domain: "app.example.com",
@@ -73,7 +79,12 @@ func TestBootstrap_Idempotent_Rerun(t *testing.T) {
73 containerSvc := inmocks.NewMockContainerService(t)79 containerSvc := inmocks.NewMockContainerService(t)
74 secretSvc := inmocks.NewMockSecretService(t)80 secretSvc := inmocks.NewMockSecretService(t)
75 81
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+ })
77 88
78 payload := dto.BootstrapRequest{89 payload := dto.BootstrapRequest{
79 Domain: "app.example.com",90 Domain: "app.example.com",
@@ -137,7 +148,12 @@ func TestBootstrap_MissingDomain(t *testing.T) {
137 containerSvc := inmocks.NewMockContainerService(t)148 containerSvc := inmocks.NewMockContainerService(t)
138 secretSvc := inmocks.NewMockSecretService(t)149 secretSvc := inmocks.NewMockSecretService(t)
139 150
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+ })
141 157
142 body, err := json.Marshal(dto.BootstrapRequest{Image: "myapp:latest"})158 body, err := json.Marshal(dto.BootstrapRequest{Image: "myapp:latest"})
143 require.NoError(t, err)159 require.NoError(t, err)
@@ -158,7 +174,12 @@ func TestBootstrap_MissingImage(t *testing.T) {
158 containerSvc := inmocks.NewMockContainerService(t)174 containerSvc := inmocks.NewMockContainerService(t)
159 secretSvc := inmocks.NewMockSecretService(t)175 secretSvc := inmocks.NewMockSecretService(t)
160 176
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+ })
162 183
163 body, err := json.Marshal(dto.BootstrapRequest{Domain: "app.example.com"})184 body, err := json.Marshal(dto.BootstrapRequest{Domain: "app.example.com"})
164 require.NoError(t, err)185 require.NoError(t, err)
@@ -179,7 +200,12 @@ func TestBootstrap_NormalizesImageBeforeStoring(t *testing.T) {
179 containerSvc := inmocks.NewMockContainerService(t)200 containerSvc := inmocks.NewMockContainerService(t)
180 secretSvc := inmocks.NewMockSecretService(t)201 secretSvc := inmocks.NewMockSecretService(t)
181 202
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+ })
183 209
184 payload := dto.BootstrapRequest{Domain: "app.example.com", Image: "pitlane:v1"}210 payload := dto.BootstrapRequest{Domain: "app.example.com", Image: "pitlane:v1"}
185 configSvc.EXPECT().GetRegistryDomain().Return("reg.bnema.dev").Once()211 configSvc.EXPECT().GetRegistryDomain().Return("reg.bnema.dev").Once()
@@ -225,7 +251,12 @@ func TestBootstrap_AddRouteValidationErrors(t *testing.T) {
225 containerSvc := inmocks.NewMockContainerService(t)251 containerSvc := inmocks.NewMockContainerService(t)
226 secretSvc := inmocks.NewMockSecretService(t)252 secretSvc := inmocks.NewMockSecretService(t)
227 253
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+ })
229 260
230 payload := dto.BootstrapRequest{Domain: "app.example.com", Image: "myapp:latest"}261 payload := dto.BootstrapRequest{Domain: "app.example.com", Image: "myapp:latest"}
231 configSvc.EXPECT().GetRegistryDomain().Return("reg.bnema.dev").Once()262 configSvc.EXPECT().GetRegistryDomain().Return("reg.bnema.dev").Once()
@@ -252,7 +283,12 @@ func TestBootstrap_PartialFailure_AttachmentError(t *testing.T) {
252 containerSvc := inmocks.NewMockContainerService(t)283 containerSvc := inmocks.NewMockContainerService(t)
253 secretSvc := inmocks.NewMockSecretService(t)284 secretSvc := inmocks.NewMockSecretService(t)
254 285
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+ })
256 292
257 payload := dto.BootstrapRequest{293 payload := dto.BootstrapRequest{
258 Domain: "app.example.com",294 Domain: "app.example.com",
M internal/adapters/in/http/admin/handler.go +34 -35
@@ -47,6 +47,7 @@ type Handler struct {
47 logSvc in.LogService47 logSvc in.LogService
48 volumeSvc in.VolumeService48 volumeSvc in.VolumeService
49 registrySvc registryDeployService49 registrySvc registryDeployService
50+ previewSvc previewService
50 eventBus out.EventPublisher51 eventBus out.EventPublisher
51 log zerowrap.Logger52 log zerowrap.Logger
52 }53 }
@@ -130,46 +131,42 @@ func toDatabaseInfoResponse(db domain.DBInfo) dto.DatabaseInfo {
130 }131 }
131 }132 }
132 133
133-// NewHandler creates a new admin HTTP handler.134+// HandlerDeps contains all dependencies for the admin HTTP handler.
134-func NewHandler(135+type HandlerDeps struct {
135- configSvc in.ConfigService,136+ ConfigSvc in.ConfigService
136- authSvc in.AuthService,137+ AuthSvc in.AuthService
137- containerSvc in.ContainerService,138+ ContainerSvc in.ContainerService
138- healthSvc in.HealthService,139+ HealthSvc in.HealthService
139- secretSvc in.SecretService,140+ SecretSvc in.SecretService
140- logSvc in.LogService,141+ LogSvc in.LogService
141- registrySvc registryDeployService,142+ RegistrySvc registryDeployService
142- eventBus out.EventPublisher,143+ EventBus out.EventPublisher
143- log zerowrap.Logger,144+ Log zerowrap.Logger
144- backupSvc in.BackupService,145+ BackupSvc in.BackupService
145- imageSvcs ...in.ImageService,146+ PreviewSvc previewService
146-) *Handler {147+ ImageSvc in.ImageService
147- var imageSvc in.ImageService148+ VolumeSvc in.VolumeService
148- if len(imageSvcs) > 0 {149+}
149- imageSvc = imageSvcs[0]
150- }
151 150
151+// NewHandler creates a new admin HTTP handler.
152+func NewHandler(deps HandlerDeps) *Handler {
152 return &Handler{153 return &Handler{
153- configSvc: configSvc,154+ configSvc: deps.ConfigSvc,
154- authSvc: authSvc,155+ authSvc: deps.AuthSvc,
155- containerSvc: containerSvc,156+ containerSvc: deps.ContainerSvc,
156- backupSvc: backupSvc,157+ backupSvc: deps.BackupSvc,
157- imageSvc: imageSvc,158+ imageSvc: deps.ImageSvc,
158- healthSvc: healthSvc,159+ healthSvc: deps.HealthSvc,
159- secretSvc: secretSvc,160+ secretSvc: deps.SecretSvc,
160- logSvc: logSvc,161+ logSvc: deps.LogSvc,
161- registrySvc: registrySvc,162+ volumeSvc: deps.VolumeSvc,
162- eventBus: eventBus,163+ registrySvc: deps.RegistrySvc,
163- log: log,164+ previewSvc: deps.PreviewSvc,
165+ eventBus: deps.EventBus,
166+ log: deps.Log,
164 }167 }
165 }168 }
166 169
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-
173 // RegisterRoutes registers the admin routes on the given mux.170 // RegisterRoutes registers the admin routes on the given mux.
174 func (h *Handler) RegisterRoutes(mux *http.ServeMux) {171 func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
175 mux.HandleFunc("/admin/", h.handleAdminRoutes)172 mux.HandleFunc("/admin/", h.handleAdminRoutes)
@@ -239,6 +236,8 @@ func (h *Handler) matchRoute(path string) (routeHandler, bool) {
239 {"/images", h.handleImages},236 {"/images", h.handleImages},
240 {"/logs", h.handleLogs},237 {"/logs", h.handleLogs},
241 {"/autoroute/allowed-domains", h.handleAutoRouteAllowedDomains},238 {"/autoroute/allowed-domains", h.handleAutoRouteAllowedDomains},
239+ {"/previews", h.handlePreviewList},
240+ {"/preview", h.handlePreviewAction},
242 }241 }
243 for _, route := range prefixRoutes {242 for _, route := range prefixRoutes {
244 if path == route.prefix || strings.HasPrefix(path, route.prefix+"/") {243 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 {
50 return context.WithValue(ctx, domain.ContextKeyScopes, scopes)50 return context.WithValue(ctx, domain.ContextKeyScopes, scopes)
51 }51 }
52 52
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+
53 // Routes endpoint tests69 // Routes endpoint tests
54 70
55 func TestHandler_RoutesGet_RequiresReadScope(t *testing.T) {71 func TestHandler_RoutesGet_RequiresReadScope(t *testing.T) {
@@ -58,7 +74,13 @@ func TestHandler_RoutesGet_RequiresReadScope(t *testing.T) {
58 containerSvc := inmocks.NewMockContainerService(t)74 containerSvc := inmocks.NewMockContainerService(t)
59 secretSvc := inmocks.NewMockSecretService(t)75 secretSvc := inmocks.NewMockSecretService(t)
60 76
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+ })
62 84
63 tests := []struct {85 tests := []struct {
64 name string86 name string
@@ -119,9 +141,13 @@ func TestHandler_RoutesPost_RequiresWriteScope(t *testing.T) {
119 authSvc := inmocks.NewMockAuthService(t)141 authSvc := inmocks.NewMockAuthService(t)
120 containerSvc := inmocks.NewMockContainerService(t)142 containerSvc := inmocks.NewMockContainerService(t)
121 secretSvc := inmocks.NewMockSecretService(t)143 secretSvc := inmocks.NewMockSecretService(t)
122- registrySvc := inmocks.NewMockRegistryService(t)
123 144
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+ })
125 151
126 routeJSON := `{"domain": "app.example.com", "image": "myapp:latest"}`152 routeJSON := `{"domain": "app.example.com", "image": "myapp:latest"}`
127 153
@@ -179,9 +205,13 @@ func TestHandler_RoutesPut_RequiresWriteScope(t *testing.T) {
179 authSvc := inmocks.NewMockAuthService(t)205 authSvc := inmocks.NewMockAuthService(t)
180 containerSvc := inmocks.NewMockContainerService(t)206 containerSvc := inmocks.NewMockContainerService(t)
181 secretSvc := inmocks.NewMockSecretService(t)207 secretSvc := inmocks.NewMockSecretService(t)
182- registrySvc := inmocks.NewMockRegistryService(t)
183 208
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+ })
185 215
186 routeJSON := `{"image": "myapp:v2"}`216 routeJSON := `{"image": "myapp:v2"}`
187 217
@@ -227,7 +257,13 @@ func TestHandler_RoutesPost_AllowsRouteCreationWithoutRegistryManifest(t *testin
227 registrySvc := inmocks.NewMockRegistryService(t)257 registrySvc := inmocks.NewMockRegistryService(t)
228 route := domain.Route{Domain: "app.example.com", Image: "myapp:latest"}258 route := domain.Route{Domain: "app.example.com", Image: "myapp:latest"}
229 259
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+ })
231 267
232 configSvc.EXPECT().AddRoute(mock.Anything, route).Return(nil).Once()268 configSvc.EXPECT().AddRoute(mock.Anything, route).Return(nil).Once()
233 269
@@ -250,7 +286,13 @@ func TestHandler_RoutesPut_AllowsRouteUpdateWithoutRegistryManifest(t *testing.T
250 registrySvc := inmocks.NewMockRegistryService(t)286 registrySvc := inmocks.NewMockRegistryService(t)
251 route := domain.Route{Domain: "app.example.com", Image: "myapp:v2"}287 route := domain.Route{Domain: "app.example.com", Image: "myapp:v2"}
252 288
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+ })
254 296
255 configSvc.EXPECT().UpdateRoute(mock.Anything, route).Return(nil).Once()297 configSvc.EXPECT().UpdateRoute(mock.Anything, route).Return(nil).Once()
256 298
@@ -271,7 +313,12 @@ func TestHandler_AttachmentsByImageGet_OneTarget(t *testing.T) {
271 containerSvc := inmocks.NewMockContainerService(t)313 containerSvc := inmocks.NewMockContainerService(t)
272 secretSvc := inmocks.NewMockSecretService(t)314 secretSvc := inmocks.NewMockSecretService(t)
273 315
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+ })
275 322
276 configSvc.EXPECT().FindAttachmentTargetsByImage(mock.Anything, "postgres:16").Return([]string{"app.example.com"}).Once()323 configSvc.EXPECT().FindAttachmentTargetsByImage(mock.Anything, "postgres:16").Return([]string{"app.example.com"}).Once()
277 324
@@ -291,7 +338,12 @@ func TestHandler_AttachmentsByImageGet_MultipleTargets(t *testing.T) {
291 containerSvc := inmocks.NewMockContainerService(t)338 containerSvc := inmocks.NewMockContainerService(t)
292 secretSvc := inmocks.NewMockSecretService(t)339 secretSvc := inmocks.NewMockSecretService(t)
293 340
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+ })
295 347
296 configSvc.EXPECT().FindAttachmentTargetsByImage(mock.Anything, "postgres").Return([]string{"app.example.com", "workers"}).Once()348 configSvc.EXPECT().FindAttachmentTargetsByImage(mock.Anything, "postgres").Return([]string{"app.example.com", "workers"}).Once()
297 349
@@ -311,7 +363,12 @@ func TestHandler_AttachmentsByImageGet_URLDecodedImage(t *testing.T) {
311 containerSvc := inmocks.NewMockContainerService(t)363 containerSvc := inmocks.NewMockContainerService(t)
312 secretSvc := inmocks.NewMockSecretService(t)364 secretSvc := inmocks.NewMockSecretService(t)
313 365
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+ })
315 372
316 configSvc.EXPECT().FindAttachmentTargetsByImage(mock.Anything, "registry/org/image:tag").Return([]string{"app.example.com"}).Once()373 configSvc.EXPECT().FindAttachmentTargetsByImage(mock.Anything, "registry/org/image:tag").Return([]string{"app.example.com"}).Once()
317 374
@@ -331,7 +388,12 @@ func TestHandler_AttachmentsByImageGet_EmptyResultSet(t *testing.T) {
331 containerSvc := inmocks.NewMockContainerService(t)388 containerSvc := inmocks.NewMockContainerService(t)
332 secretSvc := inmocks.NewMockSecretService(t)389 secretSvc := inmocks.NewMockSecretService(t)
333 390
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+ })
335 397
336 configSvc.EXPECT().FindAttachmentTargetsByImage(mock.Anything, "missing:latest").Return([]string{}).Once()398 configSvc.EXPECT().FindAttachmentTargetsByImage(mock.Anything, "missing:latest").Return([]string{}).Once()
337 399
@@ -351,7 +413,12 @@ func TestHandler_AttachmentsByImageGet_MissingImagePath(t *testing.T) {
351 containerSvc := inmocks.NewMockContainerService(t)413 containerSvc := inmocks.NewMockContainerService(t)
352 secretSvc := inmocks.NewMockSecretService(t)414 secretSvc := inmocks.NewMockSecretService(t)
353 415
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+ })
355 422
356 req := httptest.NewRequest("GET", "/admin/attachments/by-image", nil)423 req := httptest.NewRequest("GET", "/admin/attachments/by-image", nil)
357 req = req.WithContext(ctxWithScopes("admin:config:read"))424 req = req.WithContext(ctxWithScopes("admin:config:read"))
@@ -369,7 +436,12 @@ func TestHandler_AttachmentsByImageGet_InvalidEncoding(t *testing.T) {
369 containerSvc := inmocks.NewMockContainerService(t)436 containerSvc := inmocks.NewMockContainerService(t)
370 secretSvc := inmocks.NewMockSecretService(t)437 secretSvc := inmocks.NewMockSecretService(t)
371 438
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+ })
373 445
374 req := httptest.NewRequest("GET", "/admin/attachments/by-image/placeholder", nil)446 req := httptest.NewRequest("GET", "/admin/attachments/by-image/placeholder", nil)
375 req.URL = &url.URL{Path: "/admin/attachments/by-image/%zz"}447 req.URL = &url.URL{Path: "/admin/attachments/by-image/%zz"}
@@ -388,7 +460,12 @@ func TestHandler_AttachmentsByImageGet_RequiresReadScope(t *testing.T) {
388 containerSvc := inmocks.NewMockContainerService(t)460 containerSvc := inmocks.NewMockContainerService(t)
389 secretSvc := inmocks.NewMockSecretService(t)461 secretSvc := inmocks.NewMockSecretService(t)
390 462
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+ })
392 469
393 req := httptest.NewRequest("GET", "/admin/attachments/by-image/postgres:16", nil)470 req := httptest.NewRequest("GET", "/admin/attachments/by-image/postgres:16", nil)
394 req = req.WithContext(ctxWithScopes("admin:routes:read"))471 req = req.WithContext(ctxWithScopes("admin:routes:read"))
@@ -406,7 +483,12 @@ func TestHandler_RoutesDelete_RequiresWriteScope(t *testing.T) {
406 containerSvc := inmocks.NewMockContainerService(t)483 containerSvc := inmocks.NewMockContainerService(t)
407 secretSvc := inmocks.NewMockSecretService(t)484 secretSvc := inmocks.NewMockSecretService(t)
408 485
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+ })
410 492
411 tests := []struct {493 tests := []struct {
412 name string494 name string
@@ -450,7 +532,12 @@ func TestHandler_SecretsGet_RequiresReadScope(t *testing.T) {
450 containerSvc := inmocks.NewMockContainerService(t)532 containerSvc := inmocks.NewMockContainerService(t)
451 secretSvc := inmocks.NewMockSecretService(t)533 secretSvc := inmocks.NewMockSecretService(t)
452 534
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+ })
454 541
455 tests := []struct {542 tests := []struct {
456 name string543 name string
@@ -507,7 +594,12 @@ func TestHandler_SecretsPost_RequiresWriteScope(t *testing.T) {
507 containerSvc := inmocks.NewMockContainerService(t)594 containerSvc := inmocks.NewMockContainerService(t)
508 secretSvc := inmocks.NewMockSecretService(t)595 secretSvc := inmocks.NewMockSecretService(t)
509 596
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+ })
511 603
512 secretsJSON := `{"API_KEY": "secret123"}`604 secretsJSON := `{"API_KEY": "secret123"}`
513 605
@@ -556,7 +648,12 @@ func TestHandler_SecretsDelete_RequiresWriteScope(t *testing.T) {
556 containerSvc := inmocks.NewMockContainerService(t)648 containerSvc := inmocks.NewMockContainerService(t)
557 secretSvc := inmocks.NewMockSecretService(t)649 secretSvc := inmocks.NewMockSecretService(t)
558 650
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+ })
560 657
561 tests := []struct {658 tests := []struct {
562 name string659 name string
@@ -600,7 +697,12 @@ func TestHandler_AttachmentSecretsPost(t *testing.T) {
600 containerSvc := inmocks.NewMockContainerService(t)697 containerSvc := inmocks.NewMockContainerService(t)
601 secretSvc := inmocks.NewMockSecretService(t)698 secretSvc := inmocks.NewMockSecretService(t)
602 699
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+ })
604 706
605 secretSvc.EXPECT().SetAttachment(mock.Anything, "app.example.com", "redis", mock.AnythingOfType("map[string]string")).Return(nil)707 secretSvc.EXPECT().SetAttachment(mock.Anything, "app.example.com", "redis", mock.AnythingOfType("map[string]string")).Return(nil)
606 708
@@ -626,7 +728,12 @@ func TestHandler_AttachmentSecretsDelete(t *testing.T) {
626 containerSvc := inmocks.NewMockContainerService(t)728 containerSvc := inmocks.NewMockContainerService(t)
627 secretSvc := inmocks.NewMockSecretService(t)729 secretSvc := inmocks.NewMockSecretService(t)
628 730
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+ })
630 737
631 secretSvc.EXPECT().DeleteAttachment(mock.Anything, "app.example.com", "redis", "REDIS_URL").Return(nil)738 secretSvc.EXPECT().DeleteAttachment(mock.Anything, "app.example.com", "redis", "REDIS_URL").Return(nil)
632 739
@@ -652,7 +759,13 @@ func TestHandler_Bootstrap_HappyPath(t *testing.T) {
652 secretSvc := inmocks.NewMockSecretService(t)759 secretSvc := inmocks.NewMockSecretService(t)
653 registrySvc := inmocks.NewMockRegistryService(t)760 registrySvc := inmocks.NewMockRegistryService(t)
654 761
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+ })
656 769
657 configSvc.EXPECT().GetRegistryDomain().Return("reg.bnema.dev").Once()770 configSvc.EXPECT().GetRegistryDomain().Return("reg.bnema.dev").Once()
658 configSvc.EXPECT().AddRoute(mock.Anything, domain.Route{Domain: "app.example.com", Image: "reg.bnema.dev/myapp"}).Return(nil).Once()771 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) {
699 containerSvc := inmocks.NewMockContainerService(t)812 containerSvc := inmocks.NewMockContainerService(t)
700 secretSvc := inmocks.NewMockSecretService(t)813 secretSvc := inmocks.NewMockSecretService(t)
701 814
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+ })
703 821
704 tests := []struct {822 tests := []struct {
705 name string823 name string
@@ -741,7 +859,12 @@ func TestHandler_Status_RequiresReadScope(t *testing.T) {
741 containerSvc := inmocks.NewMockContainerService(t)859 containerSvc := inmocks.NewMockContainerService(t)
742 secretSvc := inmocks.NewMockSecretService(t)860 secretSvc := inmocks.NewMockSecretService(t)
743 861
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+ })
745 868
746 tests := []struct {869 tests := []struct {
747 name string870 name string
@@ -795,7 +918,12 @@ func TestHandler_Config_RequiresReadScope(t *testing.T) {
795 containerSvc := inmocks.NewMockContainerService(t)918 containerSvc := inmocks.NewMockContainerService(t)
796 secretSvc := inmocks.NewMockSecretService(t)919 secretSvc := inmocks.NewMockSecretService(t)
797 920
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+ })
799 927
800 tests := []struct {928 tests := []struct {
801 name string929 name string
@@ -852,7 +980,12 @@ func TestHandler_Reload_RequiresWriteScope(t *testing.T) {
852 containerSvc := inmocks.NewMockContainerService(t)980 containerSvc := inmocks.NewMockContainerService(t)
853 secretSvc := inmocks.NewMockSecretService(t)981 secretSvc := inmocks.NewMockSecretService(t)
854 982
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+ })
856 989
857 tests := []struct {990 tests := []struct {
858 name string991 name string
@@ -906,7 +1039,12 @@ func TestHandler_RoutesGet_ReturnsRoutes(t *testing.T) {
906 containerSvc := inmocks.NewMockContainerService(t)1039 containerSvc := inmocks.NewMockContainerService(t)
907 secretSvc := inmocks.NewMockSecretService(t)1040 secretSvc := inmocks.NewMockSecretService(t)
908 1041
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+ })
910 1048
911 expectedRoutes := []domain.Route{1049 expectedRoutes := []domain.Route{
912 {Domain: "app1.example.com", Image: "app1:latest"},1050 {Domain: "app1.example.com", Image: "app1:latest"},
@@ -941,7 +1079,12 @@ func TestHandler_RoutesGet_SingleRoute(t *testing.T) {
941 containerSvc := inmocks.NewMockContainerService(t)1079 containerSvc := inmocks.NewMockContainerService(t)
942 secretSvc := inmocks.NewMockSecretService(t)1080 secretSvc := inmocks.NewMockSecretService(t)
943 1081
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+ })
945 1088
946 configSvc.EXPECT().GetRoute(mock.Anything, "app.example.com").Return(&domain.Route{1089 configSvc.EXPECT().GetRoute(mock.Anything, "app.example.com").Return(&domain.Route{
947 Domain: "app.example.com", Image: "app:latest",1090 Domain: "app.example.com", Image: "app:latest",
@@ -972,7 +1115,12 @@ func TestHandler_RoutesGet_NotFound(t *testing.T) {
972 containerSvc := inmocks.NewMockContainerService(t)1115 containerSvc := inmocks.NewMockContainerService(t)
973 secretSvc := inmocks.NewMockSecretService(t)1116 secretSvc := inmocks.NewMockSecretService(t)
974 1117
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+ })
976 1124
977 configSvc.EXPECT().GetRoute(mock.Anything, "nonexistent.example.com").Return(nil, domain.ErrRouteNotFound)1125 configSvc.EXPECT().GetRoute(mock.Anything, "nonexistent.example.com").Return(nil, domain.ErrRouteNotFound)
978 1126
@@ -1016,7 +1164,13 @@ func TestHandler_RoutesPost_MissingFields(t *testing.T) {
1016 secretSvc := inmocks.NewMockSecretService(t)1164 secretSvc := inmocks.NewMockSecretService(t)
1017 registrySvc := inmocks.NewMockRegistryService(t)1165 registrySvc := inmocks.NewMockRegistryService(t)
1018 1166
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+ })
1020 1174
1021 configSvc.EXPECT().AddRoute(mock.Anything, mock.Anything).Return(tt.mockErr)1175 configSvc.EXPECT().AddRoute(mock.Anything, mock.Anything).Return(tt.mockErr)
1022 1176
@@ -1037,7 +1191,12 @@ func TestHandler_RoutesPut_MissingDomain(t *testing.T) {
1037 containerSvc := inmocks.NewMockContainerService(t)1191 containerSvc := inmocks.NewMockContainerService(t)
1038 secretSvc := inmocks.NewMockSecretService(t)1192 secretSvc := inmocks.NewMockSecretService(t)
1039 1193
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+ })
1041 1200
1042 req := httptest.NewRequest("PUT", "/admin/routes/", bytes.NewBufferString(`{"image": "app:latest"}`))1201 req := httptest.NewRequest("PUT", "/admin/routes/", bytes.NewBufferString(`{"image": "app:latest"}`))
1043 req = req.WithContext(ctxWithScopes("admin:routes:write"))1202 req = req.WithContext(ctxWithScopes("admin:routes:write"))
@@ -1054,7 +1213,12 @@ func TestHandler_RoutesDelete_MissingDomain(t *testing.T) {
1054 containerSvc := inmocks.NewMockContainerService(t)1213 containerSvc := inmocks.NewMockContainerService(t)
1055 secretSvc := inmocks.NewMockSecretService(t)1214 secretSvc := inmocks.NewMockSecretService(t)
1056 1215
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+ })
1058 1222
1059 req := httptest.NewRequest("DELETE", "/admin/routes/", nil)1223 req := httptest.NewRequest("DELETE", "/admin/routes/", nil)
1060 req = req.WithContext(ctxWithScopes("admin:routes:write"))1224 req = req.WithContext(ctxWithScopes("admin:routes:write"))
@@ -1071,7 +1235,12 @@ func TestHandler_Secrets_MissingDomain(t *testing.T) {
1071 containerSvc := inmocks.NewMockContainerService(t)1235 containerSvc := inmocks.NewMockContainerService(t)
1072 secretSvc := inmocks.NewMockSecretService(t)1236 secretSvc := inmocks.NewMockSecretService(t)
1073 1237
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+ })
1075 1244
1076 req := httptest.NewRequest("GET", "/admin/secrets/", nil)1245 req := httptest.NewRequest("GET", "/admin/secrets/", nil)
1077 req = req.WithContext(ctxWithScopes("admin:secrets:read"))1246 req = req.WithContext(ctxWithScopes("admin:secrets:read"))
@@ -1088,7 +1257,12 @@ func TestHandler_SecretsDelete_MissingKey(t *testing.T) {
1088 containerSvc := inmocks.NewMockContainerService(t)1257 containerSvc := inmocks.NewMockContainerService(t)
1089 secretSvc := inmocks.NewMockSecretService(t)1258 secretSvc := inmocks.NewMockSecretService(t)
1090 1259
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+ })
1092 1266
1093 req := httptest.NewRequest("DELETE", "/admin/secrets/app.example.com", nil)1267 req := httptest.NewRequest("DELETE", "/admin/secrets/app.example.com", nil)
1094 req = req.WithContext(ctxWithScopes("admin:secrets:write"))1268 req = req.WithContext(ctxWithScopes("admin:secrets:write"))
@@ -1105,7 +1279,12 @@ func TestHandler_MethodNotAllowed(t *testing.T) {
1105 containerSvc := inmocks.NewMockContainerService(t)1279 containerSvc := inmocks.NewMockContainerService(t)
1106 secretSvc := inmocks.NewMockSecretService(t)1280 secretSvc := inmocks.NewMockSecretService(t)
1107 1281
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+ })
1109 1288
1110 tests := []struct {1289 tests := []struct {
1111 method string1290 method string
@@ -1138,7 +1317,12 @@ func TestHandler_Restart_InvalidDomain(t *testing.T) {
1138 containerSvc := inmocks.NewMockContainerService(t)1317 containerSvc := inmocks.NewMockContainerService(t)
1139 secretSvc := inmocks.NewMockSecretService(t)1318 secretSvc := inmocks.NewMockSecretService(t)
1140 1319
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+ })
1142 1326
1143 tests := []struct {1327 tests := []struct {
1144 name string1328 name string
@@ -1168,7 +1352,12 @@ func TestHandler_Restart_ContainerNotFound(t *testing.T) {
1168 containerSvc := inmocks.NewMockContainerService(t)1352 containerSvc := inmocks.NewMockContainerService(t)
1169 secretSvc := inmocks.NewMockSecretService(t)1353 secretSvc := inmocks.NewMockSecretService(t)
1170 1354
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+ })
1172 1361
1173 containerSvc.EXPECT().Restart(mock.Anything, "nonexistent.example.com", false).Return(domain.ErrContainerNotFound)1362 containerSvc.EXPECT().Restart(mock.Anything, "nonexistent.example.com", false).Return(domain.ErrContainerNotFound)
1174 1363
@@ -1187,7 +1376,12 @@ func TestHandler_Restart_GenericError(t *testing.T) {
1187 containerSvc := inmocks.NewMockContainerService(t)1376 containerSvc := inmocks.NewMockContainerService(t)
1188 secretSvc := inmocks.NewMockSecretService(t)1377 secretSvc := inmocks.NewMockSecretService(t)
1189 1378
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+ })
1191 1385
1192 containerSvc.EXPECT().Restart(mock.Anything, "test.example.com", false).Return(fmt.Errorf("docker daemon error: secret details"))1386 containerSvc.EXPECT().Restart(mock.Anything, "test.example.com", false).Return(fmt.Errorf("docker daemon error: secret details"))
1193 1387
@@ -1209,7 +1403,12 @@ func TestHandler_Restart_Success(t *testing.T) {
1209 containerSvc := inmocks.NewMockContainerService(t)1403 containerSvc := inmocks.NewMockContainerService(t)
1210 secretSvc := inmocks.NewMockSecretService(t)1404 secretSvc := inmocks.NewMockSecretService(t)
1211 1405
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+ })
1213 1412
1214 tests := []struct {1413 tests := []struct {
1215 name string1414 name string
@@ -1256,7 +1455,12 @@ func TestHandler_Tags_InvalidRepository(t *testing.T) {
1256 containerSvc := inmocks.NewMockContainerService(t)1455 containerSvc := inmocks.NewMockContainerService(t)
1257 secretSvc := inmocks.NewMockSecretService(t)1456 secretSvc := inmocks.NewMockSecretService(t)
1258 1457
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+ })
1260 1464
1261 tests := []struct {1465 tests := []struct {
1262 name string1466 name string
@@ -1287,7 +1491,13 @@ func TestHandler_Tags_Success(t *testing.T) {
1287 secretSvc := inmocks.NewMockSecretService(t)1491 secretSvc := inmocks.NewMockSecretService(t)
1288 registrySvc := inmocks.NewMockRegistryService(t)1492 registrySvc := inmocks.NewMockRegistryService(t)
1289 1493
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+ })
1291 1501
1292 registrySvc.EXPECT().ListTags(mock.Anything, "myapp").Return([]string{"latest", "v1.0"}, nil)1502 registrySvc.EXPECT().ListTags(mock.Anything, "myapp").Return([]string{"latest", "v1.0"}, nil)
1293 1503
@@ -1314,7 +1524,13 @@ func TestHandler_Tags_Error(t *testing.T) {
1314 secretSvc := inmocks.NewMockSecretService(t)1524 secretSvc := inmocks.NewMockSecretService(t)
1315 registrySvc := inmocks.NewMockRegistryService(t)1525 registrySvc := inmocks.NewMockRegistryService(t)
1316 1526
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+ })
1318 1534
1319 registrySvc.EXPECT().ListTags(mock.Anything, "myapp").Return(nil, fmt.Errorf("registry error"))1535 registrySvc.EXPECT().ListTags(mock.Anything, "myapp").Return(nil, fmt.Errorf("registry error"))
1320 1536
@@ -1334,7 +1550,12 @@ func TestHandler_AttachmentSecretsPost_RequiresWriteScope(t *testing.T) {
1334 containerSvc := inmocks.NewMockContainerService(t)1550 containerSvc := inmocks.NewMockContainerService(t)
1335 secretSvc := inmocks.NewMockSecretService(t)1551 secretSvc := inmocks.NewMockSecretService(t)
1336 1552
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+ })
1338 1559
1339 body := `{"REDIS_URL": "redis://localhost:6379"}`1560 body := `{"REDIS_URL": "redis://localhost:6379"}`
1340 req := httptest.NewRequest("POST", "/admin/secrets/app.example.com/attachments/redis", bytes.NewBufferString(body))1561 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) {
1352 containerSvc := inmocks.NewMockContainerService(t)1573 containerSvc := inmocks.NewMockContainerService(t)
1353 secretSvc := inmocks.NewMockSecretService(t)1574 secretSvc := inmocks.NewMockSecretService(t)
1354 1575
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+ })
1356 1582
1357 req := httptest.NewRequest("DELETE", "/admin/secrets/app.example.com/attachments/redis/REDIS_URL", nil)1583 req := httptest.NewRequest("DELETE", "/admin/secrets/app.example.com/attachments/redis/REDIS_URL", nil)
1358 req = req.WithContext(ctxWithScopes("admin:secrets:read"))1584 req = req.WithContext(ctxWithScopes("admin:secrets:read"))
@@ -1369,7 +1595,12 @@ func TestHandler_AttachmentSecretsDelete_RequiresKey(t *testing.T) {
1369 containerSvc := inmocks.NewMockContainerService(t)1595 containerSvc := inmocks.NewMockContainerService(t)
1370 secretSvc := inmocks.NewMockSecretService(t)1596 secretSvc := inmocks.NewMockSecretService(t)
1371 1597
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+ })
1373 1604
1374 req := httptest.NewRequest("DELETE", "/admin/secrets/app.example.com/attachments/redis", nil)1605 req := httptest.NewRequest("DELETE", "/admin/secrets/app.example.com/attachments/redis", nil)
1375 req = req.WithContext(ctxWithScopes("admin:secrets:write"))1606 req = req.WithContext(ctxWithScopes("admin:secrets:write"))
@@ -1387,7 +1618,12 @@ func TestHandler_AttachmentSecretsPost_InvalidJSON(t *testing.T) {
1387 containerSvc := inmocks.NewMockContainerService(t)1618 containerSvc := inmocks.NewMockContainerService(t)
1388 secretSvc := inmocks.NewMockSecretService(t)1619 secretSvc := inmocks.NewMockSecretService(t)
1389 1620
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+ })
1391 1627
1392 body := `{not valid json`1628 body := `{not valid json`
1393 req := httptest.NewRequest("POST", "/admin/secrets/app.example.com/attachments/redis", bytes.NewBufferString(body))1629 req := httptest.NewRequest("POST", "/admin/secrets/app.example.com/attachments/redis", bytes.NewBufferString(body))
@@ -1406,7 +1642,12 @@ func TestHandler_NotFound(t *testing.T) {
1406 containerSvc := inmocks.NewMockContainerService(t)1642 containerSvc := inmocks.NewMockContainerService(t)
1407 secretSvc := inmocks.NewMockSecretService(t)1643 secretSvc := inmocks.NewMockSecretService(t)
1408 1644
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+ })
1410 1651
1411 req := httptest.NewRequest("GET", "/admin/unknown", nil)1652 req := httptest.NewRequest("GET", "/admin/unknown", nil)
1412 req = req.WithContext(ctxWithScopes("admin:*:*"))1653 req = req.WithContext(ctxWithScopes("admin:*:*"))
@@ -1426,7 +1667,13 @@ func TestHandler_BackupsStatus(t *testing.T) {
1426 1667
1427 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)1668 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)
1428 1669
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+ })
1430 1677
1431 req := httptest.NewRequest("GET", "/admin/backups/status", nil)1678 req := httptest.NewRequest("GET", "/admin/backups/status", nil)
1432 req = req.WithContext(ctxWithScopes("admin:status:read"))1679 req = req.WithContext(ctxWithScopes("admin:status:read"))
@@ -1448,7 +1695,13 @@ func TestHandler_BackupsListDomain(t *testing.T) {
1448 1695
1449 backupSvc.EXPECT().ListBackups(mock.Anything, "app.example.com").Return([]domain.BackupJob{{Domain: "app.example.com", DBName: "postgres", Status: domain.BackupStatusCompleted}}, nil)1696 backupSvc.EXPECT().ListBackups(mock.Anything, "app.example.com").Return([]domain.BackupJob{{Domain: "app.example.com", DBName: "postgres", Status: domain.BackupStatusCompleted}}, nil)
1450 1697
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+ })
1452 1705
1453 req := httptest.NewRequest("GET", "/admin/backups/app.example.com", nil)1706 req := httptest.NewRequest("GET", "/admin/backups/app.example.com", nil)
1454 req = req.WithContext(ctxWithScopes("admin:status:read"))1707 req = req.WithContext(ctxWithScopes("admin:status:read"))
@@ -1469,7 +1722,13 @@ func TestHandler_BackupsRunDomain(t *testing.T) {
1469 1722
1470 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)1723 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)
1471 1724
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+ })
1473 1732
1474 body := bytes.NewBufferString(`{"db":"postgres"}`)1733 body := bytes.NewBufferString(`{"db":"postgres"}`)
1475 req := httptest.NewRequest("POST", "/admin/backups/app.example.com", body)1734 req := httptest.NewRequest("POST", "/admin/backups/app.example.com", body)
@@ -1491,7 +1750,13 @@ func TestHandler_BackupsRunDomain_ChunkedBodyIsDecoded(t *testing.T) {
1491 1750
1492 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)1751 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)
1493 1752
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+ })
1495 1760
1496 body := bytes.NewBufferString(`{"db":"postgres"}`)1761 body := bytes.NewBufferString(`{"db":"postgres"}`)
1497 req := httptest.NewRequest("POST", "/admin/backups/app.example.com", body)1762 req := httptest.NewRequest("POST", "/admin/backups/app.example.com", body)
@@ -1514,7 +1779,13 @@ func TestHandler_BackupsDetectDomain(t *testing.T) {
1514 1779
1515 backupSvc.EXPECT().DetectDatabases(mock.Anything, "app.example.com").Return([]domain.DBInfo{{Type: domain.DBTypePostgreSQL, Name: "postgres", Port: 5432}}, nil)1780 backupSvc.EXPECT().DetectDatabases(mock.Anything, "app.example.com").Return([]domain.DBInfo{{Type: domain.DBTypePostgreSQL, Name: "postgres", Port: 5432}}, nil)
1516 1781
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+ })
1518 1789
1519 req := httptest.NewRequest("GET", "/admin/backups/app.example.com/detect", nil)1790 req := httptest.NewRequest("GET", "/admin/backups/app.example.com/detect", nil)
1520 req = req.WithContext(ctxWithScopes("admin:status:read"))1791 req = req.WithContext(ctxWithScopes("admin:status:read"))
@@ -1556,7 +1827,13 @@ func TestHandler_ImagesGet_ReturnsMappedList(t *testing.T) {
1556 },1827 },
1557 }1828 }
1558 1829
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+ })
1560 1837
1561 req := httptest.NewRequest("GET", "/admin/images", nil)1838 req := httptest.NewRequest("GET", "/admin/images", nil)
1562 req = req.WithContext(ctxWithScopes("admin:status:read"))1839 req = req.WithContext(ctxWithScopes("admin:status:read"))
@@ -1617,7 +1894,13 @@ func TestHandler_ImagesPrune_AcceptsOptionalKeepLast(t *testing.T) {
1617 },1894 },
1618 }1895 }
1619 1896
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+ })
1621 1904
1622 req := httptest.NewRequest("POST", "/admin/images/prune", bytes.NewBufferString(tt.body))1905 req := httptest.NewRequest("POST", "/admin/images/prune", bytes.NewBufferString(tt.body))
1623 req = req.WithContext(ctxWithScopes("admin:config:write"))1906 req = req.WithContext(ctxWithScopes("admin:config:write"))
@@ -1646,7 +1929,13 @@ func TestHandler_Images_ErrorMappingForServiceFailures(t *testing.T) {
1646 },1929 },
1647 }1930 }
1648 1931
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+ })
1650 1939
1651 req := httptest.NewRequest("GET", "/admin/images", nil)1940 req := httptest.NewRequest("GET", "/admin/images", nil)
1652 req = req.WithContext(ctxWithScopes("admin:status:read"))1941 req = req.WithContext(ctxWithScopes("admin:status:read"))
@@ -1671,7 +1960,13 @@ func TestHandler_Images_ErrorMappingForServiceFailures(t *testing.T) {
1671 },1960 },
1672 }1961 }
1673 1962
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+ })
1675 1970
1676 req := httptest.NewRequest("POST", "/admin/images/prune", bytes.NewBufferString(`{"keep_last": 2}`))1971 req := httptest.NewRequest("POST", "/admin/images/prune", bytes.NewBufferString(`{"keep_last": 2}`))
1677 req = req.WithContext(ctxWithScopes("admin:config:write"))1972 req = req.WithContext(ctxWithScopes("admin:config:write"))
@@ -1692,7 +1987,13 @@ func TestHandler_ImagesPrune_ValidationAndAvailability(t *testing.T) {
1692 containerSvc := inmocks.NewMockContainerService(t)1987 containerSvc := inmocks.NewMockContainerService(t)
1693 secretSvc := inmocks.NewMockSecretService(t)1988 secretSvc := inmocks.NewMockSecretService(t)
1694 1989
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+ })
1696 1997
1697 req := httptest.NewRequest("POST", "/admin/images/prune", bytes.NewBufferString(`{"keep_last": -1}`))1998 req := httptest.NewRequest("POST", "/admin/images/prune", bytes.NewBufferString(`{"keep_last": -1}`))
1698 req = req.WithContext(ctxWithScopes("admin:config:write"))1999 req = req.WithContext(ctxWithScopes("admin:config:write"))
@@ -1710,7 +2011,12 @@ func TestHandler_ImagesPrune_ValidationAndAvailability(t *testing.T) {
1710 containerSvc := inmocks.NewMockContainerService(t)2011 containerSvc := inmocks.NewMockContainerService(t)
1711 secretSvc := inmocks.NewMockSecretService(t)2012 secretSvc := inmocks.NewMockSecretService(t)
1712 2013
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+ })
1714 2020
1715 req := httptest.NewRequest("GET", "/admin/images", nil)2021 req := httptest.NewRequest("GET", "/admin/images", nil)
1716 req = req.WithContext(ctxWithScopes("admin:status:read"))2022 req = req.WithContext(ctxWithScopes("admin:status:read"))
@@ -1781,7 +2087,13 @@ func TestHandler_ImagesPrune_ScopeDefaults(t *testing.T) {
1781 },2087 },
1782 }2088 }
1783 2089
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+ })
1785 2097
1786 req := httptest.NewRequest("POST", "/admin/images/prune", bytes.NewBufferString(tt.body))2098 req := httptest.NewRequest("POST", "/admin/images/prune", bytes.NewBufferString(tt.body))
1787 req = req.WithContext(ctxWithScopes("admin:config:write"))2099 req = req.WithContext(ctxWithScopes("admin:config:write"))
@@ -1806,7 +2118,13 @@ func TestHandler_Images_Authorization(t *testing.T) {
1806 containerSvc := inmocks.NewMockContainerService(t)2118 containerSvc := inmocks.NewMockContainerService(t)
1807 secretSvc := inmocks.NewMockSecretService(t)2119 secretSvc := inmocks.NewMockSecretService(t)
1808 2120
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+ })
1810 2128
1811 t.Run("images list requires status:read", func(t *testing.T) {2129 t.Run("images list requires status:read", func(t *testing.T) {
1812 req := httptest.NewRequest("GET", "/admin/images", nil)2130 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
142 log.Debug().Str("volume", volumeName).Str("mount_path", containerPath).Msg("adding volume mount")142 log.Debug().Str("volume", volumeName).Str("mount_path", containerPath).Msg("adding volume mount")
143 }143 }
144 }144 }
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+ }
145 152
146 // Create container configuration153 // Create container configuration
147 containerConfig := &container.Config{154 containerConfig := &container.Config{
@@ -207,6 +214,29 @@ func (r *Runtime) StartContainer(ctx context.Context, containerID string) error
207 return nil214 return nil
208 }215 }
209 216
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+
210 // StopContainer stops a container.240 // StopContainer stops a container.
211 func (r *Runtime) StopContainer(ctx context.Context, containerID string) error {241 func (r *Runtime) StopContainer(ctx context.Context, containerID string) error {
212 ctx = zerowrap.CtxWithFields(ctx, map[string]any{242 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 (
64 64
65 // Use cases65 // Use cases
66 "github.com/bnema/gordon/internal/usecase/auth"66 "github.com/bnema/gordon/internal/usecase/auth"
67+ "github.com/bnema/gordon/internal/usecase/auto"
68+ "github.com/bnema/gordon/internal/usecase/auto/preview"
67 "github.com/bnema/gordon/internal/usecase/backup"69 "github.com/bnema/gordon/internal/usecase/backup"
68 "github.com/bnema/gordon/internal/usecase/config"70 "github.com/bnema/gordon/internal/usecase/config"
69 "github.com/bnema/gordon/internal/usecase/container"71 "github.com/bnema/gordon/internal/usecase/container"
@@ -192,6 +194,8 @@ type services struct {
192 adminHandler *admin.Handler194 adminHandler *admin.Handler
193 internalRegUser string195 internalRegUser string
194 internalRegPass string196 internalRegPass string
197+ previewStore *filesystem.PreviewStore
198+ previewService *preview.Service
195 envDir string199 envDir string
196 maxBlobChunkSize int64200 maxBlobChunkSize int64
197 }201 }
@@ -541,24 +545,57 @@ func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowra
541 // Create log service for accessing logs via admin API545 // Create log service for accessing logs via admin API
542 svc.logSvc = logs.NewService(resolveLogFilePath(cfg), cfg.Logging.File.Enabled, svc.containerSvc, svc.runtime, log)546 svc.logSvc = logs.NewService(resolveLogFilePath(cfg), cfg.Logging.File.Enabled, svc.containerSvc, svc.runtime, log)
543 547
548+ initPreviewService(ctx, cfg, svc, log)
549+
544 // Create admin handler for admin API550 // Create admin handler for admin API
545- svc.adminHandler = admin.NewHandler(551+ svc.adminHandler = admin.NewHandler(admin.HandlerDeps{
546- svc.configSvc,552+ ConfigSvc: svc.configSvc,
547- svc.authSvc,553+ AuthSvc: svc.authSvc,
548- svc.containerSvc,554+ ContainerSvc: svc.containerSvc,
549- svc.healthSvc,555+ HealthSvc: svc.healthSvc,
550- svc.secretSvc,556+ SecretSvc: svc.secretSvc,
551- svc.logSvc,557+ LogSvc: svc.logSvc,
552- svc.registrySvc,558+ RegistrySvc: svc.registrySvc,
553- svc.eventBus,559+ EventBus: svc.eventBus,
554- log,560+ Log: log,
555- svc.backupSvc,561+ BackupSvc: svc.backupSvc,
556- svc.imageSvc,562+ PreviewSvc: svc.previewService,
557- ).WithVolumeService(svc.volumeSvc)563+ ImageSvc: svc.imageSvc,
564+ VolumeSvc: svc.volumeSvc,
565+ })
558 566
559 return svc, nil567 return svc, nil
560 }568 }
561 569
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+
562 // injectTelemetryMetrics creates and injects OTel metrics into services when599 // injectTelemetryMetrics creates and injects OTel metrics into services when
563 // telemetry is enabled. Skipped otherwise to avoid unnecessary allocations.600 // telemetry is enabled. Skipped otherwise to avoid unnecessary allocations.
564 func injectTelemetryMetrics(cfg Config, svc *services, log zerowrap.Logger) {601 func injectTelemetryMetrics(cfg Config, svc *services, log zerowrap.Logger) {
@@ -1349,8 +1386,19 @@ func registerEventHandlers(ctx context.Context, svc *services, cfg Config) (func
1349 // Auto-route handler for creating routes from image labels1386 // Auto-route handler for creating routes from image labels
1350 autoRouteHandler := container.NewAutoRouteHandler(ctx, svc.configSvc, svc.containerSvc, svc.blobStorage, cfg.Server.RegistryDomain).1387 autoRouteHandler := container.NewAutoRouteHandler(ctx, svc.configSvc, svc.containerSvc, svc.blobStorage, cfg.Server.RegistryDomain).
1351 WithEnvExtractor(svc.runtime, svc.envDir)1388 WithEnvExtractor(svc.runtime, svc.envDir)
1352- if err := svc.eventBus.Subscribe(autoRouteHandler); err != nil {1389+
1353- return nil, fmt.Errorf("failed to subscribe auto-route handler: %w", err)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)
1354 }1402 }
1355 1403
1356 configReloadHandler := container.NewConfigReloadHandler(ctx, svc.containerSvc, svc.configSvc)1404 configReloadHandler := container.NewConfigReloadHandler(ctx, svc.containerSvc, svc.configSvc)
M internal/boundaries/in/config.go +7 -0
@@ -84,4 +84,11 @@ type ConfigService interface {
84 GetAutoRouteAllowedDomains(ctx context.Context) ([]string, error)84 GetAutoRouteAllowedDomains(ctx context.Context) ([]string, error)
85 AddAutoRouteAllowedDomain(ctx context.Context, pattern string) error85 AddAutoRouteAllowedDomain(ctx context.Context, pattern string) error
86 RemoveAutoRouteAllowedDomain(ctx context.Context, pattern string) error86 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
87 }94 }
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
386 return _c386 return _c
387 }387 }
388 388
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+
389 // GetAttachmentsFor provides a mock function for the type MockConfigService435 // GetAttachmentsFor provides a mock function for the type MockConfigService
390 func (_mock *MockConfigService) GetAttachmentsFor(ctx context.Context, domainOrGroup string) ([]string, error) {436 func (_mock *MockConfigService) GetAttachmentsFor(ctx context.Context, domainOrGroup string) ([]string, error) {
391 ret := _mock.Called(ctx, domainOrGroup)437 ret := _mock.Called(ctx, domainOrGroup)
@@ -650,6 +696,96 @@ func (_c *MockConfigService_GetNetworkPrefix_Call) RunAndReturn(run func() strin
650 return _c696 return _c
651 }697 }
652 698
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+
653 // GetRegistryDomain provides a mock function for the type MockConfigService789 // GetRegistryDomain provides a mock function for the type MockConfigService
654 func (_mock *MockConfigService) GetRegistryDomain() string {790 func (_mock *MockConfigService) GetRegistryDomain() string {
655 ret := _mock.Called()791 ret := _mock.Called()
@@ -903,6 +1039,50 @@ func (_c *MockConfigService_GetServerPort_Call) RunAndReturn(run func() int) *Mo
903 return _c1039 return _c
904 }1040 }
905 1041
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+
906 // IsAutoRouteEnabled provides a mock function for the type MockConfigService1086 // IsAutoRouteEnabled provides a mock function for the type MockConfigService
907 func (_mock *MockConfigService) IsAutoRouteEnabled() bool {1087 func (_mock *MockConfigService) IsAutoRouteEnabled() bool {
908 ret := _mock.Called()1088 ret := _mock.Called()
@@ -991,6 +1171,50 @@ func (_c *MockConfigService_IsNetworkIsolationEnabled_Call) RunAndReturn(run fun
991 return _c1171 return _c
992 }1172 }
993 1173
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+
994 // Load provides a mock function for the type MockConfigService1218 // Load provides a mock function for the type MockConfigService
995 func (_mock *MockConfigService) Load(ctx context.Context) error {1219 func (_mock *MockConfigService) Load(ctx context.Context) error {
996 ret := _mock.Called(ctx)1220 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 {
45 45
46 // ContainerConfig holds configuration for creating a container.46 // ContainerConfig holds configuration for creating a container.
47 type ContainerConfig struct {47 type ContainerConfig struct {
48- Image string48+ Image string
49- Name string49+ Name string
50- Env []string50+ Env []string
51- Ports []int51+ Ports []int
52- Labels map[string]string52+ Labels map[string]string
53- WorkingDir string53+ WorkingDir string
54- Cmd []string54+ Cmd []string
55- AutoRemove bool55+ AutoRemove bool
56- Volumes map[string]string // map[containerPath]volumeName56+ Volumes map[string]string // map[containerPath]volumeName
57- NetworkMode string // Network to join57+ ReadOnlyVolumes map[string]string // containerPath -> volumeName (mounted read-only)
58- Hostname string // Container hostname for DNS58+ NetworkMode string // Network to join
59- Aliases []string // Additional network aliases59+ Hostname string // Container hostname for DNS
60+ Aliases []string // Additional network aliases
60 }61 }
61 62
62 // ContainerStatus represents the current state of a container.63 // ContainerStatus represents the current state of a container.
M internal/domain/errors.go +3 -0
@@ -40,6 +40,9 @@ var (
40 ErrVolumeNotFound = errors.New("volume not found")40 ErrVolumeNotFound = errors.New("volume not found")
41 ErrVolumeExists = errors.New("volume already exists")41 ErrVolumeExists = errors.New("volume already exists")
42 42
43+ // Preview errors
44+ ErrPreviewNotFound = errors.New("preview not found")
45+
43 // Config errors46 // Config errors
44 ErrConfigNotFound = errors.New("configuration not found")47 ErrConfigNotFound = errors.New("configuration not found")
45 ErrInvalidConfig = errors.New("invalid configuration")48 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 {
33 DataDir string33 DataDir string
34 AutoRouteEnabled bool34 AutoRouteEnabled bool
35 AutoRouteAllowedDomains []string `mapstructure:"auto_route_allowed_domains" json:"auto_route_allowed_domains,omitempty"`35 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
36 NetworkIsolation bool41 NetworkIsolation bool
37 NetworkPrefix string42 NetworkPrefix string
38 Routes map[string]string43 Routes map[string]string
@@ -122,13 +127,43 @@ func (s *Service) loadConfigValues() Config {
122 registryDomain = s.viper.GetString("server.registry_domain")127 registryDomain = s.viper.GetString("server.registry_domain")
123 }128 }
124 129
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+
125 return Config{155 return Config{
126 ServerPort: s.viper.GetInt("server.port"),156 ServerPort: s.viper.GetInt("server.port"),
127 RegistryPort: s.viper.GetInt("server.registry_port"),157 RegistryPort: s.viper.GetInt("server.registry_port"),
128 RegistryDomain: registryDomain,158 RegistryDomain: registryDomain,
129 DataDir: s.viper.GetString("server.data_dir"),159 DataDir: s.viper.GetString("server.data_dir"),
130- AutoRouteEnabled: s.viper.GetBool("auto_route.enabled"),160+ AutoRouteEnabled: autoEnabled,
131- AutoRouteAllowedDomains: append([]string{}, s.viper.GetStringSlice("auto_route_allowed_domains")...),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"),
132 NetworkIsolation: s.viper.GetBool("network_isolation.enabled"),167 NetworkIsolation: s.viper.GetBool("network_isolation.enabled"),
133 NetworkPrefix: s.viper.GetString("network_isolation.network_prefix"),168 NetworkPrefix: s.viper.GetString("network_isolation.network_prefix"),
134 RegistryAuthEnabled: s.viper.GetBool("auth.enabled"),169 RegistryAuthEnabled: s.viper.GetBool("auth.enabled"),
@@ -519,7 +554,7 @@ type configSnapshot struct {
519 }554 }
520 555
521 // mutableSections lists config sections that are intentionally modified by service operations.556 // 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."}
523 558
524 func isMutableKey(key string) bool {559 func isMutableKey(key string) bool {
525 for _, prefix := range mutableSections {560 for _, prefix := range mutableSections {
@@ -529,7 +564,7 @@ func isMutableKey(key string) bool {
529 }564 }
530 565
531 switch key {566 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":
533 return true568 return true
534 }569 }
535 570
@@ -570,6 +605,7 @@ func (s *Service) writeConfigSurgical(configFile string) error {
570 s.viper.Set("attachments", s.config.Attachments)605 s.viper.Set("attachments", s.config.Attachments)
571 s.viper.Set("network_groups", s.config.NetworkGroups)606 s.viper.Set("network_groups", s.config.NetworkGroups)
572 s.viper.Set("auto_route_allowed_domains", s.config.AutoRouteAllowedDomains)607 s.viper.Set("auto_route_allowed_domains", s.config.AutoRouteAllowedDomains)
608+ s.viper.Set("auto.allowed_domains", s.config.AutoRouteAllowedDomains)
573 return s.viper.WriteConfigAs(configFile)609 return s.viper.WriteConfigAs(configFile)
574 }610 }
575 return fmt.Errorf("failed to read config file: %w", err)611 return fmt.Errorf("failed to read config file: %w", err)
@@ -589,7 +625,15 @@ func (s *Service) writeConfigSurgical(configFile string) error {
589 config["external_routes"] = s.config.ExternalRoutes625 config["external_routes"] = s.config.ExternalRoutes
590 config["attachments"] = s.config.Attachments626 config["attachments"] = s.config.Attachments
591 config["network_groups"] = s.config.NetworkGroups627 config["network_groups"] = s.config.NetworkGroups
628+ // Write to both old and new keys for backward compat
592 config["auto_route_allowed_domains"] = s.config.AutoRouteAllowedDomains629 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+ }
593 637
594 out, err := toml.Marshal(config)638 out, err := toml.Marshal(config)
595 if err != nil {639 if err != nil {
@@ -1050,6 +1094,45 @@ func (s *Service) RemoveAttachment(ctx context.Context, domainOrGroup, image str
1050 return nil1094 return nil
1051 }1095 }
1052 1096
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+
1053 // GetAutoRouteAllowedDomains returns the configured auto-route domain allowlist.1136 // GetAutoRouteAllowedDomains returns the configured auto-route domain allowlist.
1054 func (s *Service) GetAutoRouteAllowedDomains(_ context.Context) ([]string, error) {1137 func (s *Service) GetAutoRouteAllowedDomains(_ context.Context) ([]string, error) {
1055 s.mu.RLock()1138 s.mu.RLock()
M internal/usecase/container/autoroute.go +7 -151
@@ -4,9 +4,7 @@ import (
4 "bufio"4 "bufio"
5 "bytes"5 "bytes"
6 "context"6 "context"
7- "encoding/json"
8 "fmt"7 "fmt"
9- "io"
10 "os"8 "os"
11 "path/filepath"9 "path/filepath"
12 "sort"10 "sort"
@@ -17,6 +15,7 @@ import (
17 "github.com/bnema/gordon/internal/boundaries/in"15 "github.com/bnema/gordon/internal/boundaries/in"
18 "github.com/bnema/gordon/internal/boundaries/out"16 "github.com/bnema/gordon/internal/boundaries/out"
19 "github.com/bnema/gordon/internal/domain"17 "github.com/bnema/gordon/internal/domain"
18+ "github.com/bnema/gordon/internal/usecase/auto"
20 )19 )
21 20
22 // EnvFileExtractor defines the interface for extracting env files from images.21 // EnvFileExtractor defines the interface for extracting env files from images.
@@ -205,7 +204,7 @@ func (h *AutoRouteHandler) createOrUpdateRoute(ctx context.Context, routeDomain,
205 existingRoutes := h.configSvc.GetRoutes(ctx)204 existingRoutes := h.configSvc.GetRoutes(ctx)
206 for _, existing := range existingRoutes {205 for _, existing := range existingRoutes {
207 if existing.Domain == routeDomain {206 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) {
209 log.Warn().Str("domain", routeDomain).Str("existing_image", existing.Image).Str("image", imageName).Msg("auto-route update rejected due to repository ownership mismatch")208 log.Warn().Str("domain", routeDomain).Str("existing_image", existing.Image).Str("image", imageName).Msg("auto-route update rejected due to repository ownership mismatch")
210 return false209 return false
211 }210 }
@@ -222,7 +221,7 @@ func (h *AutoRouteHandler) createOrUpdateRoute(ctx context.Context, routeDomain,
222 }221 }
223 }222 }
224 223
225- if !matchesDomainAllowlist(routeDomain, allowedDomains) {224+ if !auto.MatchesDomainAllowlist(routeDomain, allowedDomains) {
226 log.Warn().Str("domain", routeDomain).Strs("allowed_domains", allowedDomains).Msg("auto-route create rejected due to domain allowlist")225 log.Warn().Str("domain", routeDomain).Strs("allowed_domains", allowedDomains).Msg("auto-route create rejected due to domain allowlist")
227 return false226 return false
228 }227 }
@@ -239,55 +238,6 @@ func (h *AutoRouteHandler) createOrUpdateRoute(ctx context.Context, routeDomain,
239 return false238 return false
240 }239 }
241 240
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-
291 // triggerDeploy initiates deployment for a route.241 // triggerDeploy initiates deployment for a route.
292 func (h *AutoRouteHandler) triggerDeploy(ctx context.Context, route domain.Route) {242 func (h *AutoRouteHandler) triggerDeploy(ctx context.Context, route domain.Route) {
293 log := zerowrap.FromCtx(ctx)243 log := zerowrap.FromCtx(ctx)
@@ -389,108 +339,14 @@ func (h *AutoRouteHandler) CanHandle(eventType domain.EventType) bool {
389 func (h *AutoRouteHandler) extractLabels(ctx context.Context, manifestData []byte) (*domain.ImageLabels, error) {339 func (h *AutoRouteHandler) extractLabels(ctx context.Context, manifestData []byte) (*domain.ImageLabels, error) {
390 log := zerowrap.FromCtx(ctx)340 log := zerowrap.FromCtx(ctx)
391 341
392- // Parse manifest to get config digest342+ labels, err := auto.ExtractLabels(ctx, manifestData, h.blobStorage)
393- configDigest, err := parseConfigDigest(manifestData)
394 if err != nil {343 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 {
457 return nil, err344 return nil, err
458 }345 }
459 346
460- labels := &domain.ImageLabels{}347+ // Log the config digest for observability (best-effort parse for logging only)
461-348+ if digest, parseErr := auto.ParseConfigDigest(manifestData); parseErr == nil && digest != "" {
462- if config.Config.Labels == nil {349+ log.Debug().Str("config_digest", digest).Msg("found config digest")
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)
494 }350 }
495 351
496 return labels, nil352 return labels, nil
M internal/usecase/container/autoroute_test.go +10 -8
@@ -15,6 +15,7 @@ import (
15 inmocks "github.com/bnema/gordon/internal/boundaries/in/mocks"15 inmocks "github.com/bnema/gordon/internal/boundaries/in/mocks"
16 "github.com/bnema/gordon/internal/boundaries/out/mocks"16 "github.com/bnema/gordon/internal/boundaries/out/mocks"
17 "github.com/bnema/gordon/internal/domain"17 "github.com/bnema/gordon/internal/domain"
18+ "github.com/bnema/gordon/internal/usecase/auto"
18 )19 )
19 20
20 // domainToEnvFileName tests21 // domainToEnvFileName tests
@@ -154,7 +155,8 @@ func TestParseEnvFile(t *testing.T) {
154 }155 }
155 }156 }
156 157
157-// parseConfigDigest tests158+// parseConfigDigest tests — now delegated to auto package; tested there directly.
159+// These tests call auto.ParseConfigDigest to maintain coverage for this package.
158 160
159 func TestParseConfigDigest(t *testing.T) {161 func TestParseConfigDigest(t *testing.T) {
160 tests := []struct {162 tests := []struct {
@@ -206,7 +208,7 @@ func TestParseConfigDigest(t *testing.T) {
206 data, err := json.Marshal(tt.manifest)208 data, err := json.Marshal(tt.manifest)
207 require.NoError(t, err)209 require.NoError(t, err)
208 210
209- result, err := parseConfigDigest(data)211+ result, err := auto.ParseConfigDigest(data)
210 212
211 if tt.wantErr {213 if tt.wantErr {
212 assert.Error(t, err)214 assert.Error(t, err)
@@ -220,11 +222,11 @@ func TestParseConfigDigest(t *testing.T) {
220 }222 }
221 223
222 func TestParseConfigDigest_InvalidJSON(t *testing.T) {224 func TestParseConfigDigest_InvalidJSON(t *testing.T) {
223- _, err := parseConfigDigest([]byte("invalid json"))225+ _, err := auto.ParseConfigDigest([]byte("invalid json"))
224 assert.Error(t, err)226 assert.Error(t, err)
225 }227 }
226 228
227-// parseImageLabels tests229+// parseImageLabels tests — now delegated to auto package; tested there directly.
228 230
229 func TestParseImageLabels(t *testing.T) {231 func TestParseImageLabels(t *testing.T) {
230 tests := []struct {232 tests := []struct {
@@ -317,7 +319,7 @@ func TestParseImageLabels(t *testing.T) {
317 data, err := json.Marshal(tt.config)319 data, err := json.Marshal(tt.config)
318 require.NoError(t, err)320 require.NoError(t, err)
319 321
320- result, err := parseImageLabels(data)322+ result, err := auto.ParseImageLabels(data)
321 require.NoError(t, err)323 require.NoError(t, err)
322 324
323 assert.Equal(t, tt.expected.Domain, result.Domain)325 assert.Equal(t, tt.expected.Domain, result.Domain)
@@ -330,7 +332,7 @@ func TestParseImageLabels(t *testing.T) {
330 }332 }
331 333
332 func TestParseImageLabels_InvalidJSON(t *testing.T) {334 func TestParseImageLabels_InvalidJSON(t *testing.T) {
333- _, err := parseImageLabels([]byte("invalid json"))335+ _, err := auto.ParseImageLabels([]byte("invalid json"))
334 assert.Error(t, err)336 assert.Error(t, err)
335 }337 }
336 338
@@ -353,7 +355,7 @@ func TestMatchesDomainAllowlist(t *testing.T) {
353 355
354 for _, tt := range tests {356 for _, tt := range tests {
355 t.Run(tt.name, func(t *testing.T) {357 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))
357 })359 })
358 }360 }
359 }361 }
@@ -376,7 +378,7 @@ func TestExtractRepoName(t *testing.T) {
376 378
377 for _, tt := range tests {379 for _, tt := range tests {
378 t.Run(tt.name, func(t *testing.T) {380 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))
380 })382 })
381 }383 }
382 }384 }
M pkg/runtime/interface.go +1 -0
@@ -67,6 +67,7 @@ type Runtime interface {
67 CreateContainer(ctx context.Context, config *ContainerConfig) (*Container, error)67 CreateContainer(ctx context.Context, config *ContainerConfig) (*Container, error)
68 StartContainer(ctx context.Context, containerID string) error68 StartContainer(ctx context.Context, containerID string) error
69 StopContainer(ctx context.Context, containerID string) error69 StopContainer(ctx context.Context, containerID string) error
70+ WaitForContainer(ctx context.Context, containerID string) error
70 RestartContainer(ctx context.Context, containerID string) error71 RestartContainer(ctx context.Context, containerID string) error
71 RemoveContainer(ctx context.Context, containerID string, force bool) error72 RemoveContainer(ctx context.Context, containerID string, force bool) error
72 73

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