Merge pull request #134 from bnema/feat/preview-environments
feat: ephemeral preview environments
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 | 182 | ### Authentication Failed | |
| 183 | 183 | ||
| 184 | - | ``` | |
| 184 | + | ```text | |
| 185 | 185 | 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 | 50 | - [Authentication](./config/auth.md) - Registry auth plus remote CLI login/token workflows | |
| 51 | 51 | - [Secrets](./config/secrets.md) - Secure credential storage | |
| 52 | 52 | - [Network Isolation](./config/network-isolation.md) - Per-app network isolation | |
| 53 | + | - [Preview Environments](./config/preview.md) - Ephemeral per-branch deployments | |
| 53 | 54 | - [Attachments](./config/attachments.md) - Service dependencies | |
| 54 | 55 | - [Logging](./config/logging.md) - Log collection and rotation | |
| 55 | 56 | - [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 | 145 | autorouteCmd.GroupID = groupManage | |
| 146 | 146 | rootCmd.AddCommand(autorouteCmd) | |
| 147 | 147 | ||
| 148 | + | previewCmd := newPreviewCmd() | |
| 149 | + | previewCmd.GroupID = groupManage | |
| 150 | + | rootCmd.AddCommand(previewCmd) | |
| 151 | + | ||
| 148 | 152 | // Client-only commands (no server needed) | |
| 149 | 153 | remotesCmd := newRemotesCmd() | |
| 150 | 154 | 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 | 24 | secretSvc := inmocks.NewMockSecretService(t) | |
| 25 | 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 | 35 | payload := dto.BootstrapRequest{ | |
| 30 | 36 | Domain: "app.example.com", | |
| @@ -73,7 +79,12 @@ func TestBootstrap_Idempotent_Rerun(t *testing.T) { | |||
| 73 | 79 | containerSvc := inmocks.NewMockContainerService(t) | |
| 74 | 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 | 89 | payload := dto.BootstrapRequest{ | |
| 79 | 90 | Domain: "app.example.com", | |
| @@ -137,7 +148,12 @@ func TestBootstrap_MissingDomain(t *testing.T) { | |||
| 137 | 148 | containerSvc := inmocks.NewMockContainerService(t) | |
| 138 | 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 | 158 | body, err := json.Marshal(dto.BootstrapRequest{Image: "myapp:latest"}) | |
| 143 | 159 | require.NoError(t, err) | |
| @@ -158,7 +174,12 @@ func TestBootstrap_MissingImage(t *testing.T) { | |||
| 158 | 174 | containerSvc := inmocks.NewMockContainerService(t) | |
| 159 | 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 | 184 | body, err := json.Marshal(dto.BootstrapRequest{Domain: "app.example.com"}) | |
| 164 | 185 | require.NoError(t, err) | |
| @@ -179,7 +200,12 @@ func TestBootstrap_NormalizesImageBeforeStoring(t *testing.T) { | |||
| 179 | 200 | containerSvc := inmocks.NewMockContainerService(t) | |
| 180 | 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 | 210 | payload := dto.BootstrapRequest{Domain: "app.example.com", Image: "pitlane:v1"} | |
| 185 | 211 | configSvc.EXPECT().GetRegistryDomain().Return("reg.bnema.dev").Once() | |
| @@ -225,7 +251,12 @@ func TestBootstrap_AddRouteValidationErrors(t *testing.T) { | |||
| 225 | 251 | containerSvc := inmocks.NewMockContainerService(t) | |
| 226 | 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 | 261 | payload := dto.BootstrapRequest{Domain: "app.example.com", Image: "myapp:latest"} | |
| 231 | 262 | configSvc.EXPECT().GetRegistryDomain().Return("reg.bnema.dev").Once() | |
| @@ -252,7 +283,12 @@ func TestBootstrap_PartialFailure_AttachmentError(t *testing.T) { | |||
| 252 | 283 | containerSvc := inmocks.NewMockContainerService(t) | |
| 253 | 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 | 293 | payload := dto.BootstrapRequest{ | |
| 258 | 294 | Domain: "app.example.com", | |
M internal/adapters/in/http/admin/handler.go +34 -35
| @@ -47,6 +47,7 @@ type Handler struct { | |||
| 47 | 47 | logSvc in.LogService | |
| 48 | 48 | volumeSvc in.VolumeService | |
| 49 | 49 | registrySvc registryDeployService | |
| 50 | + | previewSvc previewService | |
| 50 | 51 | eventBus out.EventPublisher | |
| 51 | 52 | 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 | - | func NewHandler( | |
| 135 | - | configSvc in.ConfigService, | |
| 136 | - | authSvc in.AuthService, | |
| 137 | - | containerSvc in.ContainerService, | |
| 138 | - | healthSvc in.HealthService, | |
| 139 | - | secretSvc in.SecretService, | |
| 140 | - | logSvc in.LogService, | |
| 141 | - | registrySvc registryDeployService, | |
| 142 | - | eventBus out.EventPublisher, | |
| 143 | - | log zerowrap.Logger, | |
| 144 | - | backupSvc in.BackupService, | |
| 145 | - | imageSvcs ...in.ImageService, | |
| 146 | - | ) *Handler { | |
| 147 | - | var imageSvc in.ImageService | |
| 148 | - | if len(imageSvcs) > 0 { | |
| 149 | - | imageSvc = imageSvcs[0] | |
| 150 | - | } | |
| 134 | + | // HandlerDeps contains all dependencies for the admin HTTP handler. | |
| 135 | + | type HandlerDeps struct { | |
| 136 | + | ConfigSvc in.ConfigService | |
| 137 | + | AuthSvc in.AuthService | |
| 138 | + | ContainerSvc in.ContainerService | |
| 139 | + | HealthSvc in.HealthService | |
| 140 | + | SecretSvc in.SecretService | |
| 141 | + | LogSvc in.LogService | |
| 142 | + | RegistrySvc registryDeployService | |
| 143 | + | EventBus out.EventPublisher | |
| 144 | + | Log zerowrap.Logger | |
| 145 | + | BackupSvc in.BackupService | |
| 146 | + | PreviewSvc previewService | |
| 147 | + | ImageSvc in.ImageService | |
| 148 | + | VolumeSvc in.VolumeService | |
| 149 | + | } | |
| 151 | 150 | ||
| 151 | + | // NewHandler creates a new admin HTTP handler. | |
| 152 | + | func NewHandler(deps HandlerDeps) *Handler { | |
| 152 | 153 | return &Handler{ | |
| 153 | - | configSvc: configSvc, | |
| 154 | - | authSvc: authSvc, | |
| 155 | - | containerSvc: containerSvc, | |
| 156 | - | backupSvc: backupSvc, | |
| 157 | - | imageSvc: imageSvc, | |
| 158 | - | healthSvc: healthSvc, | |
| 159 | - | secretSvc: secretSvc, | |
| 160 | - | logSvc: logSvc, | |
| 161 | - | registrySvc: registrySvc, | |
| 162 | - | eventBus: eventBus, | |
| 163 | - | log: log, | |
| 154 | + | configSvc: deps.ConfigSvc, | |
| 155 | + | authSvc: deps.AuthSvc, | |
| 156 | + | containerSvc: deps.ContainerSvc, | |
| 157 | + | backupSvc: deps.BackupSvc, | |
| 158 | + | imageSvc: deps.ImageSvc, | |
| 159 | + | healthSvc: deps.HealthSvc, | |
| 160 | + | secretSvc: deps.SecretSvc, | |
| 161 | + | logSvc: deps.LogSvc, | |
| 162 | + | volumeSvc: deps.VolumeSvc, | |
| 163 | + | registrySvc: deps.RegistrySvc, | |
| 164 | + | previewSvc: deps.PreviewSvc, | |
| 165 | + | eventBus: deps.EventBus, | |
| 166 | + | log: deps.Log, | |
| 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 | 170 | // RegisterRoutes registers the admin routes on the given mux. | |
| 174 | 171 | func (h *Handler) RegisterRoutes(mux *http.ServeMux) { | |
| 175 | 172 | mux.HandleFunc("/admin/", h.handleAdminRoutes) | |
| @@ -239,6 +236,8 @@ func (h *Handler) matchRoute(path string) (routeHandler, bool) { | |||
| 239 | 236 | {"/images", h.handleImages}, | |
| 240 | 237 | {"/logs", h.handleLogs}, | |
| 241 | 238 | {"/autoroute/allowed-domains", h.handleAutoRouteAllowedDomains}, | |
| 239 | + | {"/previews", h.handlePreviewList}, | |
| 240 | + | {"/preview", h.handlePreviewAction}, | |
| 242 | 241 | } | |
| 243 | 242 | for _, route := range prefixRoutes { | |
| 244 | 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 | 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 | 69 | // Routes endpoint tests | |
| 54 | 70 | ||
| 55 | 71 | func TestHandler_RoutesGet_RequiresReadScope(t *testing.T) { | |
| @@ -58,7 +74,13 @@ func TestHandler_RoutesGet_RequiresReadScope(t *testing.T) { | |||
| 58 | 74 | containerSvc := inmocks.NewMockContainerService(t) | |
| 59 | 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 | 85 | tests := []struct { | |
| 64 | 86 | name string | |
| @@ -119,9 +141,13 @@ func TestHandler_RoutesPost_RequiresWriteScope(t *testing.T) { | |||
| 119 | 141 | authSvc := inmocks.NewMockAuthService(t) | |
| 120 | 142 | containerSvc := inmocks.NewMockContainerService(t) | |
| 121 | 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 | 152 | routeJSON := `{"domain": "app.example.com", "image": "myapp:latest"}` | |
| 127 | 153 | ||
| @@ -179,9 +205,13 @@ func TestHandler_RoutesPut_RequiresWriteScope(t *testing.T) { | |||
| 179 | 205 | authSvc := inmocks.NewMockAuthService(t) | |
| 180 | 206 | containerSvc := inmocks.NewMockContainerService(t) | |
| 181 | 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 | 216 | routeJSON := `{"image": "myapp:v2"}` | |
| 187 | 217 | ||
| @@ -227,7 +257,13 @@ func TestHandler_RoutesPost_AllowsRouteCreationWithoutRegistryManifest(t *testin | |||
| 227 | 257 | registrySvc := inmocks.NewMockRegistryService(t) | |
| 228 | 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 | 268 | configSvc.EXPECT().AddRoute(mock.Anything, route).Return(nil).Once() | |
| 233 | 269 | ||
| @@ -250,7 +286,13 @@ func TestHandler_RoutesPut_AllowsRouteUpdateWithoutRegistryManifest(t *testing.T | |||
| 250 | 286 | registrySvc := inmocks.NewMockRegistryService(t) | |
| 251 | 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 | 297 | configSvc.EXPECT().UpdateRoute(mock.Anything, route).Return(nil).Once() | |
| 256 | 298 | ||
| @@ -271,7 +313,12 @@ func TestHandler_AttachmentsByImageGet_OneTarget(t *testing.T) { | |||
| 271 | 313 | containerSvc := inmocks.NewMockContainerService(t) | |
| 272 | 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 | 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 | 338 | containerSvc := inmocks.NewMockContainerService(t) | |
| 292 | 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 | 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 | 363 | containerSvc := inmocks.NewMockContainerService(t) | |
| 312 | 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 | 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 | 388 | containerSvc := inmocks.NewMockContainerService(t) | |
| 332 | 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 | 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 | 413 | containerSvc := inmocks.NewMockContainerService(t) | |
| 352 | 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 | 423 | req := httptest.NewRequest("GET", "/admin/attachments/by-image", nil) | |
| 357 | 424 | req = req.WithContext(ctxWithScopes("admin:config:read")) | |
| @@ -369,7 +436,12 @@ func TestHandler_AttachmentsByImageGet_InvalidEncoding(t *testing.T) { | |||
| 369 | 436 | containerSvc := inmocks.NewMockContainerService(t) | |
| 370 | 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 | 446 | req := httptest.NewRequest("GET", "/admin/attachments/by-image/placeholder", nil) | |
| 375 | 447 | req.URL = &url.URL{Path: "/admin/attachments/by-image/%zz"} | |
| @@ -388,7 +460,12 @@ func TestHandler_AttachmentsByImageGet_RequiresReadScope(t *testing.T) { | |||
| 388 | 460 | containerSvc := inmocks.NewMockContainerService(t) | |
| 389 | 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 | 470 | req := httptest.NewRequest("GET", "/admin/attachments/by-image/postgres:16", nil) | |
| 394 | 471 | req = req.WithContext(ctxWithScopes("admin:routes:read")) | |
| @@ -406,7 +483,12 @@ func TestHandler_RoutesDelete_RequiresWriteScope(t *testing.T) { | |||
| 406 | 483 | containerSvc := inmocks.NewMockContainerService(t) | |
| 407 | 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 | 493 | tests := []struct { | |
| 412 | 494 | name string | |
| @@ -450,7 +532,12 @@ func TestHandler_SecretsGet_RequiresReadScope(t *testing.T) { | |||
| 450 | 532 | containerSvc := inmocks.NewMockContainerService(t) | |
| 451 | 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 | 542 | tests := []struct { | |
| 456 | 543 | name string | |
| @@ -507,7 +594,12 @@ func TestHandler_SecretsPost_RequiresWriteScope(t *testing.T) { | |||
| 507 | 594 | containerSvc := inmocks.NewMockContainerService(t) | |
| 508 | 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 | 604 | secretsJSON := `{"API_KEY": "secret123"}` | |
| 513 | 605 | ||
| @@ -556,7 +648,12 @@ func TestHandler_SecretsDelete_RequiresWriteScope(t *testing.T) { | |||
| 556 | 648 | containerSvc := inmocks.NewMockContainerService(t) | |
| 557 | 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 | 658 | tests := []struct { | |
| 562 | 659 | name string | |
| @@ -600,7 +697,12 @@ func TestHandler_AttachmentSecretsPost(t *testing.T) { | |||
| 600 | 697 | containerSvc := inmocks.NewMockContainerService(t) | |
| 601 | 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 | 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 | 728 | containerSvc := inmocks.NewMockContainerService(t) | |
| 627 | 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 | 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 | 759 | secretSvc := inmocks.NewMockSecretService(t) | |
| 653 | 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 | 770 | configSvc.EXPECT().GetRegistryDomain().Return("reg.bnema.dev").Once() | |
| 658 | 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 | 812 | containerSvc := inmocks.NewMockContainerService(t) | |
| 700 | 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 | 822 | tests := []struct { | |
| 705 | 823 | name string | |
| @@ -741,7 +859,12 @@ func TestHandler_Status_RequiresReadScope(t *testing.T) { | |||
| 741 | 859 | containerSvc := inmocks.NewMockContainerService(t) | |
| 742 | 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 | 869 | tests := []struct { | |
| 747 | 870 | name string | |
| @@ -795,7 +918,12 @@ func TestHandler_Config_RequiresReadScope(t *testing.T) { | |||
| 795 | 918 | containerSvc := inmocks.NewMockContainerService(t) | |
| 796 | 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 | 928 | tests := []struct { | |
| 801 | 929 | name string | |
| @@ -852,7 +980,12 @@ func TestHandler_Reload_RequiresWriteScope(t *testing.T) { | |||
| 852 | 980 | containerSvc := inmocks.NewMockContainerService(t) | |
| 853 | 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 | 990 | tests := []struct { | |
| 858 | 991 | name string | |
| @@ -906,7 +1039,12 @@ func TestHandler_RoutesGet_ReturnsRoutes(t *testing.T) { | |||
| 906 | 1039 | containerSvc := inmocks.NewMockContainerService(t) | |
| 907 | 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 | 1049 | expectedRoutes := []domain.Route{ | |
| 912 | 1050 | {Domain: "app1.example.com", Image: "app1:latest"}, | |
| @@ -941,7 +1079,12 @@ func TestHandler_RoutesGet_SingleRoute(t *testing.T) { | |||
| 941 | 1079 | containerSvc := inmocks.NewMockContainerService(t) | |
| 942 | 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 | 1089 | configSvc.EXPECT().GetRoute(mock.Anything, "app.example.com").Return(&domain.Route{ | |
| 947 | 1090 | Domain: "app.example.com", Image: "app:latest", | |
| @@ -972,7 +1115,12 @@ func TestHandler_RoutesGet_NotFound(t *testing.T) { | |||
| 972 | 1115 | containerSvc := inmocks.NewMockContainerService(t) | |
| 973 | 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 | 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 | 1164 | secretSvc := inmocks.NewMockSecretService(t) | |
| 1017 | 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 | 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 | 1191 | containerSvc := inmocks.NewMockContainerService(t) | |
| 1038 | 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 | 1201 | req := httptest.NewRequest("PUT", "/admin/routes/", bytes.NewBufferString(`{"image": "app:latest"}`)) | |
| 1043 | 1202 | req = req.WithContext(ctxWithScopes("admin:routes:write")) | |
| @@ -1054,7 +1213,12 @@ func TestHandler_RoutesDelete_MissingDomain(t *testing.T) { | |||
| 1054 | 1213 | containerSvc := inmocks.NewMockContainerService(t) | |
| 1055 | 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 | 1223 | req := httptest.NewRequest("DELETE", "/admin/routes/", nil) | |
| 1060 | 1224 | req = req.WithContext(ctxWithScopes("admin:routes:write")) | |
| @@ -1071,7 +1235,12 @@ func TestHandler_Secrets_MissingDomain(t *testing.T) { | |||
| 1071 | 1235 | containerSvc := inmocks.NewMockContainerService(t) | |
| 1072 | 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 | 1245 | req := httptest.NewRequest("GET", "/admin/secrets/", nil) | |
| 1077 | 1246 | req = req.WithContext(ctxWithScopes("admin:secrets:read")) | |
| @@ -1088,7 +1257,12 @@ func TestHandler_SecretsDelete_MissingKey(t *testing.T) { | |||
| 1088 | 1257 | containerSvc := inmocks.NewMockContainerService(t) | |
| 1089 | 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 | 1267 | req := httptest.NewRequest("DELETE", "/admin/secrets/app.example.com", nil) | |
| 1094 | 1268 | req = req.WithContext(ctxWithScopes("admin:secrets:write")) | |
| @@ -1105,7 +1279,12 @@ func TestHandler_MethodNotAllowed(t *testing.T) { | |||
| 1105 | 1279 | containerSvc := inmocks.NewMockContainerService(t) | |
| 1106 | 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 | 1289 | tests := []struct { | |
| 1111 | 1290 | method string | |
| @@ -1138,7 +1317,12 @@ func TestHandler_Restart_InvalidDomain(t *testing.T) { | |||
| 1138 | 1317 | containerSvc := inmocks.NewMockContainerService(t) | |
| 1139 | 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 | 1327 | tests := []struct { | |
| 1144 | 1328 | name string | |
| @@ -1168,7 +1352,12 @@ func TestHandler_Restart_ContainerNotFound(t *testing.T) { | |||
| 1168 | 1352 | containerSvc := inmocks.NewMockContainerService(t) | |
| 1169 | 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 | 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 | 1376 | containerSvc := inmocks.NewMockContainerService(t) | |
| 1188 | 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 | 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 | 1403 | containerSvc := inmocks.NewMockContainerService(t) | |
| 1210 | 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 | 1413 | tests := []struct { | |
| 1215 | 1414 | name string | |
| @@ -1256,7 +1455,12 @@ func TestHandler_Tags_InvalidRepository(t *testing.T) { | |||
| 1256 | 1455 | containerSvc := inmocks.NewMockContainerService(t) | |
| 1257 | 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 | 1465 | tests := []struct { | |
| 1262 | 1466 | name string | |
| @@ -1287,7 +1491,13 @@ func TestHandler_Tags_Success(t *testing.T) { | |||
| 1287 | 1491 | secretSvc := inmocks.NewMockSecretService(t) | |
| 1288 | 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 | 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 | 1524 | secretSvc := inmocks.NewMockSecretService(t) | |
| 1315 | 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 | 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 | 1550 | containerSvc := inmocks.NewMockContainerService(t) | |
| 1335 | 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 | 1560 | body := `{"REDIS_URL": "redis://localhost:6379"}` | |
| 1340 | 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 | 1573 | containerSvc := inmocks.NewMockContainerService(t) | |
| 1353 | 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 | 1583 | req := httptest.NewRequest("DELETE", "/admin/secrets/app.example.com/attachments/redis/REDIS_URL", nil) | |
| 1358 | 1584 | req = req.WithContext(ctxWithScopes("admin:secrets:read")) | |
| @@ -1369,7 +1595,12 @@ func TestHandler_AttachmentSecretsDelete_RequiresKey(t *testing.T) { | |||
| 1369 | 1595 | containerSvc := inmocks.NewMockContainerService(t) | |
| 1370 | 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 | 1605 | req := httptest.NewRequest("DELETE", "/admin/secrets/app.example.com/attachments/redis", nil) | |
| 1375 | 1606 | req = req.WithContext(ctxWithScopes("admin:secrets:write")) | |
| @@ -1387,7 +1618,12 @@ func TestHandler_AttachmentSecretsPost_InvalidJSON(t *testing.T) { | |||
| 1387 | 1618 | containerSvc := inmocks.NewMockContainerService(t) | |
| 1388 | 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 | 1628 | body := `{not valid json` | |
| 1393 | 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 | 1642 | containerSvc := inmocks.NewMockContainerService(t) | |
| 1407 | 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 | 1652 | req := httptest.NewRequest("GET", "/admin/unknown", nil) | |
| 1412 | 1653 | req = req.WithContext(ctxWithScopes("admin:*:*")) | |
| @@ -1426,7 +1667,13 @@ func TestHandler_BackupsStatus(t *testing.T) { | |||
| 1426 | 1667 | ||
| 1427 | 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 | 1678 | req := httptest.NewRequest("GET", "/admin/backups/status", nil) | |
| 1432 | 1679 | req = req.WithContext(ctxWithScopes("admin:status:read")) | |
| @@ -1448,7 +1695,13 @@ func TestHandler_BackupsListDomain(t *testing.T) { | |||
| 1448 | 1695 | ||
| 1449 | 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 | 1706 | req := httptest.NewRequest("GET", "/admin/backups/app.example.com", nil) | |
| 1454 | 1707 | req = req.WithContext(ctxWithScopes("admin:status:read")) | |
| @@ -1469,7 +1722,13 @@ func TestHandler_BackupsRunDomain(t *testing.T) { | |||
| 1469 | 1722 | ||
| 1470 | 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 | 1733 | body := bytes.NewBufferString(`{"db":"postgres"}`) | |
| 1475 | 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 | 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 | 1761 | body := bytes.NewBufferString(`{"db":"postgres"}`) | |
| 1497 | 1762 | req := httptest.NewRequest("POST", "/admin/backups/app.example.com", body) | |
| @@ -1514,7 +1779,13 @@ func TestHandler_BackupsDetectDomain(t *testing.T) { | |||
| 1514 | 1779 | ||
| 1515 | 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 | 1790 | req := httptest.NewRequest("GET", "/admin/backups/app.example.com/detect", nil) | |
| 1520 | 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 | 1838 | req := httptest.NewRequest("GET", "/admin/images", nil) | |
| 1562 | 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 | 1905 | req := httptest.NewRequest("POST", "/admin/images/prune", bytes.NewBufferString(tt.body)) | |
| 1623 | 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 | 1940 | req := httptest.NewRequest("GET", "/admin/images", nil) | |
| 1652 | 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 | 1971 | req := httptest.NewRequest("POST", "/admin/images/prune", bytes.NewBufferString(`{"keep_last": 2}`)) | |
| 1677 | 1972 | req = req.WithContext(ctxWithScopes("admin:config:write")) | |
| @@ -1692,7 +1987,13 @@ func TestHandler_ImagesPrune_ValidationAndAvailability(t *testing.T) { | |||
| 1692 | 1987 | containerSvc := inmocks.NewMockContainerService(t) | |
| 1693 | 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 | 1998 | req := httptest.NewRequest("POST", "/admin/images/prune", bytes.NewBufferString(`{"keep_last": -1}`)) | |
| 1698 | 1999 | req = req.WithContext(ctxWithScopes("admin:config:write")) | |
| @@ -1710,7 +2011,12 @@ func TestHandler_ImagesPrune_ValidationAndAvailability(t *testing.T) { | |||
| 1710 | 2011 | containerSvc := inmocks.NewMockContainerService(t) | |
| 1711 | 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 | 2021 | req := httptest.NewRequest("GET", "/admin/images", nil) | |
| 1716 | 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 | 2098 | req := httptest.NewRequest("POST", "/admin/images/prune", bytes.NewBufferString(tt.body)) | |
| 1787 | 2099 | req = req.WithContext(ctxWithScopes("admin:config:write")) | |
| @@ -1806,7 +2118,13 @@ func TestHandler_Images_Authorization(t *testing.T) { | |||
| 1806 | 2118 | containerSvc := inmocks.NewMockContainerService(t) | |
| 1807 | 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 | 2129 | t.Run("images list requires status:read", func(t *testing.T) { | |
| 1812 | 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 | 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 | 153 | // Create container configuration | |
| 147 | 154 | containerConfig := &container.Config{ | |
| @@ -207,6 +214,29 @@ func (r *Runtime) StartContainer(ctx context.Context, containerID string) error | |||
| 207 | 214 | 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 | 240 | // StopContainer stops a container. | |
| 211 | 241 | func (r *Runtime) StopContainer(ctx context.Context, containerID string) error { | |
| 212 | 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 | 65 | // Use cases | |
| 66 | 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 | 69 | "github.com/bnema/gordon/internal/usecase/backup" | |
| 68 | 70 | "github.com/bnema/gordon/internal/usecase/config" | |
| 69 | 71 | "github.com/bnema/gordon/internal/usecase/container" | |
| @@ -192,6 +194,8 @@ type services struct { | |||
| 192 | 194 | adminHandler *admin.Handler | |
| 193 | 195 | internalRegUser string | |
| 194 | 196 | internalRegPass string | |
| 197 | + | previewStore *filesystem.PreviewStore | |
| 198 | + | previewService *preview.Service | |
| 195 | 199 | envDir string | |
| 196 | 200 | maxBlobChunkSize int64 | |
| 197 | 201 | } | |
| @@ -541,24 +545,57 @@ func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowra | |||
| 541 | 545 | // Create log service for accessing logs via admin API | |
| 542 | 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 | 550 | // Create admin handler for admin API | |
| 545 | - | svc.adminHandler = admin.NewHandler( | |
| 546 | - | svc.configSvc, | |
| 547 | - | svc.authSvc, | |
| 548 | - | svc.containerSvc, | |
| 549 | - | svc.healthSvc, | |
| 550 | - | svc.secretSvc, | |
| 551 | - | svc.logSvc, | |
| 552 | - | svc.registrySvc, | |
| 553 | - | svc.eventBus, | |
| 554 | - | log, | |
| 555 | - | svc.backupSvc, | |
| 556 | - | svc.imageSvc, | |
| 557 | - | ).WithVolumeService(svc.volumeSvc) | |
| 551 | + | svc.adminHandler = admin.NewHandler(admin.HandlerDeps{ | |
| 552 | + | ConfigSvc: svc.configSvc, | |
| 553 | + | AuthSvc: svc.authSvc, | |
| 554 | + | ContainerSvc: svc.containerSvc, | |
| 555 | + | HealthSvc: svc.healthSvc, | |
| 556 | + | SecretSvc: svc.secretSvc, | |
| 557 | + | LogSvc: svc.logSvc, | |
| 558 | + | RegistrySvc: svc.registrySvc, | |
| 559 | + | EventBus: svc.eventBus, | |
| 560 | + | Log: log, | |
| 561 | + | BackupSvc: svc.backupSvc, | |
| 562 | + | PreviewSvc: svc.previewService, | |
| 563 | + | ImageSvc: svc.imageSvc, | |
| 564 | + | VolumeSvc: svc.volumeSvc, | |
| 565 | + | }) | |
| 558 | 566 | ||
| 559 | 567 | 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 | 599 | // injectTelemetryMetrics creates and injects OTel metrics into services when | |
| 563 | 600 | // telemetry is enabled. Skipped otherwise to avoid unnecessary allocations. | |
| 564 | 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 | 1386 | // Auto-route handler for creating routes from image labels | |
| 1350 | 1387 | autoRouteHandler := container.NewAutoRouteHandler(ctx, svc.configSvc, svc.containerSvc, svc.blobStorage, cfg.Server.RegistryDomain). | |
| 1351 | 1388 | WithEnvExtractor(svc.runtime, svc.envDir) | |
| 1352 | - | if err := svc.eventBus.Subscribe(autoRouteHandler); err != nil { | |
| 1353 | - | return nil, fmt.Errorf("failed to subscribe auto-route handler: %w", err) | |
| 1389 | + | ||
| 1390 | + | // Preview handler for creating preview environments from tagged images | |
| 1391 | + | autoPreviewHandler := preview.NewAutoPreviewHandler( | |
| 1392 | + | ctx, | |
| 1393 | + | svc.configSvc, | |
| 1394 | + | svc.blobStorage, | |
| 1395 | + | svc.previewService, | |
| 1396 | + | ) | |
| 1397 | + | ||
| 1398 | + | // Dispatcher routes image push events to either auto-route or preview handler | |
| 1399 | + | dispatcher := auto.NewImagePushDispatcher(svc.configSvc, autoRouteHandler, autoPreviewHandler) | |
| 1400 | + | if err := svc.eventBus.Subscribe(dispatcher); err != nil { | |
| 1401 | + | return nil, fmt.Errorf("subscribe image push dispatcher: %w", err) | |
| 1354 | 1402 | } | |
| 1355 | 1403 | ||
| 1356 | 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 | 84 | GetAutoRouteAllowedDomains(ctx context.Context) ([]string, error) | |
| 85 | 85 | AddAutoRouteAllowedDomain(ctx context.Context, pattern string) error | |
| 86 | 86 | 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 | 386 | 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 | 435 | // GetAttachmentsFor provides a mock function for the type MockConfigService | |
| 390 | 436 | func (_mock *MockConfigService) GetAttachmentsFor(ctx context.Context, domainOrGroup string) ([]string, error) { | |
| 391 | 437 | ret := _mock.Called(ctx, domainOrGroup) | |
| @@ -650,6 +696,96 @@ func (_c *MockConfigService_GetNetworkPrefix_Call) RunAndReturn(run func() strin | |||
| 650 | 696 | 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 | 789 | // GetRegistryDomain provides a mock function for the type MockConfigService | |
| 654 | 790 | func (_mock *MockConfigService) GetRegistryDomain() string { | |
| 655 | 791 | ret := _mock.Called() | |
| @@ -903,6 +1039,50 @@ func (_c *MockConfigService_GetServerPort_Call) RunAndReturn(run func() int) *Mo | |||
| 903 | 1039 | 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 | 1086 | // IsAutoRouteEnabled provides a mock function for the type MockConfigService | |
| 907 | 1087 | func (_mock *MockConfigService) IsAutoRouteEnabled() bool { | |
| 908 | 1088 | ret := _mock.Called() | |
| @@ -991,6 +1171,50 @@ func (_c *MockConfigService_IsNetworkIsolationEnabled_Call) RunAndReturn(run fun | |||
| 991 | 1171 | 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 | 1218 | // Load provides a mock function for the type MockConfigService | |
| 995 | 1219 | func (_mock *MockConfigService) Load(ctx context.Context) error { | |
| 996 | 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 | 46 | // ContainerConfig holds configuration for creating a container. | |
| 47 | 47 | type ContainerConfig struct { | |
| 48 | - | Image string | |
| 49 | - | Name string | |
| 50 | - | Env []string | |
| 51 | - | Ports []int | |
| 52 | - | Labels map[string]string | |
| 53 | - | WorkingDir string | |
| 54 | - | Cmd []string | |
| 55 | - | AutoRemove bool | |
| 56 | - | Volumes map[string]string // map[containerPath]volumeName | |
| 57 | - | NetworkMode string // Network to join | |
| 58 | - | Hostname string // Container hostname for DNS | |
| 59 | - | Aliases []string // Additional network aliases | |
| 48 | + | Image string | |
| 49 | + | Name string | |
| 50 | + | Env []string | |
| 51 | + | Ports []int | |
| 52 | + | Labels map[string]string | |
| 53 | + | WorkingDir string | |
| 54 | + | Cmd []string | |
| 55 | + | AutoRemove bool | |
| 56 | + | Volumes map[string]string // map[containerPath]volumeName | |
| 57 | + | ReadOnlyVolumes map[string]string // containerPath -> volumeName (mounted read-only) | |
| 58 | + | NetworkMode string // Network to join | |
| 59 | + | Hostname string // Container hostname for DNS | |
| 60 | + | Aliases []string // Additional network aliases | |
| 60 | 61 | } | |
| 61 | 62 | ||
| 62 | 63 | // ContainerStatus represents the current state of a container. | |
M internal/domain/errors.go +3 -0
| @@ -40,6 +40,9 @@ var ( | |||
| 40 | 40 | ErrVolumeNotFound = errors.New("volume not found") | |
| 41 | 41 | ErrVolumeExists = errors.New("volume already exists") | |
| 42 | 42 | ||
| 43 | + | // Preview errors | |
| 44 | + | ErrPreviewNotFound = errors.New("preview not found") | |
| 45 | + | ||
| 43 | 46 | // Config errors | |
| 44 | 47 | ErrConfigNotFound = errors.New("configuration not found") | |
| 45 | 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 | 33 | DataDir string | |
| 34 | 34 | AutoRouteEnabled bool | |
| 35 | 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 | 41 | NetworkIsolation bool | |
| 37 | 42 | NetworkPrefix string | |
| 38 | 43 | Routes map[string]string | |
| @@ -122,13 +127,43 @@ func (s *Service) loadConfigValues() Config { | |||
| 122 | 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 | 155 | return Config{ | |
| 126 | 156 | ServerPort: s.viper.GetInt("server.port"), | |
| 127 | 157 | RegistryPort: s.viper.GetInt("server.registry_port"), | |
| 128 | 158 | RegistryDomain: registryDomain, | |
| 129 | 159 | DataDir: s.viper.GetString("server.data_dir"), | |
| 130 | - | AutoRouteEnabled: s.viper.GetBool("auto_route.enabled"), | |
| 131 | - | AutoRouteAllowedDomains: append([]string{}, s.viper.GetStringSlice("auto_route_allowed_domains")...), | |
| 160 | + | AutoRouteEnabled: autoEnabled, | |
| 161 | + | AutoRouteAllowedDomains: append([]string{}, allowedDomains...), | |
| 162 | + | PreviewEnabled: s.viper.GetBool("auto.preview.enabled"), | |
| 163 | + | PreviewTTL: previewTTL, | |
| 164 | + | PreviewSeparator: previewSep, | |
| 165 | + | PreviewTagPatterns: append([]string{}, s.viper.GetStringSlice("auto.preview.tag_patterns")...), | |
| 166 | + | PreviewDataCopy: s.viper.GetBool("auto.preview.data_copy"), | |
| 132 | 167 | NetworkIsolation: s.viper.GetBool("network_isolation.enabled"), | |
| 133 | 168 | NetworkPrefix: s.viper.GetString("network_isolation.network_prefix"), | |
| 134 | 169 | RegistryAuthEnabled: s.viper.GetBool("auth.enabled"), | |
| @@ -519,7 +554,7 @@ type configSnapshot struct { | |||
| 519 | 554 | } | |
| 520 | 555 | ||
| 521 | 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 | 559 | func isMutableKey(key string) bool { | |
| 525 | 560 | for _, prefix := range mutableSections { | |
| @@ -529,7 +564,7 @@ func isMutableKey(key string) bool { | |||
| 529 | 564 | } | |
| 530 | 565 | ||
| 531 | 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 | 568 | return true | |
| 534 | 569 | } | |
| 535 | 570 | ||
| @@ -570,6 +605,7 @@ func (s *Service) writeConfigSurgical(configFile string) error { | |||
| 570 | 605 | s.viper.Set("attachments", s.config.Attachments) | |
| 571 | 606 | s.viper.Set("network_groups", s.config.NetworkGroups) | |
| 572 | 607 | s.viper.Set("auto_route_allowed_domains", s.config.AutoRouteAllowedDomains) | |
| 608 | + | s.viper.Set("auto.allowed_domains", s.config.AutoRouteAllowedDomains) | |
| 573 | 609 | return s.viper.WriteConfigAs(configFile) | |
| 574 | 610 | } | |
| 575 | 611 | return fmt.Errorf("failed to read config file: %w", err) | |
| @@ -589,7 +625,15 @@ func (s *Service) writeConfigSurgical(configFile string) error { | |||
| 589 | 625 | config["external_routes"] = s.config.ExternalRoutes | |
| 590 | 626 | config["attachments"] = s.config.Attachments | |
| 591 | 627 | config["network_groups"] = s.config.NetworkGroups | |
| 628 | + | // Write to both old and new keys for backward compat | |
| 592 | 629 | 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 | 638 | out, err := toml.Marshal(config) | |
| 595 | 639 | if err != nil { | |
| @@ -1050,6 +1094,45 @@ func (s *Service) RemoveAttachment(ctx context.Context, domainOrGroup, image str | |||
| 1050 | 1094 | 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 | 1136 | // GetAutoRouteAllowedDomains returns the configured auto-route domain allowlist. | |
| 1054 | 1137 | func (s *Service) GetAutoRouteAllowedDomains(_ context.Context) ([]string, error) { | |
| 1055 | 1138 | s.mu.RLock() | |
M internal/usecase/container/autoroute.go +7 -151
| @@ -4,9 +4,7 @@ import ( | |||
| 4 | 4 | "bufio" | |
| 5 | 5 | "bytes" | |
| 6 | 6 | "context" | |
| 7 | - | "encoding/json" | |
| 8 | 7 | "fmt" | |
| 9 | - | "io" | |
| 10 | 8 | "os" | |
| 11 | 9 | "path/filepath" | |
| 12 | 10 | "sort" | |
| @@ -17,6 +15,7 @@ import ( | |||
| 17 | 15 | "github.com/bnema/gordon/internal/boundaries/in" | |
| 18 | 16 | "github.com/bnema/gordon/internal/boundaries/out" | |
| 19 | 17 | "github.com/bnema/gordon/internal/domain" | |
| 18 | + | "github.com/bnema/gordon/internal/usecase/auto" | |
| 20 | 19 | ) | |
| 21 | 20 | ||
| 22 | 21 | // EnvFileExtractor defines the interface for extracting env files from images. | |
| @@ -205,7 +204,7 @@ func (h *AutoRouteHandler) createOrUpdateRoute(ctx context.Context, routeDomain, | |||
| 205 | 204 | existingRoutes := h.configSvc.GetRoutes(ctx) | |
| 206 | 205 | for _, existing := range existingRoutes { | |
| 207 | 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 | 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 | 209 | 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 | 225 | log.Warn().Str("domain", routeDomain).Strs("allowed_domains", allowedDomains).Msg("auto-route create rejected due to domain allowlist") | |
| 227 | 226 | return false | |
| 228 | 227 | } | |
| @@ -239,55 +238,6 @@ func (h *AutoRouteHandler) createOrUpdateRoute(ctx context.Context, routeDomain, | |||
| 239 | 238 | 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 | 241 | // triggerDeploy initiates deployment for a route. | |
| 292 | 242 | func (h *AutoRouteHandler) triggerDeploy(ctx context.Context, route domain.Route) { | |
| 293 | 243 | log := zerowrap.FromCtx(ctx) | |
| @@ -389,108 +339,14 @@ func (h *AutoRouteHandler) CanHandle(eventType domain.EventType) bool { | |||
| 389 | 339 | func (h *AutoRouteHandler) extractLabels(ctx context.Context, manifestData []byte) (*domain.ImageLabels, error) { | |
| 390 | 340 | log := zerowrap.FromCtx(ctx) | |
| 391 | 341 | ||
| 392 | - | // Parse manifest to get config digest | |
| 393 | - | configDigest, err := parseConfigDigest(manifestData) | |
| 342 | + | labels, err := auto.ExtractLabels(ctx, manifestData, h.blobStorage) | |
| 394 | 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 | 344 | return nil, err | |
| 458 | 345 | } | |
| 459 | 346 | ||
| 460 | - | labels := &domain.ImageLabels{} | |
| 461 | - | ||
| 462 | - | if config.Config.Labels == nil { | |
| 463 | - | return labels, nil | |
| 464 | - | } | |
| 465 | - | ||
| 466 | - | // Extract gordon.* labels | |
| 467 | - | if v, ok := config.Config.Labels[domain.LabelDomain]; ok { | |
| 468 | - | labels.Domain = strings.TrimSpace(v) | |
| 469 | - | } | |
| 470 | - | ||
| 471 | - | if v, ok := config.Config.Labels[domain.LabelDomains]; ok { | |
| 472 | - | // Parse comma-separated domains | |
| 473 | - | for _, d := range strings.Split(v, ",") { | |
| 474 | - | d = strings.TrimSpace(d) | |
| 475 | - | if d != "" { | |
| 476 | - | labels.Domains = append(labels.Domains, d) | |
| 477 | - | } | |
| 478 | - | } | |
| 479 | - | } | |
| 480 | - | ||
| 481 | - | if v, ok := config.Config.Labels[domain.LabelHealth]; ok { | |
| 482 | - | labels.Health = strings.TrimSpace(v) | |
| 483 | - | } | |
| 484 | - | ||
| 485 | - | for _, key := range []string{domain.LabelProxyPort, domain.LabelPort} { | |
| 486 | - | if v, ok := config.Config.Labels[key]; ok { | |
| 487 | - | labels.Port = strings.TrimSpace(v) | |
| 488 | - | break | |
| 489 | - | } | |
| 490 | - | } | |
| 491 | - | ||
| 492 | - | if v, ok := config.Config.Labels[domain.LabelEnvFile]; ok { | |
| 493 | - | labels.EnvFile = strings.TrimSpace(v) | |
| 347 | + | // Log the config digest for observability (best-effort parse for logging only) | |
| 348 | + | if digest, parseErr := auto.ParseConfigDigest(manifestData); parseErr == nil && digest != "" { | |
| 349 | + | log.Debug().Str("config_digest", digest).Msg("found config digest") | |
| 494 | 350 | } | |
| 495 | 351 | ||
| 496 | 352 | return labels, nil | |
M internal/usecase/container/autoroute_test.go +10 -8
| @@ -15,6 +15,7 @@ import ( | |||
| 15 | 15 | inmocks "github.com/bnema/gordon/internal/boundaries/in/mocks" | |
| 16 | 16 | "github.com/bnema/gordon/internal/boundaries/out/mocks" | |
| 17 | 17 | "github.com/bnema/gordon/internal/domain" | |
| 18 | + | "github.com/bnema/gordon/internal/usecase/auto" | |
| 18 | 19 | ) | |
| 19 | 20 | ||
| 20 | 21 | // domainToEnvFileName tests | |
| @@ -154,7 +155,8 @@ func TestParseEnvFile(t *testing.T) { | |||
| 154 | 155 | } | |
| 155 | 156 | } | |
| 156 | 157 | ||
| 157 | - | // parseConfigDigest tests | |
| 158 | + | // parseConfigDigest tests — now delegated to auto package; tested there directly. | |
| 159 | + | // These tests call auto.ParseConfigDigest to maintain coverage for this package. | |
| 158 | 160 | ||
| 159 | 161 | func TestParseConfigDigest(t *testing.T) { | |
| 160 | 162 | tests := []struct { | |
| @@ -206,7 +208,7 @@ func TestParseConfigDigest(t *testing.T) { | |||
| 206 | 208 | data, err := json.Marshal(tt.manifest) | |
| 207 | 209 | require.NoError(t, err) | |
| 208 | 210 | ||
| 209 | - | result, err := parseConfigDigest(data) | |
| 211 | + | result, err := auto.ParseConfigDigest(data) | |
| 210 | 212 | ||
| 211 | 213 | if tt.wantErr { | |
| 212 | 214 | assert.Error(t, err) | |
| @@ -220,11 +222,11 @@ func TestParseConfigDigest(t *testing.T) { | |||
| 220 | 222 | } | |
| 221 | 223 | ||
| 222 | 224 | func TestParseConfigDigest_InvalidJSON(t *testing.T) { | |
| 223 | - | _, err := parseConfigDigest([]byte("invalid json")) | |
| 225 | + | _, err := auto.ParseConfigDigest([]byte("invalid json")) | |
| 224 | 226 | assert.Error(t, err) | |
| 225 | 227 | } | |
| 226 | 228 | ||
| 227 | - | // parseImageLabels tests | |
| 229 | + | // parseImageLabels tests — now delegated to auto package; tested there directly. | |
| 228 | 230 | ||
| 229 | 231 | func TestParseImageLabels(t *testing.T) { | |
| 230 | 232 | tests := []struct { | |
| @@ -317,7 +319,7 @@ func TestParseImageLabels(t *testing.T) { | |||
| 317 | 319 | data, err := json.Marshal(tt.config) | |
| 318 | 320 | require.NoError(t, err) | |
| 319 | 321 | ||
| 320 | - | result, err := parseImageLabels(data) | |
| 322 | + | result, err := auto.ParseImageLabels(data) | |
| 321 | 323 | require.NoError(t, err) | |
| 322 | 324 | ||
| 323 | 325 | assert.Equal(t, tt.expected.Domain, result.Domain) | |
| @@ -330,7 +332,7 @@ func TestParseImageLabels(t *testing.T) { | |||
| 330 | 332 | } | |
| 331 | 333 | ||
| 332 | 334 | func TestParseImageLabels_InvalidJSON(t *testing.T) { | |
| 333 | - | _, err := parseImageLabels([]byte("invalid json")) | |
| 335 | + | _, err := auto.ParseImageLabels([]byte("invalid json")) | |
| 334 | 336 | assert.Error(t, err) | |
| 335 | 337 | } | |
| 336 | 338 | ||
| @@ -353,7 +355,7 @@ func TestMatchesDomainAllowlist(t *testing.T) { | |||
| 353 | 355 | ||
| 354 | 356 | for _, tt := range tests { | |
| 355 | 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 | 379 | for _, tt := range tests { | |
| 378 | 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 | 67 | CreateContainer(ctx context.Context, config *ContainerConfig) (*Container, error) | |
| 68 | 68 | StartContainer(ctx context.Context, containerID string) error | |
| 69 | 69 | StopContainer(ctx context.Context, containerID string) error | |
| 70 | + | WaitForContainer(ctx context.Context, containerID string) error | |
| 70 | 71 | RestartContainer(ctx context.Context, containerID string) error | |
| 71 | 72 | RemoveContainer(ctx context.Context, containerID string, force bool) error | |
| 72 | 73 | ||