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 | ### Authentication Failed | 182 | ### Authentication Failed | ||
| 183 | 183 | ||||
| 184 | - | ``` | 184 | + | ```text |
| 185 | Error: failed to verify token | 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 | - [Authentication](./config/auth.md) - Registry auth plus remote CLI login/token workflows | 50 | - [Authentication](./config/auth.md) - Registry auth plus remote CLI login/token workflows | ||
| 51 | - [Secrets](./config/secrets.md) - Secure credential storage | 51 | - [Secrets](./config/secrets.md) - Secure credential storage | ||
| 52 | - [Network Isolation](./config/network-isolation.md) - Per-app network isolation | 52 | - [Network Isolation](./config/network-isolation.md) - Per-app network isolation | ||
| 53 | + | - [Preview Environments](./config/preview.md) - Ephemeral per-branch deployments | |||
| 53 | - [Attachments](./config/attachments.md) - Service dependencies | 54 | - [Attachments](./config/attachments.md) - Service dependencies | ||
| 54 | - [Logging](./config/logging.md) - Log collection and rotation | 55 | - [Logging](./config/logging.md) - Log collection and rotation | ||
| 55 | - [Environment Variables](./config/env.md) - Per-route environment configuration | 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 | autorouteCmd.GroupID = groupManage | 145 | autorouteCmd.GroupID = groupManage | ||
| 146 | rootCmd.AddCommand(autorouteCmd) | 146 | rootCmd.AddCommand(autorouteCmd) | ||
| 147 | 147 | ||||
| 148 | + | previewCmd := newPreviewCmd() | |||
| 149 | + | previewCmd.GroupID = groupManage | |||
| 150 | + | rootCmd.AddCommand(previewCmd) | |||
| 151 | + | ||||
| 148 | // Client-only commands (no server needed) | 152 | // Client-only commands (no server needed) | ||
| 149 | remotesCmd := newRemotesCmd() | 153 | remotesCmd := newRemotesCmd() | ||
| 150 | remotesCmd.GroupID = groupClient | 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 | secretSvc := inmocks.NewMockSecretService(t) | 24 | secretSvc := inmocks.NewMockSecretService(t) | ||
| 25 | registrySvc := inmocks.NewMockRegistryService(t) | 25 | registrySvc := inmocks.NewMockRegistryService(t) | ||
| 26 | 26 | ||||
| 27 | - | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, registrySvc, nil, testLogger(), nil) | 27 | + | handler := newTestHandler(t, func(d *HandlerDeps) { |
| 28 | + | d.ConfigSvc = configSvc | |||
| 29 | + | d.AuthSvc = authSvc | |||
| 30 | + | d.ContainerSvc = containerSvc | |||
| 31 | + | d.SecretSvc = secretSvc | |||
| 32 | + | d.RegistrySvc = registrySvc | |||
| 33 | + | }) | |||
| 28 | 34 | ||||
| 29 | payload := dto.BootstrapRequest{ | 35 | payload := dto.BootstrapRequest{ | ||
| 30 | Domain: "app.example.com", | 36 | Domain: "app.example.com", | ||
| @@ -73,7 +79,12 @@ func TestBootstrap_Idempotent_Rerun(t *testing.T) { | |||||
| 73 | containerSvc := inmocks.NewMockContainerService(t) | 79 | containerSvc := inmocks.NewMockContainerService(t) | ||
| 74 | secretSvc := inmocks.NewMockSecretService(t) | 80 | secretSvc := inmocks.NewMockSecretService(t) | ||
| 75 | 81 | ||||
| 76 | - | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil) | 82 | + | handler := newTestHandler(t, func(d *HandlerDeps) { |
| 83 | + | d.ConfigSvc = configSvc | |||
| 84 | + | d.AuthSvc = authSvc | |||
| 85 | + | d.ContainerSvc = containerSvc | |||
| 86 | + | d.SecretSvc = secretSvc | |||
| 87 | + | }) | |||
| 77 | 88 | ||||
| 78 | payload := dto.BootstrapRequest{ | 89 | payload := dto.BootstrapRequest{ | ||
| 79 | Domain: "app.example.com", | 90 | Domain: "app.example.com", | ||
| @@ -137,7 +148,12 @@ func TestBootstrap_MissingDomain(t *testing.T) { | |||||
| 137 | containerSvc := inmocks.NewMockContainerService(t) | 148 | containerSvc := inmocks.NewMockContainerService(t) | ||
| 138 | secretSvc := inmocks.NewMockSecretService(t) | 149 | secretSvc := inmocks.NewMockSecretService(t) | ||
| 139 | 150 | ||||
| 140 | - | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil) | 151 | + | handler := newTestHandler(t, func(d *HandlerDeps) { |
| 152 | + | d.ConfigSvc = configSvc | |||
| 153 | + | d.AuthSvc = authSvc | |||
| 154 | + | d.ContainerSvc = containerSvc | |||
| 155 | + | d.SecretSvc = secretSvc | |||
| 156 | + | }) | |||
| 141 | 157 | ||||
| 142 | body, err := json.Marshal(dto.BootstrapRequest{Image: "myapp:latest"}) | 158 | body, err := json.Marshal(dto.BootstrapRequest{Image: "myapp:latest"}) | ||
| 143 | require.NoError(t, err) | 159 | require.NoError(t, err) | ||
| @@ -158,7 +174,12 @@ func TestBootstrap_MissingImage(t *testing.T) { | |||||
| 158 | containerSvc := inmocks.NewMockContainerService(t) | 174 | containerSvc := inmocks.NewMockContainerService(t) | ||
| 159 | secretSvc := inmocks.NewMockSecretService(t) | 175 | secretSvc := inmocks.NewMockSecretService(t) | ||
| 160 | 176 | ||||
| 161 | - | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil) | 177 | + | handler := newTestHandler(t, func(d *HandlerDeps) { |
| 178 | + | d.ConfigSvc = configSvc | |||
| 179 | + | d.AuthSvc = authSvc | |||
| 180 | + | d.ContainerSvc = containerSvc | |||
| 181 | + | d.SecretSvc = secretSvc | |||
| 182 | + | }) | |||
| 162 | 183 | ||||
| 163 | body, err := json.Marshal(dto.BootstrapRequest{Domain: "app.example.com"}) | 184 | body, err := json.Marshal(dto.BootstrapRequest{Domain: "app.example.com"}) | ||
| 164 | require.NoError(t, err) | 185 | require.NoError(t, err) | ||
| @@ -179,7 +200,12 @@ func TestBootstrap_NormalizesImageBeforeStoring(t *testing.T) { | |||||
| 179 | containerSvc := inmocks.NewMockContainerService(t) | 200 | containerSvc := inmocks.NewMockContainerService(t) | ||
| 180 | secretSvc := inmocks.NewMockSecretService(t) | 201 | secretSvc := inmocks.NewMockSecretService(t) | ||
| 181 | 202 | ||||
| 182 | - | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil) | 203 | + | handler := newTestHandler(t, func(d *HandlerDeps) { |
| 204 | + | d.ConfigSvc = configSvc | |||
| 205 | + | d.AuthSvc = authSvc | |||
| 206 | + | d.ContainerSvc = containerSvc | |||
| 207 | + | d.SecretSvc = secretSvc | |||
| 208 | + | }) | |||
| 183 | 209 | ||||
| 184 | payload := dto.BootstrapRequest{Domain: "app.example.com", Image: "pitlane:v1"} | 210 | payload := dto.BootstrapRequest{Domain: "app.example.com", Image: "pitlane:v1"} | ||
| 185 | configSvc.EXPECT().GetRegistryDomain().Return("reg.bnema.dev").Once() | 211 | configSvc.EXPECT().GetRegistryDomain().Return("reg.bnema.dev").Once() | ||
| @@ -225,7 +251,12 @@ func TestBootstrap_AddRouteValidationErrors(t *testing.T) { | |||||
| 225 | containerSvc := inmocks.NewMockContainerService(t) | 251 | containerSvc := inmocks.NewMockContainerService(t) | ||
| 226 | secretSvc := inmocks.NewMockSecretService(t) | 252 | secretSvc := inmocks.NewMockSecretService(t) | ||
| 227 | 253 | ||||
| 228 | - | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil) | 254 | + | handler := newTestHandler(t, func(d *HandlerDeps) { |
| 255 | + | d.ConfigSvc = configSvc | |||
| 256 | + | d.AuthSvc = authSvc | |||
| 257 | + | d.ContainerSvc = containerSvc | |||
| 258 | + | d.SecretSvc = secretSvc | |||
| 259 | + | }) | |||
| 229 | 260 | ||||
| 230 | payload := dto.BootstrapRequest{Domain: "app.example.com", Image: "myapp:latest"} | 261 | payload := dto.BootstrapRequest{Domain: "app.example.com", Image: "myapp:latest"} | ||
| 231 | configSvc.EXPECT().GetRegistryDomain().Return("reg.bnema.dev").Once() | 262 | configSvc.EXPECT().GetRegistryDomain().Return("reg.bnema.dev").Once() | ||
| @@ -252,7 +283,12 @@ func TestBootstrap_PartialFailure_AttachmentError(t *testing.T) { | |||||
| 252 | containerSvc := inmocks.NewMockContainerService(t) | 283 | containerSvc := inmocks.NewMockContainerService(t) | ||
| 253 | secretSvc := inmocks.NewMockSecretService(t) | 284 | secretSvc := inmocks.NewMockSecretService(t) | ||
| 254 | 285 | ||||
| 255 | - | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil) | 286 | + | handler := newTestHandler(t, func(d *HandlerDeps) { |
| 287 | + | d.ConfigSvc = configSvc | |||
| 288 | + | d.AuthSvc = authSvc | |||
| 289 | + | d.ContainerSvc = containerSvc | |||
| 290 | + | d.SecretSvc = secretSvc | |||
| 291 | + | }) | |||
| 256 | 292 | ||||
| 257 | payload := dto.BootstrapRequest{ | 293 | payload := dto.BootstrapRequest{ | ||
| 258 | Domain: "app.example.com", | 294 | Domain: "app.example.com", | ||
M internal/adapters/in/http/admin/handler.go +34 -35
| @@ -47,6 +47,7 @@ type Handler struct { | |||||
| 47 | logSvc in.LogService | 47 | logSvc in.LogService | ||
| 48 | volumeSvc in.VolumeService | 48 | volumeSvc in.VolumeService | ||
| 49 | registrySvc registryDeployService | 49 | registrySvc registryDeployService | ||
| 50 | + | previewSvc previewService | |||
| 50 | eventBus out.EventPublisher | 51 | eventBus out.EventPublisher | ||
| 51 | log zerowrap.Logger | 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 | + | // HandlerDeps contains all dependencies for the admin HTTP handler. |
| 134 | - | func NewHandler( | 135 | + | type HandlerDeps struct { |
| 135 | - | configSvc in.ConfigService, | 136 | + | ConfigSvc in.ConfigService |
| 136 | - | authSvc in.AuthService, | 137 | + | AuthSvc in.AuthService |
| 137 | - | containerSvc in.ContainerService, | 138 | + | ContainerSvc in.ContainerService |
| 138 | - | healthSvc in.HealthService, | 139 | + | HealthSvc in.HealthService |
| 139 | - | secretSvc in.SecretService, | 140 | + | SecretSvc in.SecretService |
| 140 | - | logSvc in.LogService, | 141 | + | LogSvc in.LogService |
| 141 | - | registrySvc registryDeployService, | 142 | + | RegistrySvc registryDeployService |
| 142 | - | eventBus out.EventPublisher, | 143 | + | EventBus out.EventPublisher |
| 143 | - | log zerowrap.Logger, | 144 | + | Log zerowrap.Logger |
| 144 | - | backupSvc in.BackupService, | 145 | + | BackupSvc in.BackupService |
| 145 | - | imageSvcs ...in.ImageService, | 146 | + | PreviewSvc previewService |
| 146 | - | ) *Handler { | 147 | + | ImageSvc in.ImageService |
| 147 | - | var imageSvc in.ImageService | 148 | + | VolumeSvc in.VolumeService |
| 148 | - | if len(imageSvcs) > 0 { | 149 | + | } |
| 149 | - | imageSvc = imageSvcs[0] | |||
| 150 | - | } | |||
| 151 | 150 | ||||
| 151 | + | // NewHandler creates a new admin HTTP handler. | |||
| 152 | + | func NewHandler(deps HandlerDeps) *Handler { | |||
| 152 | return &Handler{ | 153 | return &Handler{ | ||
| 153 | - | configSvc: configSvc, | 154 | + | configSvc: deps.ConfigSvc, |
| 154 | - | authSvc: authSvc, | 155 | + | authSvc: deps.AuthSvc, |
| 155 | - | containerSvc: containerSvc, | 156 | + | containerSvc: deps.ContainerSvc, |
| 156 | - | backupSvc: backupSvc, | 157 | + | backupSvc: deps.BackupSvc, |
| 157 | - | imageSvc: imageSvc, | 158 | + | imageSvc: deps.ImageSvc, |
| 158 | - | healthSvc: healthSvc, | 159 | + | healthSvc: deps.HealthSvc, |
| 159 | - | secretSvc: secretSvc, | 160 | + | secretSvc: deps.SecretSvc, |
| 160 | - | logSvc: logSvc, | 161 | + | logSvc: deps.LogSvc, |
| 161 | - | registrySvc: registrySvc, | 162 | + | volumeSvc: deps.VolumeSvc, |
| 162 | - | eventBus: eventBus, | 163 | + | registrySvc: deps.RegistrySvc, |
| 163 | - | log: log, | 164 | + | previewSvc: deps.PreviewSvc, |
| 165 | + | eventBus: deps.EventBus, | |||
| 166 | + | log: deps.Log, | |||
| 164 | } | 167 | } | ||
| 165 | } | 168 | } | ||
| 166 | 169 | ||||
| 167 | - | // WithVolumeService sets the volume service on the handler. | |||
| 168 | - | func (h *Handler) WithVolumeService(volumeSvc in.VolumeService) *Handler { | |||
| 169 | - | h.volumeSvc = volumeSvc | |||
| 170 | - | return h | |||
| 171 | - | } | |||
| 172 | - | ||||
| 173 | // RegisterRoutes registers the admin routes on the given mux. | 170 | // RegisterRoutes registers the admin routes on the given mux. | ||
| 174 | func (h *Handler) RegisterRoutes(mux *http.ServeMux) { | 171 | func (h *Handler) RegisterRoutes(mux *http.ServeMux) { | ||
| 175 | mux.HandleFunc("/admin/", h.handleAdminRoutes) | 172 | mux.HandleFunc("/admin/", h.handleAdminRoutes) | ||
| @@ -239,6 +236,8 @@ func (h *Handler) matchRoute(path string) (routeHandler, bool) { | |||||
| 239 | {"/images", h.handleImages}, | 236 | {"/images", h.handleImages}, | ||
| 240 | {"/logs", h.handleLogs}, | 237 | {"/logs", h.handleLogs}, | ||
| 241 | {"/autoroute/allowed-domains", h.handleAutoRouteAllowedDomains}, | 238 | {"/autoroute/allowed-domains", h.handleAutoRouteAllowedDomains}, | ||
| 239 | + | {"/previews", h.handlePreviewList}, | |||
| 240 | + | {"/preview", h.handlePreviewAction}, | |||
| 242 | } | 241 | } | ||
| 243 | for _, route := range prefixRoutes { | 242 | for _, route := range prefixRoutes { | ||
| 244 | if path == route.prefix || strings.HasPrefix(path, route.prefix+"/") { | 243 | if path == route.prefix || strings.HasPrefix(path, route.prefix+"/") { | ||
M internal/adapters/in/http/admin/handler_test.go +377 -59
| @@ -50,6 +50,22 @@ func ctxWithScopes(scopes ...string) context.Context { | |||||
| 50 | return context.WithValue(ctx, domain.ContextKeyScopes, scopes) | 50 | return context.WithValue(ctx, domain.ContextKeyScopes, scopes) | ||
| 51 | } | 51 | } | ||
| 52 | 52 | ||||
| 53 | + | func newTestHandler(t *testing.T, opts ...func(*HandlerDeps)) *Handler { | |||
| 54 | + | t.Helper() | |||
| 55 | + | deps := HandlerDeps{ | |||
| 56 | + | ConfigSvc: inmocks.NewMockConfigService(t), | |||
| 57 | + | AuthSvc: inmocks.NewMockAuthService(t), | |||
| 58 | + | ContainerSvc: inmocks.NewMockContainerService(t), | |||
| 59 | + | HealthSvc: inmocks.NewMockHealthService(t), | |||
| 60 | + | SecretSvc: inmocks.NewMockSecretService(t), | |||
| 61 | + | Log: testLogger(), | |||
| 62 | + | } | |||
| 63 | + | for _, opt := range opts { | |||
| 64 | + | opt(&deps) | |||
| 65 | + | } | |||
| 66 | + | return NewHandler(deps) | |||
| 67 | + | } | |||
| 68 | + | ||||
| 53 | // Routes endpoint tests | 69 | // Routes endpoint tests | ||
| 54 | 70 | ||||
| 55 | func TestHandler_RoutesGet_RequiresReadScope(t *testing.T) { | 71 | func TestHandler_RoutesGet_RequiresReadScope(t *testing.T) { | ||
| @@ -58,7 +74,13 @@ func TestHandler_RoutesGet_RequiresReadScope(t *testing.T) { | |||||
| 58 | containerSvc := inmocks.NewMockContainerService(t) | 74 | containerSvc := inmocks.NewMockContainerService(t) | ||
| 59 | secretSvc := inmocks.NewMockSecretService(t) | 75 | secretSvc := inmocks.NewMockSecretService(t) | ||
| 60 | 76 | ||||
| 61 | - | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil) | 77 | + | handler := newTestHandler(t, func(d *HandlerDeps) { |
| 78 | + | d.ConfigSvc = configSvc | |||
| 79 | + | d.AuthSvc = authSvc | |||
| 80 | + | d.ContainerSvc = containerSvc | |||
| 81 | + | d.SecretSvc = secretSvc | |||
| 82 | + | d.RegistrySvc = inmocks.NewMockRegistryService(t) | |||
| 83 | + | }) | |||
| 62 | 84 | ||||
| 63 | tests := []struct { | 85 | tests := []struct { | ||
| 64 | name string | 86 | name string | ||
| @@ -119,9 +141,13 @@ func TestHandler_RoutesPost_RequiresWriteScope(t *testing.T) { | |||||
| 119 | authSvc := inmocks.NewMockAuthService(t) | 141 | authSvc := inmocks.NewMockAuthService(t) | ||
| 120 | containerSvc := inmocks.NewMockContainerService(t) | 142 | containerSvc := inmocks.NewMockContainerService(t) | ||
| 121 | secretSvc := inmocks.NewMockSecretService(t) | 143 | secretSvc := inmocks.NewMockSecretService(t) | ||
| 122 | - | registrySvc := inmocks.NewMockRegistryService(t) | |||
| 123 | 144 | ||||
| 124 | - | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, registrySvc, nil, testLogger(), nil) | 145 | + | handler := newTestHandler(t, func(d *HandlerDeps) { |
| 146 | + | d.ConfigSvc = configSvc | |||
| 147 | + | d.AuthSvc = authSvc | |||
| 148 | + | d.ContainerSvc = containerSvc | |||
| 149 | + | d.SecretSvc = secretSvc | |||
| 150 | + | }) | |||
| 125 | 151 | ||||
| 126 | routeJSON := `{"domain": "app.example.com", "image": "myapp:latest"}` | 152 | routeJSON := `{"domain": "app.example.com", "image": "myapp:latest"}` | ||
| 127 | 153 | ||||
| @@ -179,9 +205,13 @@ func TestHandler_RoutesPut_RequiresWriteScope(t *testing.T) { | |||||
| 179 | authSvc := inmocks.NewMockAuthService(t) | 205 | authSvc := inmocks.NewMockAuthService(t) | ||
| 180 | containerSvc := inmocks.NewMockContainerService(t) | 206 | containerSvc := inmocks.NewMockContainerService(t) | ||
| 181 | secretSvc := inmocks.NewMockSecretService(t) | 207 | secretSvc := inmocks.NewMockSecretService(t) | ||
| 182 | - | registrySvc := inmocks.NewMockRegistryService(t) | |||
| 183 | 208 | ||||
| 184 | - | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, registrySvc, nil, testLogger(), nil) | 209 | + | handler := newTestHandler(t, func(d *HandlerDeps) { |
| 210 | + | d.ConfigSvc = configSvc | |||
| 211 | + | d.AuthSvc = authSvc | |||
| 212 | + | d.ContainerSvc = containerSvc | |||
| 213 | + | d.SecretSvc = secretSvc | |||
| 214 | + | }) | |||
| 185 | 215 | ||||
| 186 | routeJSON := `{"image": "myapp:v2"}` | 216 | routeJSON := `{"image": "myapp:v2"}` | ||
| 187 | 217 | ||||
| @@ -227,7 +257,13 @@ func TestHandler_RoutesPost_AllowsRouteCreationWithoutRegistryManifest(t *testin | |||||
| 227 | registrySvc := inmocks.NewMockRegistryService(t) | 257 | registrySvc := inmocks.NewMockRegistryService(t) | ||
| 228 | route := domain.Route{Domain: "app.example.com", Image: "myapp:latest"} | 258 | route := domain.Route{Domain: "app.example.com", Image: "myapp:latest"} | ||
| 229 | 259 | ||||
| 230 | - | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, registrySvc, nil, testLogger(), nil) | 260 | + | handler := newTestHandler(t, func(d *HandlerDeps) { |
| 261 | + | d.ConfigSvc = configSvc | |||
| 262 | + | d.AuthSvc = authSvc | |||
| 263 | + | d.ContainerSvc = containerSvc | |||
| 264 | + | d.SecretSvc = secretSvc | |||
| 265 | + | d.RegistrySvc = registrySvc | |||
| 266 | + | }) | |||
| 231 | 267 | ||||
| 232 | configSvc.EXPECT().AddRoute(mock.Anything, route).Return(nil).Once() | 268 | configSvc.EXPECT().AddRoute(mock.Anything, route).Return(nil).Once() | ||
| 233 | 269 | ||||
| @@ -250,7 +286,13 @@ func TestHandler_RoutesPut_AllowsRouteUpdateWithoutRegistryManifest(t *testing.T | |||||
| 250 | registrySvc := inmocks.NewMockRegistryService(t) | 286 | registrySvc := inmocks.NewMockRegistryService(t) | ||
| 251 | route := domain.Route{Domain: "app.example.com", Image: "myapp:v2"} | 287 | route := domain.Route{Domain: "app.example.com", Image: "myapp:v2"} | ||
| 252 | 288 | ||||
| 253 | - | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, registrySvc, nil, testLogger(), nil) | 289 | + | handler := newTestHandler(t, func(d *HandlerDeps) { |
| 290 | + | d.ConfigSvc = configSvc | |||
| 291 | + | d.AuthSvc = authSvc | |||
| 292 | + | d.ContainerSvc = containerSvc | |||
| 293 | + | d.SecretSvc = secretSvc | |||
| 294 | + | d.RegistrySvc = registrySvc | |||
| 295 | + | }) | |||
| 254 | 296 | ||||
| 255 | configSvc.EXPECT().UpdateRoute(mock.Anything, route).Return(nil).Once() | 297 | configSvc.EXPECT().UpdateRoute(mock.Anything, route).Return(nil).Once() | ||
| 256 | 298 | ||||
| @@ -271,7 +313,12 @@ func TestHandler_AttachmentsByImageGet_OneTarget(t *testing.T) { | |||||
| 271 | containerSvc := inmocks.NewMockContainerService(t) | 313 | containerSvc := inmocks.NewMockContainerService(t) | ||
| 272 | secretSvc := inmocks.NewMockSecretService(t) | 314 | secretSvc := inmocks.NewMockSecretService(t) | ||
| 273 | 315 | ||||
| 274 | - | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil) | 316 | + | handler := newTestHandler(t, func(d *HandlerDeps) { |
| 317 | + | d.ConfigSvc = configSvc | |||
| 318 | + | d.AuthSvc = authSvc | |||
| 319 | + | d.ContainerSvc = containerSvc | |||
| 320 | + | d.SecretSvc = secretSvc | |||
| 321 | + | }) | |||
| 275 | 322 | ||||
| 276 | configSvc.EXPECT().FindAttachmentTargetsByImage(mock.Anything, "postgres:16").Return([]string{"app.example.com"}).Once() | 323 | configSvc.EXPECT().FindAttachmentTargetsByImage(mock.Anything, "postgres:16").Return([]string{"app.example.com"}).Once() | ||
| 277 | 324 | ||||
| @@ -291,7 +338,12 @@ func TestHandler_AttachmentsByImageGet_MultipleTargets(t *testing.T) { | |||||
| 291 | containerSvc := inmocks.NewMockContainerService(t) | 338 | containerSvc := inmocks.NewMockContainerService(t) | ||
| 292 | secretSvc := inmocks.NewMockSecretService(t) | 339 | secretSvc := inmocks.NewMockSecretService(t) | ||
| 293 | 340 | ||||
| 294 | - | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil) | 341 | + | handler := newTestHandler(t, func(d *HandlerDeps) { |
| 342 | + | d.ConfigSvc = configSvc | |||
| 343 | + | d.AuthSvc = authSvc | |||
| 344 | + | d.ContainerSvc = containerSvc | |||
| 345 | + | d.SecretSvc = secretSvc | |||
| 346 | + | }) | |||
| 295 | 347 | ||||
| 296 | configSvc.EXPECT().FindAttachmentTargetsByImage(mock.Anything, "postgres").Return([]string{"app.example.com", "workers"}).Once() | 348 | configSvc.EXPECT().FindAttachmentTargetsByImage(mock.Anything, "postgres").Return([]string{"app.example.com", "workers"}).Once() | ||
| 297 | 349 | ||||
| @@ -311,7 +363,12 @@ func TestHandler_AttachmentsByImageGet_URLDecodedImage(t *testing.T) { | |||||
| 311 | containerSvc := inmocks.NewMockContainerService(t) | 363 | containerSvc := inmocks.NewMockContainerService(t) | ||
| 312 | secretSvc := inmocks.NewMockSecretService(t) | 364 | secretSvc := inmocks.NewMockSecretService(t) | ||
| 313 | 365 | ||||
| 314 | - | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil) | 366 | + | handler := newTestHandler(t, func(d *HandlerDeps) { |
| 367 | + | d.ConfigSvc = configSvc | |||
| 368 | + | d.AuthSvc = authSvc | |||
| 369 | + | d.ContainerSvc = containerSvc | |||
| 370 | + | d.SecretSvc = secretSvc | |||
| 371 | + | }) | |||
| 315 | 372 | ||||
| 316 | configSvc.EXPECT().FindAttachmentTargetsByImage(mock.Anything, "registry/org/image:tag").Return([]string{"app.example.com"}).Once() | 373 | configSvc.EXPECT().FindAttachmentTargetsByImage(mock.Anything, "registry/org/image:tag").Return([]string{"app.example.com"}).Once() | ||
| 317 | 374 | ||||
| @@ -331,7 +388,12 @@ func TestHandler_AttachmentsByImageGet_EmptyResultSet(t *testing.T) { | |||||
| 331 | containerSvc := inmocks.NewMockContainerService(t) | 388 | containerSvc := inmocks.NewMockContainerService(t) | ||
| 332 | secretSvc := inmocks.NewMockSecretService(t) | 389 | secretSvc := inmocks.NewMockSecretService(t) | ||
| 333 | 390 | ||||
| 334 | - | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil) | 391 | + | handler := newTestHandler(t, func(d *HandlerDeps) { |
| 392 | + | d.ConfigSvc = configSvc | |||
| 393 | + | d.AuthSvc = authSvc | |||
| 394 | + | d.ContainerSvc = containerSvc | |||
| 395 | + | d.SecretSvc = secretSvc | |||
| 396 | + | }) | |||
| 335 | 397 | ||||
| 336 | configSvc.EXPECT().FindAttachmentTargetsByImage(mock.Anything, "missing:latest").Return([]string{}).Once() | 398 | configSvc.EXPECT().FindAttachmentTargetsByImage(mock.Anything, "missing:latest").Return([]string{}).Once() | ||
| 337 | 399 | ||||
| @@ -351,7 +413,12 @@ func TestHandler_AttachmentsByImageGet_MissingImagePath(t *testing.T) { | |||||
| 351 | containerSvc := inmocks.NewMockContainerService(t) | 413 | containerSvc := inmocks.NewMockContainerService(t) | ||
| 352 | secretSvc := inmocks.NewMockSecretService(t) | 414 | secretSvc := inmocks.NewMockSecretService(t) | ||
| 353 | 415 | ||||
| 354 | - | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil) | 416 | + | handler := newTestHandler(t, func(d *HandlerDeps) { |
| 417 | + | d.ConfigSvc = configSvc | |||
| 418 | + | d.AuthSvc = authSvc | |||
| 419 | + | d.ContainerSvc = containerSvc | |||
| 420 | + | d.SecretSvc = secretSvc | |||
| 421 | + | }) | |||
| 355 | 422 | ||||
| 356 | req := httptest.NewRequest("GET", "/admin/attachments/by-image", nil) | 423 | req := httptest.NewRequest("GET", "/admin/attachments/by-image", nil) | ||
| 357 | req = req.WithContext(ctxWithScopes("admin:config:read")) | 424 | req = req.WithContext(ctxWithScopes("admin:config:read")) | ||
| @@ -369,7 +436,12 @@ func TestHandler_AttachmentsByImageGet_InvalidEncoding(t *testing.T) { | |||||
| 369 | containerSvc := inmocks.NewMockContainerService(t) | 436 | containerSvc := inmocks.NewMockContainerService(t) | ||
| 370 | secretSvc := inmocks.NewMockSecretService(t) | 437 | secretSvc := inmocks.NewMockSecretService(t) | ||
| 371 | 438 | ||||
| 372 | - | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil) | 439 | + | handler := newTestHandler(t, func(d *HandlerDeps) { |
| 440 | + | d.ConfigSvc = configSvc | |||
| 441 | + | d.AuthSvc = authSvc | |||
| 442 | + | d.ContainerSvc = containerSvc | |||
| 443 | + | d.SecretSvc = secretSvc | |||
| 444 | + | }) | |||
| 373 | 445 | ||||
| 374 | req := httptest.NewRequest("GET", "/admin/attachments/by-image/placeholder", nil) | 446 | req := httptest.NewRequest("GET", "/admin/attachments/by-image/placeholder", nil) | ||
| 375 | req.URL = &url.URL{Path: "/admin/attachments/by-image/%zz"} | 447 | req.URL = &url.URL{Path: "/admin/attachments/by-image/%zz"} | ||
| @@ -388,7 +460,12 @@ func TestHandler_AttachmentsByImageGet_RequiresReadScope(t *testing.T) { | |||||
| 388 | containerSvc := inmocks.NewMockContainerService(t) | 460 | containerSvc := inmocks.NewMockContainerService(t) | ||
| 389 | secretSvc := inmocks.NewMockSecretService(t) | 461 | secretSvc := inmocks.NewMockSecretService(t) | ||
| 390 | 462 | ||||
| 391 | - | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil) | 463 | + | handler := newTestHandler(t, func(d *HandlerDeps) { |
| 464 | + | d.ConfigSvc = configSvc | |||
| 465 | + | d.AuthSvc = authSvc | |||
| 466 | + | d.ContainerSvc = containerSvc | |||
| 467 | + | d.SecretSvc = secretSvc | |||
| 468 | + | }) | |||
| 392 | 469 | ||||
| 393 | req := httptest.NewRequest("GET", "/admin/attachments/by-image/postgres:16", nil) | 470 | req := httptest.NewRequest("GET", "/admin/attachments/by-image/postgres:16", nil) | ||
| 394 | req = req.WithContext(ctxWithScopes("admin:routes:read")) | 471 | req = req.WithContext(ctxWithScopes("admin:routes:read")) | ||
| @@ -406,7 +483,12 @@ func TestHandler_RoutesDelete_RequiresWriteScope(t *testing.T) { | |||||
| 406 | containerSvc := inmocks.NewMockContainerService(t) | 483 | containerSvc := inmocks.NewMockContainerService(t) | ||
| 407 | secretSvc := inmocks.NewMockSecretService(t) | 484 | secretSvc := inmocks.NewMockSecretService(t) | ||
| 408 | 485 | ||||
| 409 | - | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil) | 486 | + | handler := newTestHandler(t, func(d *HandlerDeps) { |
| 487 | + | d.ConfigSvc = configSvc | |||
| 488 | + | d.AuthSvc = authSvc | |||
| 489 | + | d.ContainerSvc = containerSvc | |||
| 490 | + | d.SecretSvc = secretSvc | |||
| 491 | + | }) | |||
| 410 | 492 | ||||
| 411 | tests := []struct { | 493 | tests := []struct { | ||
| 412 | name string | 494 | name string | ||
| @@ -450,7 +532,12 @@ func TestHandler_SecretsGet_RequiresReadScope(t *testing.T) { | |||||
| 450 | containerSvc := inmocks.NewMockContainerService(t) | 532 | containerSvc := inmocks.NewMockContainerService(t) | ||
| 451 | secretSvc := inmocks.NewMockSecretService(t) | 533 | secretSvc := inmocks.NewMockSecretService(t) | ||
| 452 | 534 | ||||
| 453 | - | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil) | 535 | + | handler := newTestHandler(t, func(d *HandlerDeps) { |
| 536 | + | d.ConfigSvc = configSvc | |||
| 537 | + | d.AuthSvc = authSvc | |||
| 538 | + | d.ContainerSvc = containerSvc | |||
| 539 | + | d.SecretSvc = secretSvc | |||
| 540 | + | }) | |||
| 454 | 541 | ||||
| 455 | tests := []struct { | 542 | tests := []struct { | ||
| 456 | name string | 543 | name string | ||
| @@ -507,7 +594,12 @@ func TestHandler_SecretsPost_RequiresWriteScope(t *testing.T) { | |||||
| 507 | containerSvc := inmocks.NewMockContainerService(t) | 594 | containerSvc := inmocks.NewMockContainerService(t) | ||
| 508 | secretSvc := inmocks.NewMockSecretService(t) | 595 | secretSvc := inmocks.NewMockSecretService(t) | ||
| 509 | 596 | ||||
| 510 | - | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil) | 597 | + | handler := newTestHandler(t, func(d *HandlerDeps) { |
| 598 | + | d.ConfigSvc = configSvc | |||
| 599 | + | d.AuthSvc = authSvc | |||
| 600 | + | d.ContainerSvc = containerSvc | |||
| 601 | + | d.SecretSvc = secretSvc | |||
| 602 | + | }) | |||
| 511 | 603 | ||||
| 512 | secretsJSON := `{"API_KEY": "secret123"}` | 604 | secretsJSON := `{"API_KEY": "secret123"}` | ||
| 513 | 605 | ||||
| @@ -556,7 +648,12 @@ func TestHandler_SecretsDelete_RequiresWriteScope(t *testing.T) { | |||||
| 556 | containerSvc := inmocks.NewMockContainerService(t) | 648 | containerSvc := inmocks.NewMockContainerService(t) | ||
| 557 | secretSvc := inmocks.NewMockSecretService(t) | 649 | secretSvc := inmocks.NewMockSecretService(t) | ||
| 558 | 650 | ||||
| 559 | - | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil) | 651 | + | handler := newTestHandler(t, func(d *HandlerDeps) { |
| 652 | + | d.ConfigSvc = configSvc | |||
| 653 | + | d.AuthSvc = authSvc | |||
| 654 | + | d.ContainerSvc = containerSvc | |||
| 655 | + | d.SecretSvc = secretSvc | |||
| 656 | + | }) | |||
| 560 | 657 | ||||
| 561 | tests := []struct { | 658 | tests := []struct { | ||
| 562 | name string | 659 | name string | ||
| @@ -600,7 +697,12 @@ func TestHandler_AttachmentSecretsPost(t *testing.T) { | |||||
| 600 | containerSvc := inmocks.NewMockContainerService(t) | 697 | containerSvc := inmocks.NewMockContainerService(t) | ||
| 601 | secretSvc := inmocks.NewMockSecretService(t) | 698 | secretSvc := inmocks.NewMockSecretService(t) | ||
| 602 | 699 | ||||
| 603 | - | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil) | 700 | + | handler := newTestHandler(t, func(d *HandlerDeps) { |
| 701 | + | d.ConfigSvc = configSvc | |||
| 702 | + | d.AuthSvc = authSvc | |||
| 703 | + | d.ContainerSvc = containerSvc | |||
| 704 | + | d.SecretSvc = secretSvc | |||
| 705 | + | }) | |||
| 604 | 706 | ||||
| 605 | secretSvc.EXPECT().SetAttachment(mock.Anything, "app.example.com", "redis", mock.AnythingOfType("map[string]string")).Return(nil) | 707 | secretSvc.EXPECT().SetAttachment(mock.Anything, "app.example.com", "redis", mock.AnythingOfType("map[string]string")).Return(nil) | ||
| 606 | 708 | ||||
| @@ -626,7 +728,12 @@ func TestHandler_AttachmentSecretsDelete(t *testing.T) { | |||||
| 626 | containerSvc := inmocks.NewMockContainerService(t) | 728 | containerSvc := inmocks.NewMockContainerService(t) | ||
| 627 | secretSvc := inmocks.NewMockSecretService(t) | 729 | secretSvc := inmocks.NewMockSecretService(t) | ||
| 628 | 730 | ||||
| 629 | - | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil) | 731 | + | handler := newTestHandler(t, func(d *HandlerDeps) { |
| 732 | + | d.ConfigSvc = configSvc | |||
| 733 | + | d.AuthSvc = authSvc | |||
| 734 | + | d.ContainerSvc = containerSvc | |||
| 735 | + | d.SecretSvc = secretSvc | |||
| 736 | + | }) | |||
| 630 | 737 | ||||
| 631 | secretSvc.EXPECT().DeleteAttachment(mock.Anything, "app.example.com", "redis", "REDIS_URL").Return(nil) | 738 | secretSvc.EXPECT().DeleteAttachment(mock.Anything, "app.example.com", "redis", "REDIS_URL").Return(nil) | ||
| 632 | 739 | ||||
| @@ -652,7 +759,13 @@ func TestHandler_Bootstrap_HappyPath(t *testing.T) { | |||||
| 652 | secretSvc := inmocks.NewMockSecretService(t) | 759 | secretSvc := inmocks.NewMockSecretService(t) | ||
| 653 | registrySvc := inmocks.NewMockRegistryService(t) | 760 | registrySvc := inmocks.NewMockRegistryService(t) | ||
| 654 | 761 | ||||
| 655 | - | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, registrySvc, nil, testLogger(), nil) | 762 | + | handler := newTestHandler(t, func(d *HandlerDeps) { |
| 763 | + | d.ConfigSvc = configSvc | |||
| 764 | + | d.AuthSvc = authSvc | |||
| 765 | + | d.ContainerSvc = containerSvc | |||
| 766 | + | d.SecretSvc = secretSvc | |||
| 767 | + | d.RegistrySvc = registrySvc | |||
| 768 | + | }) | |||
| 656 | 769 | ||||
| 657 | configSvc.EXPECT().GetRegistryDomain().Return("reg.bnema.dev").Once() | 770 | configSvc.EXPECT().GetRegistryDomain().Return("reg.bnema.dev").Once() | ||
| 658 | configSvc.EXPECT().AddRoute(mock.Anything, domain.Route{Domain: "app.example.com", Image: "reg.bnema.dev/myapp"}).Return(nil).Once() | 771 | configSvc.EXPECT().AddRoute(mock.Anything, domain.Route{Domain: "app.example.com", Image: "reg.bnema.dev/myapp"}).Return(nil).Once() | ||
| @@ -699,7 +812,12 @@ func TestHandler_Bootstrap_RequiresPermissions(t *testing.T) { | |||||
| 699 | containerSvc := inmocks.NewMockContainerService(t) | 812 | containerSvc := inmocks.NewMockContainerService(t) | ||
| 700 | secretSvc := inmocks.NewMockSecretService(t) | 813 | secretSvc := inmocks.NewMockSecretService(t) | ||
| 701 | 814 | ||||
| 702 | - | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil) | 815 | + | handler := newTestHandler(t, func(d *HandlerDeps) { |
| 816 | + | d.ConfigSvc = configSvc | |||
| 817 | + | d.AuthSvc = authSvc | |||
| 818 | + | d.ContainerSvc = containerSvc | |||
| 819 | + | d.SecretSvc = secretSvc | |||
| 820 | + | }) | |||
| 703 | 821 | ||||
| 704 | tests := []struct { | 822 | tests := []struct { | ||
| 705 | name string | 823 | name string | ||
| @@ -741,7 +859,12 @@ func TestHandler_Status_RequiresReadScope(t *testing.T) { | |||||
| 741 | containerSvc := inmocks.NewMockContainerService(t) | 859 | containerSvc := inmocks.NewMockContainerService(t) | ||
| 742 | secretSvc := inmocks.NewMockSecretService(t) | 860 | secretSvc := inmocks.NewMockSecretService(t) | ||
| 743 | 861 | ||||
| 744 | - | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil) | 862 | + | handler := newTestHandler(t, func(d *HandlerDeps) { |
| 863 | + | d.ConfigSvc = configSvc | |||
| 864 | + | d.AuthSvc = authSvc | |||
| 865 | + | d.ContainerSvc = containerSvc | |||
| 866 | + | d.SecretSvc = secretSvc | |||
| 867 | + | }) | |||
| 745 | 868 | ||||
| 746 | tests := []struct { | 869 | tests := []struct { | ||
| 747 | name string | 870 | name string | ||
| @@ -795,7 +918,12 @@ func TestHandler_Config_RequiresReadScope(t *testing.T) { | |||||
| 795 | containerSvc := inmocks.NewMockContainerService(t) | 918 | containerSvc := inmocks.NewMockContainerService(t) | ||
| 796 | secretSvc := inmocks.NewMockSecretService(t) | 919 | secretSvc := inmocks.NewMockSecretService(t) | ||
| 797 | 920 | ||||
| 798 | - | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil) | 921 | + | handler := newTestHandler(t, func(d *HandlerDeps) { |
| 922 | + | d.ConfigSvc = configSvc | |||
| 923 | + | d.AuthSvc = authSvc | |||
| 924 | + | d.ContainerSvc = containerSvc | |||
| 925 | + | d.SecretSvc = secretSvc | |||
| 926 | + | }) | |||
| 799 | 927 | ||||
| 800 | tests := []struct { | 928 | tests := []struct { | ||
| 801 | name string | 929 | name string | ||
| @@ -852,7 +980,12 @@ func TestHandler_Reload_RequiresWriteScope(t *testing.T) { | |||||
| 852 | containerSvc := inmocks.NewMockContainerService(t) | 980 | containerSvc := inmocks.NewMockContainerService(t) | ||
| 853 | secretSvc := inmocks.NewMockSecretService(t) | 981 | secretSvc := inmocks.NewMockSecretService(t) | ||
| 854 | 982 | ||||
| 855 | - | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil) | 983 | + | handler := newTestHandler(t, func(d *HandlerDeps) { |
| 984 | + | d.ConfigSvc = configSvc | |||
| 985 | + | d.AuthSvc = authSvc | |||
| 986 | + | d.ContainerSvc = containerSvc | |||
| 987 | + | d.SecretSvc = secretSvc | |||
| 988 | + | }) | |||
| 856 | 989 | ||||
| 857 | tests := []struct { | 990 | tests := []struct { | ||
| 858 | name string | 991 | name string | ||
| @@ -906,7 +1039,12 @@ func TestHandler_RoutesGet_ReturnsRoutes(t *testing.T) { | |||||
| 906 | containerSvc := inmocks.NewMockContainerService(t) | 1039 | containerSvc := inmocks.NewMockContainerService(t) | ||
| 907 | secretSvc := inmocks.NewMockSecretService(t) | 1040 | secretSvc := inmocks.NewMockSecretService(t) | ||
| 908 | 1041 | ||||
| 909 | - | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil) | 1042 | + | handler := newTestHandler(t, func(d *HandlerDeps) { |
| 1043 | + | d.ConfigSvc = configSvc | |||
| 1044 | + | d.AuthSvc = authSvc | |||
| 1045 | + | d.ContainerSvc = containerSvc | |||
| 1046 | + | d.SecretSvc = secretSvc | |||
| 1047 | + | }) | |||
| 910 | 1048 | ||||
| 911 | expectedRoutes := []domain.Route{ | 1049 | expectedRoutes := []domain.Route{ | ||
| 912 | {Domain: "app1.example.com", Image: "app1:latest"}, | 1050 | {Domain: "app1.example.com", Image: "app1:latest"}, | ||
| @@ -941,7 +1079,12 @@ func TestHandler_RoutesGet_SingleRoute(t *testing.T) { | |||||
| 941 | containerSvc := inmocks.NewMockContainerService(t) | 1079 | containerSvc := inmocks.NewMockContainerService(t) | ||
| 942 | secretSvc := inmocks.NewMockSecretService(t) | 1080 | secretSvc := inmocks.NewMockSecretService(t) | ||
| 943 | 1081 | ||||
| 944 | - | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil) | 1082 | + | handler := newTestHandler(t, func(d *HandlerDeps) { |
| 1083 | + | d.ConfigSvc = configSvc | |||
| 1084 | + | d.AuthSvc = authSvc | |||
| 1085 | + | d.ContainerSvc = containerSvc | |||
| 1086 | + | d.SecretSvc = secretSvc | |||
| 1087 | + | }) | |||
| 945 | 1088 | ||||
| 946 | configSvc.EXPECT().GetRoute(mock.Anything, "app.example.com").Return(&domain.Route{ | 1089 | configSvc.EXPECT().GetRoute(mock.Anything, "app.example.com").Return(&domain.Route{ | ||
| 947 | Domain: "app.example.com", Image: "app:latest", | 1090 | Domain: "app.example.com", Image: "app:latest", | ||
| @@ -972,7 +1115,12 @@ func TestHandler_RoutesGet_NotFound(t *testing.T) { | |||||
| 972 | containerSvc := inmocks.NewMockContainerService(t) | 1115 | containerSvc := inmocks.NewMockContainerService(t) | ||
| 973 | secretSvc := inmocks.NewMockSecretService(t) | 1116 | secretSvc := inmocks.NewMockSecretService(t) | ||
| 974 | 1117 | ||||
| 975 | - | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil) | 1118 | + | handler := newTestHandler(t, func(d *HandlerDeps) { |
| 1119 | + | d.ConfigSvc = configSvc | |||
| 1120 | + | d.AuthSvc = authSvc | |||
| 1121 | + | d.ContainerSvc = containerSvc | |||
| 1122 | + | d.SecretSvc = secretSvc | |||
| 1123 | + | }) | |||
| 976 | 1124 | ||||
| 977 | configSvc.EXPECT().GetRoute(mock.Anything, "nonexistent.example.com").Return(nil, domain.ErrRouteNotFound) | 1125 | configSvc.EXPECT().GetRoute(mock.Anything, "nonexistent.example.com").Return(nil, domain.ErrRouteNotFound) | ||
| 978 | 1126 | ||||
| @@ -1016,7 +1164,13 @@ func TestHandler_RoutesPost_MissingFields(t *testing.T) { | |||||
| 1016 | secretSvc := inmocks.NewMockSecretService(t) | 1164 | secretSvc := inmocks.NewMockSecretService(t) | ||
| 1017 | registrySvc := inmocks.NewMockRegistryService(t) | 1165 | registrySvc := inmocks.NewMockRegistryService(t) | ||
| 1018 | 1166 | ||||
| 1019 | - | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, registrySvc, nil, testLogger(), nil) | 1167 | + | handler := newTestHandler(t, func(d *HandlerDeps) { |
| 1168 | + | d.ConfigSvc = configSvc | |||
| 1169 | + | d.AuthSvc = authSvc | |||
| 1170 | + | d.ContainerSvc = containerSvc | |||
| 1171 | + | d.SecretSvc = secretSvc | |||
| 1172 | + | d.RegistrySvc = registrySvc | |||
| 1173 | + | }) | |||
| 1020 | 1174 | ||||
| 1021 | configSvc.EXPECT().AddRoute(mock.Anything, mock.Anything).Return(tt.mockErr) | 1175 | configSvc.EXPECT().AddRoute(mock.Anything, mock.Anything).Return(tt.mockErr) | ||
| 1022 | 1176 | ||||
| @@ -1037,7 +1191,12 @@ func TestHandler_RoutesPut_MissingDomain(t *testing.T) { | |||||
| 1037 | containerSvc := inmocks.NewMockContainerService(t) | 1191 | containerSvc := inmocks.NewMockContainerService(t) | ||
| 1038 | secretSvc := inmocks.NewMockSecretService(t) | 1192 | secretSvc := inmocks.NewMockSecretService(t) | ||
| 1039 | 1193 | ||||
| 1040 | - | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil) | 1194 | + | handler := newTestHandler(t, func(d *HandlerDeps) { |
| 1195 | + | d.ConfigSvc = configSvc | |||
| 1196 | + | d.AuthSvc = authSvc | |||
| 1197 | + | d.ContainerSvc = containerSvc | |||
| 1198 | + | d.SecretSvc = secretSvc | |||
| 1199 | + | }) | |||
| 1041 | 1200 | ||||
| 1042 | req := httptest.NewRequest("PUT", "/admin/routes/", bytes.NewBufferString(`{"image": "app:latest"}`)) | 1201 | req := httptest.NewRequest("PUT", "/admin/routes/", bytes.NewBufferString(`{"image": "app:latest"}`)) | ||
| 1043 | req = req.WithContext(ctxWithScopes("admin:routes:write")) | 1202 | req = req.WithContext(ctxWithScopes("admin:routes:write")) | ||
| @@ -1054,7 +1213,12 @@ func TestHandler_RoutesDelete_MissingDomain(t *testing.T) { | |||||
| 1054 | containerSvc := inmocks.NewMockContainerService(t) | 1213 | containerSvc := inmocks.NewMockContainerService(t) | ||
| 1055 | secretSvc := inmocks.NewMockSecretService(t) | 1214 | secretSvc := inmocks.NewMockSecretService(t) | ||
| 1056 | 1215 | ||||
| 1057 | - | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil) | 1216 | + | handler := newTestHandler(t, func(d *HandlerDeps) { |
| 1217 | + | d.ConfigSvc = configSvc | |||
| 1218 | + | d.AuthSvc = authSvc | |||
| 1219 | + | d.ContainerSvc = containerSvc | |||
| 1220 | + | d.SecretSvc = secretSvc | |||
| 1221 | + | }) | |||
| 1058 | 1222 | ||||
| 1059 | req := httptest.NewRequest("DELETE", "/admin/routes/", nil) | 1223 | req := httptest.NewRequest("DELETE", "/admin/routes/", nil) | ||
| 1060 | req = req.WithContext(ctxWithScopes("admin:routes:write")) | 1224 | req = req.WithContext(ctxWithScopes("admin:routes:write")) | ||
| @@ -1071,7 +1235,12 @@ func TestHandler_Secrets_MissingDomain(t *testing.T) { | |||||
| 1071 | containerSvc := inmocks.NewMockContainerService(t) | 1235 | containerSvc := inmocks.NewMockContainerService(t) | ||
| 1072 | secretSvc := inmocks.NewMockSecretService(t) | 1236 | secretSvc := inmocks.NewMockSecretService(t) | ||
| 1073 | 1237 | ||||
| 1074 | - | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil) | 1238 | + | handler := newTestHandler(t, func(d *HandlerDeps) { |
| 1239 | + | d.ConfigSvc = configSvc | |||
| 1240 | + | d.AuthSvc = authSvc | |||
| 1241 | + | d.ContainerSvc = containerSvc | |||
| 1242 | + | d.SecretSvc = secretSvc | |||
| 1243 | + | }) | |||
| 1075 | 1244 | ||||
| 1076 | req := httptest.NewRequest("GET", "/admin/secrets/", nil) | 1245 | req := httptest.NewRequest("GET", "/admin/secrets/", nil) | ||
| 1077 | req = req.WithContext(ctxWithScopes("admin:secrets:read")) | 1246 | req = req.WithContext(ctxWithScopes("admin:secrets:read")) | ||
| @@ -1088,7 +1257,12 @@ func TestHandler_SecretsDelete_MissingKey(t *testing.T) { | |||||
| 1088 | containerSvc := inmocks.NewMockContainerService(t) | 1257 | containerSvc := inmocks.NewMockContainerService(t) | ||
| 1089 | secretSvc := inmocks.NewMockSecretService(t) | 1258 | secretSvc := inmocks.NewMockSecretService(t) | ||
| 1090 | 1259 | ||||
| 1091 | - | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil) | 1260 | + | handler := newTestHandler(t, func(d *HandlerDeps) { |
| 1261 | + | d.ConfigSvc = configSvc | |||
| 1262 | + | d.AuthSvc = authSvc | |||
| 1263 | + | d.ContainerSvc = containerSvc | |||
| 1264 | + | d.SecretSvc = secretSvc | |||
| 1265 | + | }) | |||
| 1092 | 1266 | ||||
| 1093 | req := httptest.NewRequest("DELETE", "/admin/secrets/app.example.com", nil) | 1267 | req := httptest.NewRequest("DELETE", "/admin/secrets/app.example.com", nil) | ||
| 1094 | req = req.WithContext(ctxWithScopes("admin:secrets:write")) | 1268 | req = req.WithContext(ctxWithScopes("admin:secrets:write")) | ||
| @@ -1105,7 +1279,12 @@ func TestHandler_MethodNotAllowed(t *testing.T) { | |||||
| 1105 | containerSvc := inmocks.NewMockContainerService(t) | 1279 | containerSvc := inmocks.NewMockContainerService(t) | ||
| 1106 | secretSvc := inmocks.NewMockSecretService(t) | 1280 | secretSvc := inmocks.NewMockSecretService(t) | ||
| 1107 | 1281 | ||||
| 1108 | - | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil) | 1282 | + | handler := newTestHandler(t, func(d *HandlerDeps) { |
| 1283 | + | d.ConfigSvc = configSvc | |||
| 1284 | + | d.AuthSvc = authSvc | |||
| 1285 | + | d.ContainerSvc = containerSvc | |||
| 1286 | + | d.SecretSvc = secretSvc | |||
| 1287 | + | }) | |||
| 1109 | 1288 | ||||
| 1110 | tests := []struct { | 1289 | tests := []struct { | ||
| 1111 | method string | 1290 | method string | ||
| @@ -1138,7 +1317,12 @@ func TestHandler_Restart_InvalidDomain(t *testing.T) { | |||||
| 1138 | containerSvc := inmocks.NewMockContainerService(t) | 1317 | containerSvc := inmocks.NewMockContainerService(t) | ||
| 1139 | secretSvc := inmocks.NewMockSecretService(t) | 1318 | secretSvc := inmocks.NewMockSecretService(t) | ||
| 1140 | 1319 | ||||
| 1141 | - | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil) | 1320 | + | handler := newTestHandler(t, func(d *HandlerDeps) { |
| 1321 | + | d.ConfigSvc = configSvc | |||
| 1322 | + | d.AuthSvc = authSvc | |||
| 1323 | + | d.ContainerSvc = containerSvc | |||
| 1324 | + | d.SecretSvc = secretSvc | |||
| 1325 | + | }) | |||
| 1142 | 1326 | ||||
| 1143 | tests := []struct { | 1327 | tests := []struct { | ||
| 1144 | name string | 1328 | name string | ||
| @@ -1168,7 +1352,12 @@ func TestHandler_Restart_ContainerNotFound(t *testing.T) { | |||||
| 1168 | containerSvc := inmocks.NewMockContainerService(t) | 1352 | containerSvc := inmocks.NewMockContainerService(t) | ||
| 1169 | secretSvc := inmocks.NewMockSecretService(t) | 1353 | secretSvc := inmocks.NewMockSecretService(t) | ||
| 1170 | 1354 | ||||
| 1171 | - | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil) | 1355 | + | handler := newTestHandler(t, func(d *HandlerDeps) { |
| 1356 | + | d.ConfigSvc = configSvc | |||
| 1357 | + | d.AuthSvc = authSvc | |||
| 1358 | + | d.ContainerSvc = containerSvc | |||
| 1359 | + | d.SecretSvc = secretSvc | |||
| 1360 | + | }) | |||
| 1172 | 1361 | ||||
| 1173 | containerSvc.EXPECT().Restart(mock.Anything, "nonexistent.example.com", false).Return(domain.ErrContainerNotFound) | 1362 | containerSvc.EXPECT().Restart(mock.Anything, "nonexistent.example.com", false).Return(domain.ErrContainerNotFound) | ||
| 1174 | 1363 | ||||
| @@ -1187,7 +1376,12 @@ func TestHandler_Restart_GenericError(t *testing.T) { | |||||
| 1187 | containerSvc := inmocks.NewMockContainerService(t) | 1376 | containerSvc := inmocks.NewMockContainerService(t) | ||
| 1188 | secretSvc := inmocks.NewMockSecretService(t) | 1377 | secretSvc := inmocks.NewMockSecretService(t) | ||
| 1189 | 1378 | ||||
| 1190 | - | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil) | 1379 | + | handler := newTestHandler(t, func(d *HandlerDeps) { |
| 1380 | + | d.ConfigSvc = configSvc | |||
| 1381 | + | d.AuthSvc = authSvc | |||
| 1382 | + | d.ContainerSvc = containerSvc | |||
| 1383 | + | d.SecretSvc = secretSvc | |||
| 1384 | + | }) | |||
| 1191 | 1385 | ||||
| 1192 | containerSvc.EXPECT().Restart(mock.Anything, "test.example.com", false).Return(fmt.Errorf("docker daemon error: secret details")) | 1386 | containerSvc.EXPECT().Restart(mock.Anything, "test.example.com", false).Return(fmt.Errorf("docker daemon error: secret details")) | ||
| 1193 | 1387 | ||||
| @@ -1209,7 +1403,12 @@ func TestHandler_Restart_Success(t *testing.T) { | |||||
| 1209 | containerSvc := inmocks.NewMockContainerService(t) | 1403 | containerSvc := inmocks.NewMockContainerService(t) | ||
| 1210 | secretSvc := inmocks.NewMockSecretService(t) | 1404 | secretSvc := inmocks.NewMockSecretService(t) | ||
| 1211 | 1405 | ||||
| 1212 | - | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil) | 1406 | + | handler := newTestHandler(t, func(d *HandlerDeps) { |
| 1407 | + | d.ConfigSvc = configSvc | |||
| 1408 | + | d.AuthSvc = authSvc | |||
| 1409 | + | d.ContainerSvc = containerSvc | |||
| 1410 | + | d.SecretSvc = secretSvc | |||
| 1411 | + | }) | |||
| 1213 | 1412 | ||||
| 1214 | tests := []struct { | 1413 | tests := []struct { | ||
| 1215 | name string | 1414 | name string | ||
| @@ -1256,7 +1455,12 @@ func TestHandler_Tags_InvalidRepository(t *testing.T) { | |||||
| 1256 | containerSvc := inmocks.NewMockContainerService(t) | 1455 | containerSvc := inmocks.NewMockContainerService(t) | ||
| 1257 | secretSvc := inmocks.NewMockSecretService(t) | 1456 | secretSvc := inmocks.NewMockSecretService(t) | ||
| 1258 | 1457 | ||||
| 1259 | - | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil) | 1458 | + | handler := newTestHandler(t, func(d *HandlerDeps) { |
| 1459 | + | d.ConfigSvc = configSvc | |||
| 1460 | + | d.AuthSvc = authSvc | |||
| 1461 | + | d.ContainerSvc = containerSvc | |||
| 1462 | + | d.SecretSvc = secretSvc | |||
| 1463 | + | }) | |||
| 1260 | 1464 | ||||
| 1261 | tests := []struct { | 1465 | tests := []struct { | ||
| 1262 | name string | 1466 | name string | ||
| @@ -1287,7 +1491,13 @@ func TestHandler_Tags_Success(t *testing.T) { | |||||
| 1287 | secretSvc := inmocks.NewMockSecretService(t) | 1491 | secretSvc := inmocks.NewMockSecretService(t) | ||
| 1288 | registrySvc := inmocks.NewMockRegistryService(t) | 1492 | registrySvc := inmocks.NewMockRegistryService(t) | ||
| 1289 | 1493 | ||||
| 1290 | - | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, registrySvc, nil, testLogger(), nil) | 1494 | + | handler := newTestHandler(t, func(d *HandlerDeps) { |
| 1495 | + | d.ConfigSvc = configSvc | |||
| 1496 | + | d.AuthSvc = authSvc | |||
| 1497 | + | d.ContainerSvc = containerSvc | |||
| 1498 | + | d.SecretSvc = secretSvc | |||
| 1499 | + | d.RegistrySvc = registrySvc | |||
| 1500 | + | }) | |||
| 1291 | 1501 | ||||
| 1292 | registrySvc.EXPECT().ListTags(mock.Anything, "myapp").Return([]string{"latest", "v1.0"}, nil) | 1502 | registrySvc.EXPECT().ListTags(mock.Anything, "myapp").Return([]string{"latest", "v1.0"}, nil) | ||
| 1293 | 1503 | ||||
| @@ -1314,7 +1524,13 @@ func TestHandler_Tags_Error(t *testing.T) { | |||||
| 1314 | secretSvc := inmocks.NewMockSecretService(t) | 1524 | secretSvc := inmocks.NewMockSecretService(t) | ||
| 1315 | registrySvc := inmocks.NewMockRegistryService(t) | 1525 | registrySvc := inmocks.NewMockRegistryService(t) | ||
| 1316 | 1526 | ||||
| 1317 | - | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, registrySvc, nil, testLogger(), nil) | 1527 | + | handler := newTestHandler(t, func(d *HandlerDeps) { |
| 1528 | + | d.ConfigSvc = configSvc | |||
| 1529 | + | d.AuthSvc = authSvc | |||
| 1530 | + | d.ContainerSvc = containerSvc | |||
| 1531 | + | d.SecretSvc = secretSvc | |||
| 1532 | + | d.RegistrySvc = registrySvc | |||
| 1533 | + | }) | |||
| 1318 | 1534 | ||||
| 1319 | registrySvc.EXPECT().ListTags(mock.Anything, "myapp").Return(nil, fmt.Errorf("registry error")) | 1535 | registrySvc.EXPECT().ListTags(mock.Anything, "myapp").Return(nil, fmt.Errorf("registry error")) | ||
| 1320 | 1536 | ||||
| @@ -1334,7 +1550,12 @@ func TestHandler_AttachmentSecretsPost_RequiresWriteScope(t *testing.T) { | |||||
| 1334 | containerSvc := inmocks.NewMockContainerService(t) | 1550 | containerSvc := inmocks.NewMockContainerService(t) | ||
| 1335 | secretSvc := inmocks.NewMockSecretService(t) | 1551 | secretSvc := inmocks.NewMockSecretService(t) | ||
| 1336 | 1552 | ||||
| 1337 | - | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil) | 1553 | + | handler := newTestHandler(t, func(d *HandlerDeps) { |
| 1554 | + | d.ConfigSvc = configSvc | |||
| 1555 | + | d.AuthSvc = authSvc | |||
| 1556 | + | d.ContainerSvc = containerSvc | |||
| 1557 | + | d.SecretSvc = secretSvc | |||
| 1558 | + | }) | |||
| 1338 | 1559 | ||||
| 1339 | body := `{"REDIS_URL": "redis://localhost:6379"}` | 1560 | body := `{"REDIS_URL": "redis://localhost:6379"}` | ||
| 1340 | req := httptest.NewRequest("POST", "/admin/secrets/app.example.com/attachments/redis", bytes.NewBufferString(body)) | 1561 | req := httptest.NewRequest("POST", "/admin/secrets/app.example.com/attachments/redis", bytes.NewBufferString(body)) | ||
| @@ -1352,7 +1573,12 @@ func TestHandler_AttachmentSecretsDelete_RequiresWriteScope(t *testing.T) { | |||||
| 1352 | containerSvc := inmocks.NewMockContainerService(t) | 1573 | containerSvc := inmocks.NewMockContainerService(t) | ||
| 1353 | secretSvc := inmocks.NewMockSecretService(t) | 1574 | secretSvc := inmocks.NewMockSecretService(t) | ||
| 1354 | 1575 | ||||
| 1355 | - | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil) | 1576 | + | handler := newTestHandler(t, func(d *HandlerDeps) { |
| 1577 | + | d.ConfigSvc = configSvc | |||
| 1578 | + | d.AuthSvc = authSvc | |||
| 1579 | + | d.ContainerSvc = containerSvc | |||
| 1580 | + | d.SecretSvc = secretSvc | |||
| 1581 | + | }) | |||
| 1356 | 1582 | ||||
| 1357 | req := httptest.NewRequest("DELETE", "/admin/secrets/app.example.com/attachments/redis/REDIS_URL", nil) | 1583 | req := httptest.NewRequest("DELETE", "/admin/secrets/app.example.com/attachments/redis/REDIS_URL", nil) | ||
| 1358 | req = req.WithContext(ctxWithScopes("admin:secrets:read")) | 1584 | req = req.WithContext(ctxWithScopes("admin:secrets:read")) | ||
| @@ -1369,7 +1595,12 @@ func TestHandler_AttachmentSecretsDelete_RequiresKey(t *testing.T) { | |||||
| 1369 | containerSvc := inmocks.NewMockContainerService(t) | 1595 | containerSvc := inmocks.NewMockContainerService(t) | ||
| 1370 | secretSvc := inmocks.NewMockSecretService(t) | 1596 | secretSvc := inmocks.NewMockSecretService(t) | ||
| 1371 | 1597 | ||||
| 1372 | - | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil) | 1598 | + | handler := newTestHandler(t, func(d *HandlerDeps) { |
| 1599 | + | d.ConfigSvc = configSvc | |||
| 1600 | + | d.AuthSvc = authSvc | |||
| 1601 | + | d.ContainerSvc = containerSvc | |||
| 1602 | + | d.SecretSvc = secretSvc | |||
| 1603 | + | }) | |||
| 1373 | 1604 | ||||
| 1374 | req := httptest.NewRequest("DELETE", "/admin/secrets/app.example.com/attachments/redis", nil) | 1605 | req := httptest.NewRequest("DELETE", "/admin/secrets/app.example.com/attachments/redis", nil) | ||
| 1375 | req = req.WithContext(ctxWithScopes("admin:secrets:write")) | 1606 | req = req.WithContext(ctxWithScopes("admin:secrets:write")) | ||
| @@ -1387,7 +1618,12 @@ func TestHandler_AttachmentSecretsPost_InvalidJSON(t *testing.T) { | |||||
| 1387 | containerSvc := inmocks.NewMockContainerService(t) | 1618 | containerSvc := inmocks.NewMockContainerService(t) | ||
| 1388 | secretSvc := inmocks.NewMockSecretService(t) | 1619 | secretSvc := inmocks.NewMockSecretService(t) | ||
| 1389 | 1620 | ||||
| 1390 | - | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil) | 1621 | + | handler := newTestHandler(t, func(d *HandlerDeps) { |
| 1622 | + | d.ConfigSvc = configSvc | |||
| 1623 | + | d.AuthSvc = authSvc | |||
| 1624 | + | d.ContainerSvc = containerSvc | |||
| 1625 | + | d.SecretSvc = secretSvc | |||
| 1626 | + | }) | |||
| 1391 | 1627 | ||||
| 1392 | body := `{not valid json` | 1628 | body := `{not valid json` | ||
| 1393 | req := httptest.NewRequest("POST", "/admin/secrets/app.example.com/attachments/redis", bytes.NewBufferString(body)) | 1629 | req := httptest.NewRequest("POST", "/admin/secrets/app.example.com/attachments/redis", bytes.NewBufferString(body)) | ||
| @@ -1406,7 +1642,12 @@ func TestHandler_NotFound(t *testing.T) { | |||||
| 1406 | containerSvc := inmocks.NewMockContainerService(t) | 1642 | containerSvc := inmocks.NewMockContainerService(t) | ||
| 1407 | secretSvc := inmocks.NewMockSecretService(t) | 1643 | secretSvc := inmocks.NewMockSecretService(t) | ||
| 1408 | 1644 | ||||
| 1409 | - | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil) | 1645 | + | handler := newTestHandler(t, func(d *HandlerDeps) { |
| 1646 | + | d.ConfigSvc = configSvc | |||
| 1647 | + | d.AuthSvc = authSvc | |||
| 1648 | + | d.ContainerSvc = containerSvc | |||
| 1649 | + | d.SecretSvc = secretSvc | |||
| 1650 | + | }) | |||
| 1410 | 1651 | ||||
| 1411 | req := httptest.NewRequest("GET", "/admin/unknown", nil) | 1652 | req := httptest.NewRequest("GET", "/admin/unknown", nil) | ||
| 1412 | req = req.WithContext(ctxWithScopes("admin:*:*")) | 1653 | req = req.WithContext(ctxWithScopes("admin:*:*")) | ||
| @@ -1426,7 +1667,13 @@ func TestHandler_BackupsStatus(t *testing.T) { | |||||
| 1426 | 1667 | ||||
| 1427 | backupSvc.EXPECT().Status(mock.Anything).Return([]domain.BackupJob{{Domain: "app.example.com", DBName: "postgres", Status: domain.BackupStatusCompleted, FilePath: "/var/lib/gordon/backups/private.bak"}}, nil) | 1668 | backupSvc.EXPECT().Status(mock.Anything).Return([]domain.BackupJob{{Domain: "app.example.com", DBName: "postgres", Status: domain.BackupStatusCompleted, FilePath: "/var/lib/gordon/backups/private.bak"}}, nil) | ||
| 1428 | 1669 | ||||
| 1429 | - | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), backupSvc) | 1670 | + | handler := newTestHandler(t, func(d *HandlerDeps) { |
| 1671 | + | d.ConfigSvc = configSvc | |||
| 1672 | + | d.AuthSvc = authSvc | |||
| 1673 | + | d.ContainerSvc = containerSvc | |||
| 1674 | + | d.SecretSvc = secretSvc | |||
| 1675 | + | d.BackupSvc = backupSvc | |||
| 1676 | + | }) | |||
| 1430 | 1677 | ||||
| 1431 | req := httptest.NewRequest("GET", "/admin/backups/status", nil) | 1678 | req := httptest.NewRequest("GET", "/admin/backups/status", nil) | ||
| 1432 | req = req.WithContext(ctxWithScopes("admin:status:read")) | 1679 | req = req.WithContext(ctxWithScopes("admin:status:read")) | ||
| @@ -1448,7 +1695,13 @@ func TestHandler_BackupsListDomain(t *testing.T) { | |||||
| 1448 | 1695 | ||||
| 1449 | backupSvc.EXPECT().ListBackups(mock.Anything, "app.example.com").Return([]domain.BackupJob{{Domain: "app.example.com", DBName: "postgres", Status: domain.BackupStatusCompleted}}, nil) | 1696 | backupSvc.EXPECT().ListBackups(mock.Anything, "app.example.com").Return([]domain.BackupJob{{Domain: "app.example.com", DBName: "postgres", Status: domain.BackupStatusCompleted}}, nil) | ||
| 1450 | 1697 | ||||
| 1451 | - | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), backupSvc) | 1698 | + | handler := newTestHandler(t, func(d *HandlerDeps) { |
| 1699 | + | d.ConfigSvc = configSvc | |||
| 1700 | + | d.AuthSvc = authSvc | |||
| 1701 | + | d.ContainerSvc = containerSvc | |||
| 1702 | + | d.SecretSvc = secretSvc | |||
| 1703 | + | d.BackupSvc = backupSvc | |||
| 1704 | + | }) | |||
| 1452 | 1705 | ||||
| 1453 | req := httptest.NewRequest("GET", "/admin/backups/app.example.com", nil) | 1706 | req := httptest.NewRequest("GET", "/admin/backups/app.example.com", nil) | ||
| 1454 | req = req.WithContext(ctxWithScopes("admin:status:read")) | 1707 | req = req.WithContext(ctxWithScopes("admin:status:read")) | ||
| @@ -1469,7 +1722,13 @@ func TestHandler_BackupsRunDomain(t *testing.T) { | |||||
| 1469 | 1722 | ||||
| 1470 | backupSvc.EXPECT().RunBackup(mock.Anything, "app.example.com", "postgres").Return(&domain.BackupResult{Job: domain.BackupJob{Domain: "app.example.com", DBName: "postgres", Status: domain.BackupStatusCompleted}}, nil) | 1723 | backupSvc.EXPECT().RunBackup(mock.Anything, "app.example.com", "postgres").Return(&domain.BackupResult{Job: domain.BackupJob{Domain: "app.example.com", DBName: "postgres", Status: domain.BackupStatusCompleted}}, nil) | ||
| 1471 | 1724 | ||||
| 1472 | - | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), backupSvc) | 1725 | + | handler := newTestHandler(t, func(d *HandlerDeps) { |
| 1726 | + | d.ConfigSvc = configSvc | |||
| 1727 | + | d.AuthSvc = authSvc | |||
| 1728 | + | d.ContainerSvc = containerSvc | |||
| 1729 | + | d.SecretSvc = secretSvc | |||
| 1730 | + | d.BackupSvc = backupSvc | |||
| 1731 | + | }) | |||
| 1473 | 1732 | ||||
| 1474 | body := bytes.NewBufferString(`{"db":"postgres"}`) | 1733 | body := bytes.NewBufferString(`{"db":"postgres"}`) | ||
| 1475 | req := httptest.NewRequest("POST", "/admin/backups/app.example.com", body) | 1734 | req := httptest.NewRequest("POST", "/admin/backups/app.example.com", body) | ||
| @@ -1491,7 +1750,13 @@ func TestHandler_BackupsRunDomain_ChunkedBodyIsDecoded(t *testing.T) { | |||||
| 1491 | 1750 | ||||
| 1492 | backupSvc.EXPECT().RunBackup(mock.Anything, "app.example.com", "postgres").Return(&domain.BackupResult{Job: domain.BackupJob{Domain: "app.example.com", DBName: "postgres", Status: domain.BackupStatusCompleted}}, nil) | 1751 | backupSvc.EXPECT().RunBackup(mock.Anything, "app.example.com", "postgres").Return(&domain.BackupResult{Job: domain.BackupJob{Domain: "app.example.com", DBName: "postgres", Status: domain.BackupStatusCompleted}}, nil) | ||
| 1493 | 1752 | ||||
| 1494 | - | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), backupSvc) | 1753 | + | handler := newTestHandler(t, func(d *HandlerDeps) { |
| 1754 | + | d.ConfigSvc = configSvc | |||
| 1755 | + | d.AuthSvc = authSvc | |||
| 1756 | + | d.ContainerSvc = containerSvc | |||
| 1757 | + | d.SecretSvc = secretSvc | |||
| 1758 | + | d.BackupSvc = backupSvc | |||
| 1759 | + | }) | |||
| 1495 | 1760 | ||||
| 1496 | body := bytes.NewBufferString(`{"db":"postgres"}`) | 1761 | body := bytes.NewBufferString(`{"db":"postgres"}`) | ||
| 1497 | req := httptest.NewRequest("POST", "/admin/backups/app.example.com", body) | 1762 | req := httptest.NewRequest("POST", "/admin/backups/app.example.com", body) | ||
| @@ -1514,7 +1779,13 @@ func TestHandler_BackupsDetectDomain(t *testing.T) { | |||||
| 1514 | 1779 | ||||
| 1515 | backupSvc.EXPECT().DetectDatabases(mock.Anything, "app.example.com").Return([]domain.DBInfo{{Type: domain.DBTypePostgreSQL, Name: "postgres", Port: 5432}}, nil) | 1780 | backupSvc.EXPECT().DetectDatabases(mock.Anything, "app.example.com").Return([]domain.DBInfo{{Type: domain.DBTypePostgreSQL, Name: "postgres", Port: 5432}}, nil) | ||
| 1516 | 1781 | ||||
| 1517 | - | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), backupSvc) | 1782 | + | handler := newTestHandler(t, func(d *HandlerDeps) { |
| 1783 | + | d.ConfigSvc = configSvc | |||
| 1784 | + | d.AuthSvc = authSvc | |||
| 1785 | + | d.ContainerSvc = containerSvc | |||
| 1786 | + | d.SecretSvc = secretSvc | |||
| 1787 | + | d.BackupSvc = backupSvc | |||
| 1788 | + | }) | |||
| 1518 | 1789 | ||||
| 1519 | req := httptest.NewRequest("GET", "/admin/backups/app.example.com/detect", nil) | 1790 | req := httptest.NewRequest("GET", "/admin/backups/app.example.com/detect", nil) | ||
| 1520 | req = req.WithContext(ctxWithScopes("admin:status:read")) | 1791 | req = req.WithContext(ctxWithScopes("admin:status:read")) | ||
| @@ -1556,7 +1827,13 @@ func TestHandler_ImagesGet_ReturnsMappedList(t *testing.T) { | |||||
| 1556 | }, | 1827 | }, | ||
| 1557 | } | 1828 | } | ||
| 1558 | 1829 | ||||
| 1559 | - | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil, imageSvc) | 1830 | + | handler := newTestHandler(t, func(d *HandlerDeps) { |
| 1831 | + | d.ConfigSvc = configSvc | |||
| 1832 | + | d.AuthSvc = authSvc | |||
| 1833 | + | d.ContainerSvc = containerSvc | |||
| 1834 | + | d.SecretSvc = secretSvc | |||
| 1835 | + | d.ImageSvc = imageSvc | |||
| 1836 | + | }) | |||
| 1560 | 1837 | ||||
| 1561 | req := httptest.NewRequest("GET", "/admin/images", nil) | 1838 | req := httptest.NewRequest("GET", "/admin/images", nil) | ||
| 1562 | req = req.WithContext(ctxWithScopes("admin:status:read")) | 1839 | req = req.WithContext(ctxWithScopes("admin:status:read")) | ||
| @@ -1617,7 +1894,13 @@ func TestHandler_ImagesPrune_AcceptsOptionalKeepLast(t *testing.T) { | |||||
| 1617 | }, | 1894 | }, | ||
| 1618 | } | 1895 | } | ||
| 1619 | 1896 | ||||
| 1620 | - | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil, imageSvc) | 1897 | + | handler := newTestHandler(t, func(d *HandlerDeps) { |
| 1898 | + | d.ConfigSvc = configSvc | |||
| 1899 | + | d.AuthSvc = authSvc | |||
| 1900 | + | d.ContainerSvc = containerSvc | |||
| 1901 | + | d.SecretSvc = secretSvc | |||
| 1902 | + | d.ImageSvc = imageSvc | |||
| 1903 | + | }) | |||
| 1621 | 1904 | ||||
| 1622 | req := httptest.NewRequest("POST", "/admin/images/prune", bytes.NewBufferString(tt.body)) | 1905 | req := httptest.NewRequest("POST", "/admin/images/prune", bytes.NewBufferString(tt.body)) | ||
| 1623 | req = req.WithContext(ctxWithScopes("admin:config:write")) | 1906 | req = req.WithContext(ctxWithScopes("admin:config:write")) | ||
| @@ -1646,7 +1929,13 @@ func TestHandler_Images_ErrorMappingForServiceFailures(t *testing.T) { | |||||
| 1646 | }, | 1929 | }, | ||
| 1647 | } | 1930 | } | ||
| 1648 | 1931 | ||||
| 1649 | - | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil, imageSvc) | 1932 | + | handler := newTestHandler(t, func(d *HandlerDeps) { |
| 1933 | + | d.ConfigSvc = configSvc | |||
| 1934 | + | d.AuthSvc = authSvc | |||
| 1935 | + | d.ContainerSvc = containerSvc | |||
| 1936 | + | d.SecretSvc = secretSvc | |||
| 1937 | + | d.ImageSvc = imageSvc | |||
| 1938 | + | }) | |||
| 1650 | 1939 | ||||
| 1651 | req := httptest.NewRequest("GET", "/admin/images", nil) | 1940 | req := httptest.NewRequest("GET", "/admin/images", nil) | ||
| 1652 | req = req.WithContext(ctxWithScopes("admin:status:read")) | 1941 | req = req.WithContext(ctxWithScopes("admin:status:read")) | ||
| @@ -1671,7 +1960,13 @@ func TestHandler_Images_ErrorMappingForServiceFailures(t *testing.T) { | |||||
| 1671 | }, | 1960 | }, | ||
| 1672 | } | 1961 | } | ||
| 1673 | 1962 | ||||
| 1674 | - | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil, imageSvc) | 1963 | + | handler := newTestHandler(t, func(d *HandlerDeps) { |
| 1964 | + | d.ConfigSvc = configSvc | |||
| 1965 | + | d.AuthSvc = authSvc | |||
| 1966 | + | d.ContainerSvc = containerSvc | |||
| 1967 | + | d.SecretSvc = secretSvc | |||
| 1968 | + | d.ImageSvc = imageSvc | |||
| 1969 | + | }) | |||
| 1675 | 1970 | ||||
| 1676 | req := httptest.NewRequest("POST", "/admin/images/prune", bytes.NewBufferString(`{"keep_last": 2}`)) | 1971 | req := httptest.NewRequest("POST", "/admin/images/prune", bytes.NewBufferString(`{"keep_last": 2}`)) | ||
| 1677 | req = req.WithContext(ctxWithScopes("admin:config:write")) | 1972 | req = req.WithContext(ctxWithScopes("admin:config:write")) | ||
| @@ -1692,7 +1987,13 @@ func TestHandler_ImagesPrune_ValidationAndAvailability(t *testing.T) { | |||||
| 1692 | containerSvc := inmocks.NewMockContainerService(t) | 1987 | containerSvc := inmocks.NewMockContainerService(t) | ||
| 1693 | secretSvc := inmocks.NewMockSecretService(t) | 1988 | secretSvc := inmocks.NewMockSecretService(t) | ||
| 1694 | 1989 | ||||
| 1695 | - | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil, &stubImageService{}) | 1990 | + | handler := newTestHandler(t, func(d *HandlerDeps) { |
| 1991 | + | d.ConfigSvc = configSvc | |||
| 1992 | + | d.AuthSvc = authSvc | |||
| 1993 | + | d.ContainerSvc = containerSvc | |||
| 1994 | + | d.SecretSvc = secretSvc | |||
| 1995 | + | d.ImageSvc = &stubImageService{} | |||
| 1996 | + | }) | |||
| 1696 | 1997 | ||||
| 1697 | req := httptest.NewRequest("POST", "/admin/images/prune", bytes.NewBufferString(`{"keep_last": -1}`)) | 1998 | req := httptest.NewRequest("POST", "/admin/images/prune", bytes.NewBufferString(`{"keep_last": -1}`)) | ||
| 1698 | req = req.WithContext(ctxWithScopes("admin:config:write")) | 1999 | req = req.WithContext(ctxWithScopes("admin:config:write")) | ||
| @@ -1710,7 +2011,12 @@ func TestHandler_ImagesPrune_ValidationAndAvailability(t *testing.T) { | |||||
| 1710 | containerSvc := inmocks.NewMockContainerService(t) | 2011 | containerSvc := inmocks.NewMockContainerService(t) | ||
| 1711 | secretSvc := inmocks.NewMockSecretService(t) | 2012 | secretSvc := inmocks.NewMockSecretService(t) | ||
| 1712 | 2013 | ||||
| 1713 | - | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil) | 2014 | + | handler := newTestHandler(t, func(d *HandlerDeps) { |
| 2015 | + | d.ConfigSvc = configSvc | |||
| 2016 | + | d.AuthSvc = authSvc | |||
| 2017 | + | d.ContainerSvc = containerSvc | |||
| 2018 | + | d.SecretSvc = secretSvc | |||
| 2019 | + | }) | |||
| 1714 | 2020 | ||||
| 1715 | req := httptest.NewRequest("GET", "/admin/images", nil) | 2021 | req := httptest.NewRequest("GET", "/admin/images", nil) | ||
| 1716 | req = req.WithContext(ctxWithScopes("admin:status:read")) | 2022 | req = req.WithContext(ctxWithScopes("admin:status:read")) | ||
| @@ -1781,7 +2087,13 @@ func TestHandler_ImagesPrune_ScopeDefaults(t *testing.T) { | |||||
| 1781 | }, | 2087 | }, | ||
| 1782 | } | 2088 | } | ||
| 1783 | 2089 | ||||
| 1784 | - | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil, imageSvc) | 2090 | + | handler := newTestHandler(t, func(d *HandlerDeps) { |
| 2091 | + | d.ConfigSvc = configSvc | |||
| 2092 | + | d.AuthSvc = authSvc | |||
| 2093 | + | d.ContainerSvc = containerSvc | |||
| 2094 | + | d.SecretSvc = secretSvc | |||
| 2095 | + | d.ImageSvc = imageSvc | |||
| 2096 | + | }) | |||
| 1785 | 2097 | ||||
| 1786 | req := httptest.NewRequest("POST", "/admin/images/prune", bytes.NewBufferString(tt.body)) | 2098 | req := httptest.NewRequest("POST", "/admin/images/prune", bytes.NewBufferString(tt.body)) | ||
| 1787 | req = req.WithContext(ctxWithScopes("admin:config:write")) | 2099 | req = req.WithContext(ctxWithScopes("admin:config:write")) | ||
| @@ -1806,7 +2118,13 @@ func TestHandler_Images_Authorization(t *testing.T) { | |||||
| 1806 | containerSvc := inmocks.NewMockContainerService(t) | 2118 | containerSvc := inmocks.NewMockContainerService(t) | ||
| 1807 | secretSvc := inmocks.NewMockSecretService(t) | 2119 | secretSvc := inmocks.NewMockSecretService(t) | ||
| 1808 | 2120 | ||||
| 1809 | - | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil, &stubImageService{}) | 2121 | + | handler := newTestHandler(t, func(d *HandlerDeps) { |
| 2122 | + | d.ConfigSvc = configSvc | |||
| 2123 | + | d.AuthSvc = authSvc | |||
| 2124 | + | d.ContainerSvc = containerSvc | |||
| 2125 | + | d.SecretSvc = secretSvc | |||
| 2126 | + | d.ImageSvc = &stubImageService{} | |||
| 2127 | + | }) | |||
| 1810 | 2128 | ||||
| 1811 | t.Run("images list requires status:read", func(t *testing.T) { | 2129 | t.Run("images list requires status:read", func(t *testing.T) { | ||
| 1812 | req := httptest.NewRequest("GET", "/admin/images", nil) | 2130 | req := httptest.NewRequest("GET", "/admin/images", nil) | ||
A internal/adapters/in/http/admin/preview_handler.go +149 -0
| @@ -0,0 +1,149 @@ | |||||
| 1 | + | package admin | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "encoding/json" | |||
| 6 | + | "errors" | |||
| 7 | + | "net/http" | |||
| 8 | + | "strings" | |||
| 9 | + | "time" | |||
| 10 | + | ||||
| 11 | + | "github.com/bnema/zerowrap" | |||
| 12 | + | ||||
| 13 | + | "github.com/bnema/gordon/internal/domain" | |||
| 14 | + | ) | |||
| 15 | + | ||||
| 16 | + | // previewService is a minimal interface for preview management used by the admin handler. | |||
| 17 | + | type previewService interface { | |||
| 18 | + | List(ctx context.Context) ([]domain.PreviewRoute, error) | |||
| 19 | + | Delete(ctx context.Context, name string) error | |||
| 20 | + | Extend(ctx context.Context, name string, ttl time.Duration) error | |||
| 21 | + | } | |||
| 22 | + | ||||
| 23 | + | // previewListResponse is the JSON payload returned by GET /admin/previews. | |||
| 24 | + | type previewListResponse struct { | |||
| 25 | + | Previews []domain.PreviewRoute `json:"previews"` | |||
| 26 | + | } | |||
| 27 | + | ||||
| 28 | + | // previewExtendRequest is the JSON body for PATCH /admin/preview/{name}. | |||
| 29 | + | type previewExtendRequest struct { | |||
| 30 | + | TTL string `json:"ttl"` // e.g. "24h", "30m" | |||
| 31 | + | } | |||
| 32 | + | ||||
| 33 | + | // handlePreviewList handles GET /admin/previews. | |||
| 34 | + | // Returns a JSON array of all active preview environments. | |||
| 35 | + | func (h *Handler) handlePreviewList(w http.ResponseWriter, r *http.Request, _ string) { | |||
| 36 | + | if r.Method != http.MethodGet { | |||
| 37 | + | h.sendError(w, http.StatusMethodNotAllowed, "method not allowed") | |||
| 38 | + | return | |||
| 39 | + | } | |||
| 40 | + | ||||
| 41 | + | ctx := r.Context() | |||
| 42 | + | ||||
| 43 | + | if !HasAccess(ctx, domain.AdminResourceStatus, domain.AdminActionRead) { | |||
| 44 | + | h.sendError(w, http.StatusForbidden, "insufficient permissions for status:read") | |||
| 45 | + | return | |||
| 46 | + | } | |||
| 47 | + | ||||
| 48 | + | if h.previewSvc == nil { | |||
| 49 | + | h.sendError(w, http.StatusServiceUnavailable, "preview service not available") | |||
| 50 | + | return | |||
| 51 | + | } | |||
| 52 | + | ||||
| 53 | + | previews, err := h.previewSvc.List(ctx) | |||
| 54 | + | if err != nil { | |||
| 55 | + | log := zerowrap.FromCtx(ctx) | |||
| 56 | + | log.Error().Err(err).Msg("failed to list previews") | |||
| 57 | + | h.sendError(w, http.StatusInternalServerError, "failed to list previews") | |||
| 58 | + | return | |||
| 59 | + | } | |||
| 60 | + | ||||
| 61 | + | if previews == nil { | |||
| 62 | + | previews = []domain.PreviewRoute{} | |||
| 63 | + | } | |||
| 64 | + | ||||
| 65 | + | h.sendJSON(w, http.StatusOK, previewListResponse{Previews: previews}) | |||
| 66 | + | } | |||
| 67 | + | ||||
| 68 | + | // handlePreviewAction handles DELETE and PATCH on /admin/preview/{name}. | |||
| 69 | + | // | |||
| 70 | + | // DELETE /admin/preview/{name} — tear down the preview | |||
| 71 | + | // PATCH /admin/preview/{name} — extend TTL (body: {"ttl":"24h"}) | |||
| 72 | + | func (h *Handler) handlePreviewAction(w http.ResponseWriter, r *http.Request, path string) { | |||
| 73 | + | ctx := r.Context() | |||
| 74 | + | log := zerowrap.FromCtx(ctx) | |||
| 75 | + | ||||
| 76 | + | if h.previewSvc == nil { | |||
| 77 | + | h.sendError(w, http.StatusServiceUnavailable, "preview service not available") | |||
| 78 | + | return | |||
| 79 | + | } | |||
| 80 | + | ||||
| 81 | + | name := strings.TrimPrefix(path, "/preview/") | |||
| 82 | + | if name == "" || name == "/preview" { | |||
| 83 | + | h.sendError(w, http.StatusBadRequest, "preview name required in path") | |||
| 84 | + | return | |||
| 85 | + | } | |||
| 86 | + | ||||
| 87 | + | switch r.Method { | |||
| 88 | + | case http.MethodDelete: | |||
| 89 | + | if !HasAccess(ctx, domain.AdminResourceConfig, domain.AdminActionWrite) { | |||
| 90 | + | h.sendError(w, http.StatusForbidden, "insufficient permissions for config:write") | |||
| 91 | + | return | |||
| 92 | + | } | |||
| 93 | + | ||||
| 94 | + | if err := h.previewSvc.Delete(ctx, name); err != nil { | |||
| 95 | + | log.Error().Err(err).Str("name", name).Msg("failed to delete preview") | |||
| 96 | + | if errors.Is(err, domain.ErrPreviewNotFound) { | |||
| 97 | + | h.sendError(w, http.StatusNotFound, "preview not found") | |||
| 98 | + | } else { | |||
| 99 | + | h.sendError(w, http.StatusInternalServerError, "failed to delete preview") | |||
| 100 | + | } | |||
| 101 | + | return | |||
| 102 | + | } | |||
| 103 | + | ||||
| 104 | + | log.Info().Str("name", name).Msg("preview deleted via admin API") | |||
| 105 | + | h.sendJSON(w, http.StatusOK, map[string]string{"status": "deleted", "name": name}) | |||
| 106 | + | ||||
| 107 | + | case http.MethodPatch: | |||
| 108 | + | if !HasAccess(ctx, domain.AdminResourceConfig, domain.AdminActionWrite) { | |||
| 109 | + | h.sendError(w, http.StatusForbidden, "insufficient permissions for config:write") | |||
| 110 | + | return | |||
| 111 | + | } | |||
| 112 | + | ||||
| 113 | + | r.Body = http.MaxBytesReader(w, r.Body, maxAdminRequestSize) | |||
| 114 | + | ||||
| 115 | + | var req previewExtendRequest | |||
| 116 | + | if err := json.NewDecoder(r.Body).Decode(&req); err != nil { | |||
| 117 | + | log.Warn().Err(err).Msg("invalid preview extend JSON") | |||
| 118 | + | h.sendError(w, http.StatusBadRequest, "invalid JSON") | |||
| 119 | + | return | |||
| 120 | + | } | |||
| 121 | + | ||||
| 122 | + | if req.TTL == "" { | |||
| 123 | + | h.sendError(w, http.StatusBadRequest, "ttl is required") | |||
| 124 | + | return | |||
| 125 | + | } | |||
| 126 | + | ||||
| 127 | + | ttl, err := time.ParseDuration(req.TTL) | |||
| 128 | + | if err != nil { | |||
| 129 | + | h.sendError(w, http.StatusBadRequest, "invalid ttl: use Go duration format, e.g. 24h or 30m") | |||
| 130 | + | return | |||
| 131 | + | } | |||
| 132 | + | ||||
| 133 | + | if err := h.previewSvc.Extend(ctx, name, ttl); err != nil { | |||
| 134 | + | log.Error().Err(err).Str("name", name).Dur("ttl", ttl).Msg("failed to extend preview TTL") | |||
| 135 | + | if errors.Is(err, domain.ErrPreviewNotFound) { | |||
| 136 | + | h.sendError(w, http.StatusNotFound, "preview not found") | |||
| 137 | + | } else { | |||
| 138 | + | h.sendError(w, http.StatusInternalServerError, "failed to extend preview") | |||
| 139 | + | } | |||
| 140 | + | return | |||
| 141 | + | } | |||
| 142 | + | ||||
| 143 | + | log.Info().Str("name", name).Dur("ttl", ttl).Msg("preview TTL extended via admin API") | |||
| 144 | + | h.sendJSON(w, http.StatusOK, map[string]string{"status": "extended", "name": name, "ttl": req.TTL}) | |||
| 145 | + | ||||
| 146 | + | default: | |||
| 147 | + | h.sendError(w, http.StatusMethodNotAllowed, "method not allowed") | |||
| 148 | + | } | |||
| 149 | + | } | |||
M internal/adapters/out/docker/runtime.go +30 -0
| @@ -142,6 +142,13 @@ func (r *Runtime) CreateContainer(ctx context.Context, config *domain.ContainerC | |||||
| 142 | log.Debug().Str("volume", volumeName).Str("mount_path", containerPath).Msg("adding volume mount") | 142 | log.Debug().Str("volume", volumeName).Str("mount_path", containerPath).Msg("adding volume mount") | ||
| 143 | } | 143 | } | ||
| 144 | } | 144 | } | ||
| 145 | + | if config.ReadOnlyVolumes != nil { | |||
| 146 | + | for containerPath, volumeName := range config.ReadOnlyVolumes { | |||
| 147 | + | bind := fmt.Sprintf("%s:%s:ro", volumeName, containerPath) | |||
| 148 | + | binds = append(binds, bind) | |||
| 149 | + | log.Debug().Str("volume", volumeName).Str("mount_path", containerPath).Msg("adding read-only volume mount") | |||
| 150 | + | } | |||
| 151 | + | } | |||
| 145 | 152 | ||||
| 146 | // Create container configuration | 153 | // Create container configuration | ||
| 147 | containerConfig := &container.Config{ | 154 | containerConfig := &container.Config{ | ||
| @@ -207,6 +214,29 @@ func (r *Runtime) StartContainer(ctx context.Context, containerID string) error | |||||
| 207 | return nil | 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 | // StopContainer stops a container. | 240 | // StopContainer stops a container. | ||
| 211 | func (r *Runtime) StopContainer(ctx context.Context, containerID string) error { | 241 | func (r *Runtime) StopContainer(ctx context.Context, containerID string) error { | ||
| 212 | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | 242 | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | ||
A internal/adapters/out/filesystem/previewstore.go +92 -0
| @@ -0,0 +1,92 @@ | |||||
| 1 | + | package filesystem | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "encoding/json" | |||
| 6 | + | "errors" | |||
| 7 | + | "io/fs" | |||
| 8 | + | "os" | |||
| 9 | + | "path/filepath" | |||
| 10 | + | "sync" | |||
| 11 | + | ||||
| 12 | + | "github.com/bnema/gordon/internal/domain" | |||
| 13 | + | ) | |||
| 14 | + | ||||
| 15 | + | type previewStoreData struct { | |||
| 16 | + | Previews []domain.PreviewRoute `json:"previews"` | |||
| 17 | + | } | |||
| 18 | + | ||||
| 19 | + | // PreviewStore persists preview routes to a JSON file. | |||
| 20 | + | type PreviewStore struct { | |||
| 21 | + | path string | |||
| 22 | + | mu sync.Mutex | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | // NewPreviewStore creates a new filesystem-backed preview store. | |||
| 26 | + | func NewPreviewStore(path string) *PreviewStore { | |||
| 27 | + | return &PreviewStore{path: path} | |||
| 28 | + | } | |||
| 29 | + | ||||
| 30 | + | func (s *PreviewStore) Load(_ context.Context) ([]domain.PreviewRoute, error) { | |||
| 31 | + | s.mu.Lock() | |||
| 32 | + | defer s.mu.Unlock() | |||
| 33 | + | ||||
| 34 | + | data, err := os.ReadFile(s.path) | |||
| 35 | + | if err != nil { | |||
| 36 | + | if errors.Is(err, fs.ErrNotExist) { | |||
| 37 | + | return nil, nil | |||
| 38 | + | } | |||
| 39 | + | return nil, err | |||
| 40 | + | } | |||
| 41 | + | ||||
| 42 | + | var store previewStoreData | |||
| 43 | + | if err := json.Unmarshal(data, &store); err != nil { | |||
| 44 | + | return nil, err | |||
| 45 | + | } | |||
| 46 | + | return store.Previews, nil | |||
| 47 | + | } | |||
| 48 | + | ||||
| 49 | + | func (s *PreviewStore) Save(_ context.Context, previews []domain.PreviewRoute) error { | |||
| 50 | + | s.mu.Lock() | |||
| 51 | + | defer s.mu.Unlock() | |||
| 52 | + | ||||
| 53 | + | if len(previews) == 0 { | |||
| 54 | + | err := os.Remove(s.path) | |||
| 55 | + | if errors.Is(err, fs.ErrNotExist) { | |||
| 56 | + | return nil | |||
| 57 | + | } | |||
| 58 | + | return err | |||
| 59 | + | } | |||
| 60 | + | ||||
| 61 | + | data, err := json.MarshalIndent(previewStoreData{Previews: previews}, "", " ") | |||
| 62 | + | if err != nil { | |||
| 63 | + | return err | |||
| 64 | + | } | |||
| 65 | + | ||||
| 66 | + | // Atomic write: temp file → rename | |||
| 67 | + | dir := filepath.Dir(s.path) | |||
| 68 | + | if err := os.MkdirAll(dir, 0o750); err != nil { | |||
| 69 | + | return err | |||
| 70 | + | } | |||
| 71 | + | tmp, err := os.CreateTemp(dir, ".previews-*.json.tmp") | |||
| 72 | + | if err != nil { | |||
| 73 | + | return err | |||
| 74 | + | } | |||
| 75 | + | tmpName := tmp.Name() | |||
| 76 | + | ||||
| 77 | + | if _, err := tmp.Write(data); err != nil { | |||
| 78 | + | tmp.Close() | |||
| 79 | + | os.Remove(tmpName) | |||
| 80 | + | return err | |||
| 81 | + | } | |||
| 82 | + | if err := tmp.Sync(); err != nil { | |||
| 83 | + | tmp.Close() | |||
| 84 | + | os.Remove(tmpName) | |||
| 85 | + | return err | |||
| 86 | + | } | |||
| 87 | + | if err := tmp.Close(); err != nil { | |||
| 88 | + | os.Remove(tmpName) | |||
| 89 | + | return err | |||
| 90 | + | } | |||
| 91 | + | return os.Rename(tmpName, s.path) | |||
| 92 | + | } | |||
A internal/adapters/out/filesystem/previewstore_test.go +61 -0
| @@ -0,0 +1,61 @@ | |||||
| 1 | + | package filesystem | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "os" | |||
| 6 | + | "path/filepath" | |||
| 7 | + | "testing" | |||
| 8 | + | "time" | |||
| 9 | + | ||||
| 10 | + | "github.com/bnema/gordon/internal/domain" | |||
| 11 | + | "github.com/stretchr/testify/assert" | |||
| 12 | + | "github.com/stretchr/testify/require" | |||
| 13 | + | ) | |||
| 14 | + | ||||
| 15 | + | func TestPreviewStore_SaveAndLoad(t *testing.T) { | |||
| 16 | + | dir := t.TempDir() | |||
| 17 | + | store := NewPreviewStore(filepath.Join(dir, "previews.json")) | |||
| 18 | + | ||||
| 19 | + | previews := []domain.PreviewRoute{ | |||
| 20 | + | { | |||
| 21 | + | Domain: "myapp--feat.example.com", | |||
| 22 | + | Image: "myapp:preview-feat", | |||
| 23 | + | BaseRoute: "myapp.example.com", | |||
| 24 | + | Name: "feat", | |||
| 25 | + | CreatedAt: time.Now().UTC().Truncate(time.Second), | |||
| 26 | + | ExpiresAt: time.Now().UTC().Add(48 * time.Hour).Truncate(time.Second), | |||
| 27 | + | HTTPS: true, | |||
| 28 | + | Volumes: []string{"preview-feat-pgdata"}, | |||
| 29 | + | Containers: []string{"preview-feat-postgres"}, | |||
| 30 | + | }, | |||
| 31 | + | } | |||
| 32 | + | ||||
| 33 | + | ctx := context.Background() | |||
| 34 | + | err := store.Save(ctx, previews) | |||
| 35 | + | require.NoError(t, err) | |||
| 36 | + | ||||
| 37 | + | loaded, err := store.Load(ctx) | |||
| 38 | + | require.NoError(t, err) | |||
| 39 | + | assert.Equal(t, previews, loaded) | |||
| 40 | + | } | |||
| 41 | + | ||||
| 42 | + | func TestPreviewStore_LoadEmpty(t *testing.T) { | |||
| 43 | + | dir := t.TempDir() | |||
| 44 | + | store := NewPreviewStore(filepath.Join(dir, "previews.json")) | |||
| 45 | + | ||||
| 46 | + | loaded, err := store.Load(context.Background()) | |||
| 47 | + | require.NoError(t, err) | |||
| 48 | + | assert.Empty(t, loaded) | |||
| 49 | + | } | |||
| 50 | + | ||||
| 51 | + | func TestPreviewStore_SaveEmpty(t *testing.T) { | |||
| 52 | + | dir := t.TempDir() | |||
| 53 | + | path := filepath.Join(dir, "previews.json") | |||
| 54 | + | store := NewPreviewStore(path) | |||
| 55 | + | ||||
| 56 | + | err := store.Save(context.Background(), []domain.PreviewRoute{}) | |||
| 57 | + | require.NoError(t, err) | |||
| 58 | + | ||||
| 59 | + | _, err = os.Stat(path) | |||
| 60 | + | assert.True(t, os.IsNotExist(err)) | |||
| 61 | + | } | |||
M internal/app/run.go +63 -15
| @@ -64,6 +64,8 @@ import ( | |||||
| 64 | 64 | ||||
| 65 | // Use cases | 65 | // Use cases | ||
| 66 | "github.com/bnema/gordon/internal/usecase/auth" | 66 | "github.com/bnema/gordon/internal/usecase/auth" | ||
| 67 | + | "github.com/bnema/gordon/internal/usecase/auto" | |||
| 68 | + | "github.com/bnema/gordon/internal/usecase/auto/preview" | |||
| 67 | "github.com/bnema/gordon/internal/usecase/backup" | 69 | "github.com/bnema/gordon/internal/usecase/backup" | ||
| 68 | "github.com/bnema/gordon/internal/usecase/config" | 70 | "github.com/bnema/gordon/internal/usecase/config" | ||
| 69 | "github.com/bnema/gordon/internal/usecase/container" | 71 | "github.com/bnema/gordon/internal/usecase/container" | ||
| @@ -192,6 +194,8 @@ type services struct { | |||||
| 192 | adminHandler *admin.Handler | 194 | adminHandler *admin.Handler | ||
| 193 | internalRegUser string | 195 | internalRegUser string | ||
| 194 | internalRegPass string | 196 | internalRegPass string | ||
| 197 | + | previewStore *filesystem.PreviewStore | |||
| 198 | + | previewService *preview.Service | |||
| 195 | envDir string | 199 | envDir string | ||
| 196 | maxBlobChunkSize int64 | 200 | maxBlobChunkSize int64 | ||
| 197 | } | 201 | } | ||
| @@ -541,24 +545,57 @@ func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowra | |||||
| 541 | // Create log service for accessing logs via admin API | 545 | // Create log service for accessing logs via admin API | ||
| 542 | svc.logSvc = logs.NewService(resolveLogFilePath(cfg), cfg.Logging.File.Enabled, svc.containerSvc, svc.runtime, log) | 546 | svc.logSvc = logs.NewService(resolveLogFilePath(cfg), cfg.Logging.File.Enabled, svc.containerSvc, svc.runtime, log) | ||
| 543 | 547 | ||||
| 548 | + | initPreviewService(ctx, cfg, svc, log) | |||
| 549 | + | ||||
| 544 | // Create admin handler for admin API | 550 | // Create admin handler for admin API | ||
| 545 | - | svc.adminHandler = admin.NewHandler( | 551 | + | svc.adminHandler = admin.NewHandler(admin.HandlerDeps{ |
| 546 | - | svc.configSvc, | 552 | + | ConfigSvc: svc.configSvc, |
| 547 | - | svc.authSvc, | 553 | + | AuthSvc: svc.authSvc, |
| 548 | - | svc.containerSvc, | 554 | + | ContainerSvc: svc.containerSvc, |
| 549 | - | svc.healthSvc, | 555 | + | HealthSvc: svc.healthSvc, |
| 550 | - | svc.secretSvc, | 556 | + | SecretSvc: svc.secretSvc, |
| 551 | - | svc.logSvc, | 557 | + | LogSvc: svc.logSvc, |
| 552 | - | svc.registrySvc, | 558 | + | RegistrySvc: svc.registrySvc, |
| 553 | - | svc.eventBus, | 559 | + | EventBus: svc.eventBus, |
| 554 | - | log, | 560 | + | Log: log, |
| 555 | - | svc.backupSvc, | 561 | + | BackupSvc: svc.backupSvc, |
| 556 | - | svc.imageSvc, | 562 | + | PreviewSvc: svc.previewService, |
| 557 | - | ).WithVolumeService(svc.volumeSvc) | 563 | + | ImageSvc: svc.imageSvc, |
| 564 | + | VolumeSvc: svc.volumeSvc, | |||
| 565 | + | }) | |||
| 558 | 566 | ||||
| 559 | return svc, nil | 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 | // injectTelemetryMetrics creates and injects OTel metrics into services when | 599 | // injectTelemetryMetrics creates and injects OTel metrics into services when | ||
| 563 | // telemetry is enabled. Skipped otherwise to avoid unnecessary allocations. | 600 | // telemetry is enabled. Skipped otherwise to avoid unnecessary allocations. | ||
| 564 | func injectTelemetryMetrics(cfg Config, svc *services, log zerowrap.Logger) { | 601 | func injectTelemetryMetrics(cfg Config, svc *services, log zerowrap.Logger) { | ||
| @@ -1349,8 +1386,19 @@ func registerEventHandlers(ctx context.Context, svc *services, cfg Config) (func | |||||
| 1349 | // Auto-route handler for creating routes from image labels | 1386 | // Auto-route handler for creating routes from image labels | ||
| 1350 | autoRouteHandler := container.NewAutoRouteHandler(ctx, svc.configSvc, svc.containerSvc, svc.blobStorage, cfg.Server.RegistryDomain). | 1387 | autoRouteHandler := container.NewAutoRouteHandler(ctx, svc.configSvc, svc.containerSvc, svc.blobStorage, cfg.Server.RegistryDomain). | ||
| 1351 | WithEnvExtractor(svc.runtime, svc.envDir) | 1388 | WithEnvExtractor(svc.runtime, svc.envDir) | ||
| 1352 | - | if err := svc.eventBus.Subscribe(autoRouteHandler); err != nil { | 1389 | + | |
| 1353 | - | return nil, fmt.Errorf("failed to subscribe auto-route handler: %w", err) | 1390 | + | // Preview handler for creating preview environments from tagged images |
| 1391 | + | autoPreviewHandler := preview.NewAutoPreviewHandler( | |||
| 1392 | + | ctx, | |||
| 1393 | + | svc.configSvc, | |||
| 1394 | + | svc.blobStorage, | |||
| 1395 | + | svc.previewService, | |||
| 1396 | + | ) | |||
| 1397 | + | ||||
| 1398 | + | // Dispatcher routes image push events to either auto-route or preview handler | |||
| 1399 | + | dispatcher := auto.NewImagePushDispatcher(svc.configSvc, autoRouteHandler, autoPreviewHandler) | |||
| 1400 | + | if err := svc.eventBus.Subscribe(dispatcher); err != nil { | |||
| 1401 | + | return nil, fmt.Errorf("subscribe image push dispatcher: %w", err) | |||
| 1354 | } | 1402 | } | ||
| 1355 | 1403 | ||||
| 1356 | configReloadHandler := container.NewConfigReloadHandler(ctx, svc.containerSvc, svc.configSvc) | 1404 | configReloadHandler := container.NewConfigReloadHandler(ctx, svc.containerSvc, svc.configSvc) | ||
M internal/boundaries/in/config.go +7 -0
| @@ -84,4 +84,11 @@ type ConfigService interface { | |||||
| 84 | GetAutoRouteAllowedDomains(ctx context.Context) ([]string, error) | 84 | GetAutoRouteAllowedDomains(ctx context.Context) ([]string, error) | ||
| 85 | AddAutoRouteAllowedDomain(ctx context.Context, pattern string) error | 85 | AddAutoRouteAllowedDomain(ctx context.Context, pattern string) error | ||
| 86 | RemoveAutoRouteAllowedDomain(ctx context.Context, pattern string) error | 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 | return _c | 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 | // GetAttachmentsFor provides a mock function for the type MockConfigService | 435 | // GetAttachmentsFor provides a mock function for the type MockConfigService | ||
| 390 | func (_mock *MockConfigService) GetAttachmentsFor(ctx context.Context, domainOrGroup string) ([]string, error) { | 436 | func (_mock *MockConfigService) GetAttachmentsFor(ctx context.Context, domainOrGroup string) ([]string, error) { | ||
| 391 | ret := _mock.Called(ctx, domainOrGroup) | 437 | ret := _mock.Called(ctx, domainOrGroup) | ||
| @@ -650,6 +696,96 @@ func (_c *MockConfigService_GetNetworkPrefix_Call) RunAndReturn(run func() strin | |||||
| 650 | return _c | 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 | // GetRegistryDomain provides a mock function for the type MockConfigService | 789 | // GetRegistryDomain provides a mock function for the type MockConfigService | ||
| 654 | func (_mock *MockConfigService) GetRegistryDomain() string { | 790 | func (_mock *MockConfigService) GetRegistryDomain() string { | ||
| 655 | ret := _mock.Called() | 791 | ret := _mock.Called() | ||
| @@ -903,6 +1039,50 @@ func (_c *MockConfigService_GetServerPort_Call) RunAndReturn(run func() int) *Mo | |||||
| 903 | return _c | 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 | // IsAutoRouteEnabled provides a mock function for the type MockConfigService | 1086 | // IsAutoRouteEnabled provides a mock function for the type MockConfigService | ||
| 907 | func (_mock *MockConfigService) IsAutoRouteEnabled() bool { | 1087 | func (_mock *MockConfigService) IsAutoRouteEnabled() bool { | ||
| 908 | ret := _mock.Called() | 1088 | ret := _mock.Called() | ||
| @@ -991,6 +1171,50 @@ func (_c *MockConfigService_IsNetworkIsolationEnabled_Call) RunAndReturn(run fun | |||||
| 991 | return _c | 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 | // Load provides a mock function for the type MockConfigService | 1218 | // Load provides a mock function for the type MockConfigService | ||
| 995 | func (_mock *MockConfigService) Load(ctx context.Context) error { | 1219 | func (_mock *MockConfigService) Load(ctx context.Context) error { | ||
| 996 | ret := _mock.Called(ctx) | 1220 | ret := _mock.Called(ctx) | ||
A internal/boundaries/out/previewstore.go +13 -0
| @@ -0,0 +1,13 @@ | |||||
| 1 | + | package out | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/gordon/internal/domain" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | // PreviewStore persists active preview routes. | |||
| 10 | + | type PreviewStore interface { | |||
| 11 | + | Load(ctx context.Context) ([]domain.PreviewRoute, error) | |||
| 12 | + | Save(ctx context.Context, previews []domain.PreviewRoute) error | |||
| 13 | + | } | |||
A internal/domain/auto.go +23 -0
| @@ -0,0 +1,23 @@ | |||||
| 1 | + | package domain | |||
| 2 | + | ||||
| 3 | + | import "time" | |||
| 4 | + | ||||
| 5 | + | // AutoConfig holds the unified auto-route and auto-preview configuration. | |||
| 6 | + | type AutoConfig struct { | |||
| 7 | + | Enabled bool | |||
| 8 | + | AllowedDomains []string | |||
| 9 | + | Route AutoRouteConfig | |||
| 10 | + | Preview PreviewConfig | |||
| 11 | + | } | |||
| 12 | + | ||||
| 13 | + | // AutoRouteConfig holds auto-route specific settings (reserved for future use). | |||
| 14 | + | type AutoRouteConfig struct{} | |||
| 15 | + | ||||
| 16 | + | // PreviewConfig holds auto-preview specific settings. | |||
| 17 | + | type PreviewConfig struct { | |||
| 18 | + | Enabled bool | |||
| 19 | + | TTL time.Duration | |||
| 20 | + | Separator string | |||
| 21 | + | TagPatterns []string | |||
| 22 | + | DataCopy bool | |||
| 23 | + | } | |||
M internal/domain/container.go +13 -12
| @@ -45,18 +45,19 @@ type RouteInfo struct { | |||||
| 45 | 45 | ||||
| 46 | // ContainerConfig holds configuration for creating a container. | 46 | // ContainerConfig holds configuration for creating a container. | ||
| 47 | type ContainerConfig struct { | 47 | type ContainerConfig struct { | ||
| 48 | - | Image string | 48 | + | Image string |
| 49 | - | Name string | 49 | + | Name string |
| 50 | - | Env []string | 50 | + | Env []string |
| 51 | - | Ports []int | 51 | + | Ports []int |
| 52 | - | Labels map[string]string | 52 | + | Labels map[string]string |
| 53 | - | WorkingDir string | 53 | + | WorkingDir string |
| 54 | - | Cmd []string | 54 | + | Cmd []string |
| 55 | - | AutoRemove bool | 55 | + | AutoRemove bool |
| 56 | - | Volumes map[string]string // map[containerPath]volumeName | 56 | + | Volumes map[string]string // map[containerPath]volumeName |
| 57 | - | NetworkMode string // Network to join | 57 | + | ReadOnlyVolumes map[string]string // containerPath -> volumeName (mounted read-only) |
| 58 | - | Hostname string // Container hostname for DNS | 58 | + | NetworkMode string // Network to join |
| 59 | - | Aliases []string // Additional network aliases | 59 | + | Hostname string // Container hostname for DNS |
| 60 | + | Aliases []string // Additional network aliases | |||
| 60 | } | 61 | } | ||
| 61 | 62 | ||||
| 62 | // ContainerStatus represents the current state of a container. | 63 | // ContainerStatus represents the current state of a container. | ||
M internal/domain/errors.go +3 -0
| @@ -40,6 +40,9 @@ var ( | |||||
| 40 | ErrVolumeNotFound = errors.New("volume not found") | 40 | ErrVolumeNotFound = errors.New("volume not found") | ||
| 41 | ErrVolumeExists = errors.New("volume already exists") | 41 | ErrVolumeExists = errors.New("volume already exists") | ||
| 42 | 42 | ||||
| 43 | + | // Preview errors | |||
| 44 | + | ErrPreviewNotFound = errors.New("preview not found") | |||
| 45 | + | ||||
| 43 | // Config errors | 46 | // Config errors | ||
| 44 | ErrConfigNotFound = errors.New("configuration not found") | 47 | ErrConfigNotFound = errors.New("configuration not found") | ||
| 45 | ErrInvalidConfig = errors.New("invalid configuration") | 48 | ErrInvalidConfig = errors.New("invalid configuration") | ||
A internal/domain/preview.go +21 -0
| @@ -0,0 +1,21 @@ | |||||
| 1 | + | package domain | |||
| 2 | + | ||||
| 3 | + | import "time" | |||
| 4 | + | ||||
| 5 | + | // PreviewRoute represents an ephemeral preview environment. | |||
| 6 | + | type PreviewRoute struct { | |||
| 7 | + | Domain string `json:"domain"` | |||
| 8 | + | Image string `json:"image"` | |||
| 9 | + | BaseRoute string `json:"base_route"` | |||
| 10 | + | Name string `json:"name"` | |||
| 11 | + | CreatedAt time.Time `json:"created_at"` | |||
| 12 | + | ExpiresAt time.Time `json:"expires_at"` | |||
| 13 | + | HTTPS bool `json:"https"` | |||
| 14 | + | Volumes []string `json:"volumes"` | |||
| 15 | + | Containers []string `json:"containers"` | |||
| 16 | + | } | |||
| 17 | + | ||||
| 18 | + | // IsExpired returns true if the preview has exceeded its TTL. | |||
| 19 | + | func (p PreviewRoute) IsExpired(now time.Time) bool { | |||
| 20 | + | return now.After(p.ExpiresAt) | |||
| 21 | + | } | |||
A internal/domain/preview_test.go +27 -0
| @@ -0,0 +1,27 @@ | |||||
| 1 | + | package domain | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | "time" | |||
| 6 | + | ||||
| 7 | + | "github.com/stretchr/testify/assert" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | func TestPreviewRoute_IsExpired(t *testing.T) { | |||
| 11 | + | now := time.Now() | |||
| 12 | + | tests := []struct { | |||
| 13 | + | name string | |||
| 14 | + | expiresAt time.Time | |||
| 15 | + | want bool | |||
| 16 | + | }{ | |||
| 17 | + | {"expired", now.Add(-1 * time.Hour), true}, | |||
| 18 | + | {"not expired", now.Add(1 * time.Hour), false}, | |||
| 19 | + | {"just expired", now.Add(-1 * time.Second), true}, | |||
| 20 | + | } | |||
| 21 | + | for _, tt := range tests { | |||
| 22 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 23 | + | p := PreviewRoute{ExpiresAt: tt.expiresAt} | |||
| 24 | + | assert.Equal(t, tt.want, p.IsExpired(now)) | |||
| 25 | + | }) | |||
| 26 | + | } | |||
| 27 | + | } | |||
A internal/usecase/auto/dispatcher.go +68 -0
| @@ -0,0 +1,68 @@ | |||||
| 1 | + | package auto | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "path/filepath" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/zerowrap" | |||
| 8 | + | ||||
| 9 | + | "github.com/bnema/gordon/internal/boundaries/out" | |||
| 10 | + | "github.com/bnema/gordon/internal/domain" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | // AutoConfigProvider exposes unified auto config to the dispatcher. | |||
| 14 | + | type AutoConfigProvider interface { | |||
| 15 | + | IsAutoEnabled() bool | |||
| 16 | + | IsPreviewEnabled() bool | |||
| 17 | + | GetPreviewTagPatterns() []string | |||
| 18 | + | GetPreviewConfig() domain.PreviewConfig | |||
| 19 | + | GetAllowedDomains() []string | |||
| 20 | + | } | |||
| 21 | + | ||||
| 22 | + | // ImagePushDispatcher classifies image push events and delegates | |||
| 23 | + | // to either the auto-route handler or the auto-preview handler. | |||
| 24 | + | type ImagePushDispatcher struct { | |||
| 25 | + | config AutoConfigProvider | |||
| 26 | + | routeHandler out.EventHandler | |||
| 27 | + | previewHandler out.EventHandler | |||
| 28 | + | } | |||
| 29 | + | ||||
| 30 | + | func NewImagePushDispatcher(config AutoConfigProvider, routeHandler, previewHandler out.EventHandler) *ImagePushDispatcher { | |||
| 31 | + | return &ImagePushDispatcher{ | |||
| 32 | + | config: config, | |||
| 33 | + | routeHandler: routeHandler, | |||
| 34 | + | previewHandler: previewHandler, | |||
| 35 | + | } | |||
| 36 | + | } | |||
| 37 | + | ||||
| 38 | + | func (d *ImagePushDispatcher) CanHandle(t domain.EventType) bool { | |||
| 39 | + | return t == domain.EventImagePushed | |||
| 40 | + | } | |||
| 41 | + | ||||
| 42 | + | func (d *ImagePushDispatcher) Handle(ctx context.Context, event domain.Event) error { | |||
| 43 | + | log := zerowrap.FromCtx(ctx) | |||
| 44 | + | ||||
| 45 | + | payload, ok := event.Data.(domain.ImagePushedPayload) | |||
| 46 | + | if !ok { | |||
| 47 | + | log.Warn().Str("event_type", string(event.Type)).Msg("dispatcher received event with unexpected payload type; forwarding to route handler") | |||
| 48 | + | return d.routeHandler.Handle(ctx, event) | |||
| 49 | + | } | |||
| 50 | + | ||||
| 51 | + | if d.config.IsPreviewEnabled() && d.matchesTagPatterns(payload.Reference) { | |||
| 52 | + | return d.previewHandler.Handle(ctx, event) | |||
| 53 | + | } | |||
| 54 | + | return d.routeHandler.Handle(ctx, event) | |||
| 55 | + | } | |||
| 56 | + | ||||
| 57 | + | func (d *ImagePushDispatcher) matchesTagPatterns(reference string) bool { | |||
| 58 | + | for _, pattern := range d.config.GetPreviewTagPatterns() { | |||
| 59 | + | matched, err := filepath.Match(pattern, reference) | |||
| 60 | + | if err != nil { | |||
| 61 | + | continue // skip malformed patterns | |||
| 62 | + | } | |||
| 63 | + | if matched { | |||
| 64 | + | return true | |||
| 65 | + | } | |||
| 66 | + | } | |||
| 67 | + | return false | |||
| 68 | + | } | |||
A internal/usecase/auto/dispatcher_test.go +91 -0
| @@ -0,0 +1,91 @@ | |||||
| 1 | + | package auto | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "testing" | |||
| 6 | + | ||||
| 7 | + | "github.com/stretchr/testify/assert" | |||
| 8 | + | ||||
| 9 | + | "github.com/bnema/gordon/internal/domain" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | type mockHandler struct { | |||
| 13 | + | called bool | |||
| 14 | + | } | |||
| 15 | + | ||||
| 16 | + | func (m *mockHandler) Handle(_ context.Context, _ domain.Event) error { | |||
| 17 | + | m.called = true | |||
| 18 | + | return nil | |||
| 19 | + | } | |||
| 20 | + | ||||
| 21 | + | func (m *mockHandler) CanHandle(t domain.EventType) bool { | |||
| 22 | + | return t == domain.EventImagePushed | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | type mockAutoConfig struct { | |||
| 26 | + | previewEnabled bool | |||
| 27 | + | tagPatterns []string | |||
| 28 | + | } | |||
| 29 | + | ||||
| 30 | + | func (m *mockAutoConfig) IsAutoEnabled() bool { return true } | |||
| 31 | + | func (m *mockAutoConfig) IsPreviewEnabled() bool { return m.previewEnabled } | |||
| 32 | + | func (m *mockAutoConfig) GetPreviewTagPatterns() []string { return m.tagPatterns } | |||
| 33 | + | func (m *mockAutoConfig) GetPreviewConfig() domain.PreviewConfig { return domain.PreviewConfig{} } | |||
| 34 | + | func (m *mockAutoConfig) GetAllowedDomains() []string { return nil } | |||
| 35 | + | ||||
| 36 | + | func TestDispatcher_PreviewTag_DelegatesToPreview(t *testing.T) { | |||
| 37 | + | route := &mockHandler{} | |||
| 38 | + | preview := &mockHandler{} | |||
| 39 | + | d := NewImagePushDispatcher( | |||
| 40 | + | &mockAutoConfig{previewEnabled: true, tagPatterns: []string{"preview-*"}}, | |||
| 41 | + | route, preview, | |||
| 42 | + | ) | |||
| 43 | + | event := domain.Event{ | |||
| 44 | + | Type: domain.EventImagePushed, | |||
| 45 | + | Data: domain.ImagePushedPayload{Name: "myapp", Reference: "preview-feat"}, | |||
| 46 | + | } | |||
| 47 | + | err := d.Handle(context.Background(), event) | |||
| 48 | + | assert.NoError(t, err) | |||
| 49 | + | assert.True(t, preview.called) | |||
| 50 | + | assert.False(t, route.called) | |||
| 51 | + | } | |||
| 52 | + | ||||
| 53 | + | func TestDispatcher_NormalTag_DelegatesToRoute(t *testing.T) { | |||
| 54 | + | route := &mockHandler{} | |||
| 55 | + | preview := &mockHandler{} | |||
| 56 | + | d := NewImagePushDispatcher( | |||
| 57 | + | &mockAutoConfig{previewEnabled: true, tagPatterns: []string{"preview-*"}}, | |||
| 58 | + | route, preview, | |||
| 59 | + | ) | |||
| 60 | + | event := domain.Event{ | |||
| 61 | + | Type: domain.EventImagePushed, | |||
| 62 | + | Data: domain.ImagePushedPayload{Name: "myapp", Reference: "v1.0.0"}, | |||
| 63 | + | } | |||
| 64 | + | err := d.Handle(context.Background(), event) | |||
| 65 | + | assert.NoError(t, err) | |||
| 66 | + | assert.True(t, route.called) | |||
| 67 | + | assert.False(t, preview.called) | |||
| 68 | + | } | |||
| 69 | + | ||||
| 70 | + | func TestDispatcher_PreviewDisabled_AlwaysRoute(t *testing.T) { | |||
| 71 | + | route := &mockHandler{} | |||
| 72 | + | preview := &mockHandler{} | |||
| 73 | + | d := NewImagePushDispatcher( | |||
| 74 | + | &mockAutoConfig{previewEnabled: false, tagPatterns: []string{"preview-*"}}, | |||
| 75 | + | route, preview, | |||
| 76 | + | ) | |||
| 77 | + | event := domain.Event{ | |||
| 78 | + | Type: domain.EventImagePushed, | |||
| 79 | + | Data: domain.ImagePushedPayload{Name: "myapp", Reference: "preview-feat"}, | |||
| 80 | + | } | |||
| 81 | + | err := d.Handle(context.Background(), event) | |||
| 82 | + | assert.NoError(t, err) | |||
| 83 | + | assert.True(t, route.called) | |||
| 84 | + | assert.False(t, preview.called) | |||
| 85 | + | } | |||
| 86 | + | ||||
| 87 | + | func TestDispatcher_CanHandle(t *testing.T) { | |||
| 88 | + | d := &ImagePushDispatcher{} | |||
| 89 | + | assert.True(t, d.CanHandle(domain.EventImagePushed)) | |||
| 90 | + | assert.False(t, d.CanHandle(domain.EventConfigReload)) | |||
| 91 | + | } | |||
A internal/usecase/auto/labels.go +119 -0
| @@ -0,0 +1,119 @@ | |||||
| 1 | + | package auto | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "encoding/json" | |||
| 6 | + | "fmt" | |||
| 7 | + | "io" | |||
| 8 | + | "strings" | |||
| 9 | + | ||||
| 10 | + | "github.com/bnema/gordon/internal/boundaries/out" | |||
| 11 | + | "github.com/bnema/gordon/internal/domain" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | // manifestSchema represents the relevant parts of an OCI/Docker manifest. | |||
| 15 | + | type manifestSchema struct { | |||
| 16 | + | SchemaVersion int `json:"schemaVersion"` | |||
| 17 | + | MediaType string `json:"mediaType"` | |||
| 18 | + | Config struct { | |||
| 19 | + | MediaType string `json:"mediaType"` | |||
| 20 | + | Digest string `json:"digest"` | |||
| 21 | + | Size int64 `json:"size"` | |||
| 22 | + | } `json:"config"` | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | // ParseConfigDigest extracts the config digest from a manifest. | |||
| 26 | + | func ParseConfigDigest(manifestData []byte) (string, error) { | |||
| 27 | + | var manifest manifestSchema | |||
| 28 | + | if err := json.Unmarshal(manifestData, &manifest); err != nil { | |||
| 29 | + | return "", err | |||
| 30 | + | } | |||
| 31 | + | ||||
| 32 | + | return manifest.Config.Digest, nil | |||
| 33 | + | } | |||
| 34 | + | ||||
| 35 | + | // imageConfig represents the relevant parts of an OCI/Docker image config. | |||
| 36 | + | type imageConfig struct { | |||
| 37 | + | Config struct { | |||
| 38 | + | Labels map[string]string `json:"Labels"` | |||
| 39 | + | } `json:"config"` | |||
| 40 | + | } | |||
| 41 | + | ||||
| 42 | + | // ParseImageLabels extracts Gordon labels from an image config blob. | |||
| 43 | + | func ParseImageLabels(configData []byte) (*domain.ImageLabels, error) { | |||
| 44 | + | var config imageConfig | |||
| 45 | + | if err := json.Unmarshal(configData, &config); err != nil { | |||
| 46 | + | return nil, err | |||
| 47 | + | } | |||
| 48 | + | ||||
| 49 | + | labels := &domain.ImageLabels{} | |||
| 50 | + | ||||
| 51 | + | if config.Config.Labels == nil { | |||
| 52 | + | return labels, nil | |||
| 53 | + | } | |||
| 54 | + | ||||
| 55 | + | // Extract gordon.* labels | |||
| 56 | + | if v, ok := config.Config.Labels[domain.LabelDomain]; ok { | |||
| 57 | + | labels.Domain = strings.TrimSpace(v) | |||
| 58 | + | } | |||
| 59 | + | ||||
| 60 | + | if v, ok := config.Config.Labels[domain.LabelDomains]; ok { | |||
| 61 | + | // Parse comma-separated domains | |||
| 62 | + | for _, d := range strings.Split(v, ",") { | |||
| 63 | + | d = strings.TrimSpace(d) | |||
| 64 | + | if d != "" { | |||
| 65 | + | labels.Domains = append(labels.Domains, d) | |||
| 66 | + | } | |||
| 67 | + | } | |||
| 68 | + | } | |||
| 69 | + | ||||
| 70 | + | if v, ok := config.Config.Labels[domain.LabelHealth]; ok { | |||
| 71 | + | labels.Health = strings.TrimSpace(v) | |||
| 72 | + | } | |||
| 73 | + | ||||
| 74 | + | for _, key := range []string{domain.LabelProxyPort, domain.LabelPort} { | |||
| 75 | + | if v, ok := config.Config.Labels[key]; ok { | |||
| 76 | + | labels.Port = strings.TrimSpace(v) | |||
| 77 | + | break | |||
| 78 | + | } | |||
| 79 | + | } | |||
| 80 | + | ||||
| 81 | + | if v, ok := config.Config.Labels[domain.LabelEnvFile]; ok { | |||
| 82 | + | labels.EnvFile = strings.TrimSpace(v) | |||
| 83 | + | } | |||
| 84 | + | ||||
| 85 | + | return labels, nil | |||
| 86 | + | } | |||
| 87 | + | ||||
| 88 | + | // ExtractLabels extracts Gordon labels from an image manifest by fetching the | |||
| 89 | + | // config blob from blobStorage and parsing it. It is the shared implementation | |||
| 90 | + | // used by both AutoRouteHandler and AutoPreviewHandler. | |||
| 91 | + | // ctx is accepted for future use when BlobStorage gains context support. | |||
| 92 | + | func ExtractLabels(ctx context.Context, manifestData []byte, blobStorage out.BlobStorage) (*domain.ImageLabels, error) { | |||
| 93 | + | configDigest, err := ParseConfigDigest(manifestData) | |||
| 94 | + | if err != nil { | |||
| 95 | + | return nil, fmt.Errorf("failed to parse manifest: %w", err) | |||
| 96 | + | } | |||
| 97 | + | ||||
| 98 | + | if configDigest == "" { | |||
| 99 | + | return nil, fmt.Errorf("no config digest found in manifest") | |||
| 100 | + | } | |||
| 101 | + | ||||
| 102 | + | reader, err := blobStorage.GetBlob(configDigest) | |||
| 103 | + | if err != nil { | |||
| 104 | + | return nil, fmt.Errorf("failed to get config blob: %w", err) | |||
| 105 | + | } | |||
| 106 | + | defer reader.Close() | |||
| 107 | + | ||||
| 108 | + | configData, err := io.ReadAll(reader) | |||
| 109 | + | if err != nil { | |||
| 110 | + | return nil, fmt.Errorf("failed to read config blob: %w", err) | |||
| 111 | + | } | |||
| 112 | + | ||||
| 113 | + | labels, err := ParseImageLabels(configData) | |||
| 114 | + | if err != nil { | |||
| 115 | + | return nil, fmt.Errorf("failed to parse config: %w", err) | |||
| 116 | + | } | |||
| 117 | + | ||||
| 118 | + | return labels, nil | |||
| 119 | + | } | |||
A internal/usecase/auto/labels_test.go +277 -0
| @@ -0,0 +1,277 @@ | |||||
| 1 | + | package auto | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "encoding/json" | |||
| 6 | + | "io" | |||
| 7 | + | "strings" | |||
| 8 | + | "testing" | |||
| 9 | + | ||||
| 10 | + | "github.com/stretchr/testify/assert" | |||
| 11 | + | "github.com/stretchr/testify/require" | |||
| 12 | + | ||||
| 13 | + | "github.com/bnema/gordon/internal/boundaries/out/mocks" | |||
| 14 | + | "github.com/bnema/gordon/internal/domain" | |||
| 15 | + | ) | |||
| 16 | + | ||||
| 17 | + | func TestParseConfigDigest(t *testing.T) { | |||
| 18 | + | tests := []struct { | |||
| 19 | + | name string | |||
| 20 | + | manifest map[string]any | |||
| 21 | + | expected string | |||
| 22 | + | wantErr bool | |||
| 23 | + | }{ | |||
| 24 | + | { | |||
| 25 | + | name: "valid manifest v2", | |||
| 26 | + | manifest: map[string]any{ | |||
| 27 | + | "schemaVersion": 2, | |||
| 28 | + | "mediaType": "application/vnd.docker.distribution.manifest.v2+json", | |||
| 29 | + | "config": map[string]any{ | |||
| 30 | + | "mediaType": "application/vnd.docker.container.image.v1+json", | |||
| 31 | + | "digest": "sha256:abc123", | |||
| 32 | + | "size": 1234, | |||
| 33 | + | }, | |||
| 34 | + | }, | |||
| 35 | + | expected: "sha256:abc123", | |||
| 36 | + | }, | |||
| 37 | + | { | |||
| 38 | + | name: "OCI manifest", | |||
| 39 | + | manifest: map[string]any{ | |||
| 40 | + | "schemaVersion": 2, | |||
| 41 | + | "mediaType": "application/vnd.oci.image.manifest.v1+json", | |||
| 42 | + | "config": map[string]any{ | |||
| 43 | + | "mediaType": "application/vnd.oci.image.config.v1+json", | |||
| 44 | + | "digest": "sha256:def456", | |||
| 45 | + | "size": 5678, | |||
| 46 | + | }, | |||
| 47 | + | }, | |||
| 48 | + | expected: "sha256:def456", | |||
| 49 | + | }, | |||
| 50 | + | { | |||
| 51 | + | name: "empty config digest", | |||
| 52 | + | manifest: map[string]any{ | |||
| 53 | + | "schemaVersion": 2, | |||
| 54 | + | "config": map[string]any{ | |||
| 55 | + | "digest": "", | |||
| 56 | + | }, | |||
| 57 | + | }, | |||
| 58 | + | expected: "", | |||
| 59 | + | }, | |||
| 60 | + | } | |||
| 61 | + | ||||
| 62 | + | for _, tt := range tests { | |||
| 63 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 64 | + | data, err := json.Marshal(tt.manifest) | |||
| 65 | + | require.NoError(t, err) | |||
| 66 | + | ||||
| 67 | + | result, err := ParseConfigDigest(data) | |||
| 68 | + | ||||
| 69 | + | if tt.wantErr { | |||
| 70 | + | assert.Error(t, err) | |||
| 71 | + | return | |||
| 72 | + | } | |||
| 73 | + | ||||
| 74 | + | require.NoError(t, err) | |||
| 75 | + | assert.Equal(t, tt.expected, result) | |||
| 76 | + | }) | |||
| 77 | + | } | |||
| 78 | + | } | |||
| 79 | + | ||||
| 80 | + | func TestParseConfigDigest_InvalidJSON(t *testing.T) { | |||
| 81 | + | _, err := ParseConfigDigest([]byte("invalid json")) | |||
| 82 | + | assert.Error(t, err) | |||
| 83 | + | } | |||
| 84 | + | ||||
| 85 | + | func TestParseImageLabels(t *testing.T) { | |||
| 86 | + | tests := []struct { | |||
| 87 | + | name string | |||
| 88 | + | config map[string]any | |||
| 89 | + | expected *domain.ImageLabels | |||
| 90 | + | }{ | |||
| 91 | + | { | |||
| 92 | + | name: "single domain label", | |||
| 93 | + | config: map[string]any{ | |||
| 94 | + | "config": map[string]any{ | |||
| 95 | + | "Labels": map[string]any{ | |||
| 96 | + | "gordon.domain": "app.example.com", | |||
| 97 | + | }, | |||
| 98 | + | }, | |||
| 99 | + | }, | |||
| 100 | + | expected: &domain.ImageLabels{ | |||
| 101 | + | Domain: "app.example.com", | |||
| 102 | + | }, | |||
| 103 | + | }, | |||
| 104 | + | { | |||
| 105 | + | name: "multiple domains label", | |||
| 106 | + | config: map[string]any{ | |||
| 107 | + | "config": map[string]any{ | |||
| 108 | + | "Labels": map[string]any{ | |||
| 109 | + | "gordon.domains": "app1.example.com, app2.example.com", | |||
| 110 | + | }, | |||
| 111 | + | }, | |||
| 112 | + | }, | |||
| 113 | + | expected: &domain.ImageLabels{ | |||
| 114 | + | Domains: []string{"app1.example.com", "app2.example.com"}, | |||
| 115 | + | }, | |||
| 116 | + | }, | |||
| 117 | + | { | |||
| 118 | + | name: "all gordon labels", | |||
| 119 | + | config: map[string]any{ | |||
| 120 | + | "config": map[string]any{ | |||
| 121 | + | "Labels": map[string]any{ | |||
| 122 | + | "gordon.domain": "app.example.com", | |||
| 123 | + | "gordon.health": "/healthz", | |||
| 124 | + | "gordon.proxy.port": "8080", | |||
| 125 | + | "gordon.env-file": ".env.prod", | |||
| 126 | + | }, | |||
| 127 | + | }, | |||
| 128 | + | }, | |||
| 129 | + | expected: &domain.ImageLabels{ | |||
| 130 | + | Domain: "app.example.com", | |||
| 131 | + | Health: "/healthz", | |||
| 132 | + | Port: "8080", | |||
| 133 | + | EnvFile: ".env.prod", | |||
| 134 | + | }, | |||
| 135 | + | }, | |||
| 136 | + | { | |||
| 137 | + | name: "no labels", | |||
| 138 | + | config: map[string]any{ | |||
| 139 | + | "config": map[string]any{ | |||
| 140 | + | "Labels": nil, | |||
| 141 | + | }, | |||
| 142 | + | }, | |||
| 143 | + | expected: &domain.ImageLabels{}, | |||
| 144 | + | }, | |||
| 145 | + | { | |||
| 146 | + | name: "no gordon labels", | |||
| 147 | + | config: map[string]any{ | |||
| 148 | + | "config": map[string]any{ | |||
| 149 | + | "Labels": map[string]any{ | |||
| 150 | + | "other.label": "value", | |||
| 151 | + | }, | |||
| 152 | + | }, | |||
| 153 | + | }, | |||
| 154 | + | expected: &domain.ImageLabels{}, | |||
| 155 | + | }, | |||
| 156 | + | { | |||
| 157 | + | name: "whitespace in labels", | |||
| 158 | + | config: map[string]any{ | |||
| 159 | + | "config": map[string]any{ | |||
| 160 | + | "Labels": map[string]any{ | |||
| 161 | + | "gordon.domain": " app.example.com ", | |||
| 162 | + | }, | |||
| 163 | + | }, | |||
| 164 | + | }, | |||
| 165 | + | expected: &domain.ImageLabels{ | |||
| 166 | + | Domain: "app.example.com", | |||
| 167 | + | }, | |||
| 168 | + | }, | |||
| 169 | + | { | |||
| 170 | + | name: "deprecated port label fallback", | |||
| 171 | + | config: map[string]any{ | |||
| 172 | + | "config": map[string]any{ | |||
| 173 | + | "Labels": map[string]any{ | |||
| 174 | + | "gordon.port": "9090", | |||
| 175 | + | }, | |||
| 176 | + | }, | |||
| 177 | + | }, | |||
| 178 | + | expected: &domain.ImageLabels{ | |||
| 179 | + | Port: "9090", | |||
| 180 | + | }, | |||
| 181 | + | }, | |||
| 182 | + | { | |||
| 183 | + | name: "proxy.port takes precedence over port", | |||
| 184 | + | config: map[string]any{ | |||
| 185 | + | "config": map[string]any{ | |||
| 186 | + | "Labels": map[string]any{ | |||
| 187 | + | "gordon.proxy.port": "8080", | |||
| 188 | + | "gordon.port": "9090", | |||
| 189 | + | }, | |||
| 190 | + | }, | |||
| 191 | + | }, | |||
| 192 | + | expected: &domain.ImageLabels{ | |||
| 193 | + | Port: "8080", | |||
| 194 | + | }, | |||
| 195 | + | }, | |||
| 196 | + | } | |||
| 197 | + | ||||
| 198 | + | for _, tt := range tests { | |||
| 199 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 200 | + | data, err := json.Marshal(tt.config) | |||
| 201 | + | require.NoError(t, err) | |||
| 202 | + | ||||
| 203 | + | result, err := ParseImageLabels(data) | |||
| 204 | + | require.NoError(t, err) | |||
| 205 | + | ||||
| 206 | + | assert.Equal(t, tt.expected.Domain, result.Domain) | |||
| 207 | + | assert.Equal(t, tt.expected.Domains, result.Domains) | |||
| 208 | + | assert.Equal(t, tt.expected.Health, result.Health) | |||
| 209 | + | assert.Equal(t, tt.expected.Port, result.Port) | |||
| 210 | + | assert.Equal(t, tt.expected.EnvFile, result.EnvFile) | |||
| 211 | + | }) | |||
| 212 | + | } | |||
| 213 | + | } | |||
| 214 | + | ||||
| 215 | + | func TestParseImageLabels_InvalidJSON(t *testing.T) { | |||
| 216 | + | _, err := ParseImageLabels([]byte("invalid json")) | |||
| 217 | + | assert.Error(t, err) | |||
| 218 | + | } | |||
| 219 | + | ||||
| 220 | + | func TestExtractLabels(t *testing.T) { | |||
| 221 | + | ctx := context.Background() | |||
| 222 | + | ||||
| 223 | + | manifest := map[string]any{ | |||
| 224 | + | "schemaVersion": 2, | |||
| 225 | + | "config": map[string]any{ | |||
| 226 | + | "digest": "sha256:configdigest123", | |||
| 227 | + | }, | |||
| 228 | + | } | |||
| 229 | + | manifestData, err := json.Marshal(manifest) | |||
| 230 | + | require.NoError(t, err) | |||
| 231 | + | ||||
| 232 | + | imageConf := map[string]any{ | |||
| 233 | + | "config": map[string]any{ | |||
| 234 | + | "Labels": map[string]any{ | |||
| 235 | + | "gordon.domain": "app.example.com", | |||
| 236 | + | "gordon.health": "/healthz", | |||
| 237 | + | }, | |||
| 238 | + | }, | |||
| 239 | + | } | |||
| 240 | + | configData, err := json.Marshal(imageConf) | |||
| 241 | + | require.NoError(t, err) | |||
| 242 | + | ||||
| 243 | + | blobStorage := mocks.NewMockBlobStorage(t) | |||
| 244 | + | blobStorage.EXPECT().GetBlob("sha256:configdigest123").Return(io.NopCloser(strings.NewReader(string(configData))), nil) | |||
| 245 | + | ||||
| 246 | + | labels, err := ExtractLabels(ctx, manifestData, blobStorage) | |||
| 247 | + | require.NoError(t, err) | |||
| 248 | + | assert.Equal(t, "app.example.com", labels.Domain) | |||
| 249 | + | assert.Equal(t, "/healthz", labels.Health) | |||
| 250 | + | } | |||
| 251 | + | ||||
| 252 | + | func TestExtractLabels_InvalidManifest(t *testing.T) { | |||
| 253 | + | ctx := context.Background() | |||
| 254 | + | blobStorage := mocks.NewMockBlobStorage(t) | |||
| 255 | + | ||||
| 256 | + | _, err := ExtractLabels(ctx, []byte("bad json"), blobStorage) | |||
| 257 | + | assert.Error(t, err) | |||
| 258 | + | } | |||
| 259 | + | ||||
| 260 | + | func TestExtractLabels_EmptyConfigDigest(t *testing.T) { | |||
| 261 | + | ctx := context.Background() | |||
| 262 | + | ||||
| 263 | + | manifest := map[string]any{ | |||
| 264 | + | "schemaVersion": 2, | |||
| 265 | + | "config": map[string]any{ | |||
| 266 | + | "digest": "", | |||
| 267 | + | }, | |||
| 268 | + | } | |||
| 269 | + | manifestData, err := json.Marshal(manifest) | |||
| 270 | + | require.NoError(t, err) | |||
| 271 | + | ||||
| 272 | + | blobStorage := mocks.NewMockBlobStorage(t) | |||
| 273 | + | ||||
| 274 | + | _, err = ExtractLabels(ctx, manifestData, blobStorage) | |||
| 275 | + | assert.Error(t, err) | |||
| 276 | + | assert.Contains(t, err.Error(), "no config digest") | |||
| 277 | + | } | |||
A internal/usecase/auto/preview/handler.go +111 -0
| @@ -0,0 +1,111 @@ | |||||
| 1 | + | package preview | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "fmt" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/zerowrap" | |||
| 8 | + | ||||
| 9 | + | "github.com/bnema/gordon/internal/boundaries/out" | |||
| 10 | + | "github.com/bnema/gordon/internal/domain" | |||
| 11 | + | "github.com/bnema/gordon/internal/usecase/auto" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | // AutoPreviewHandler processes EventImagePushed for preview tag patterns. | |||
| 15 | + | type AutoPreviewHandler struct { | |||
| 16 | + | serviceCtx context.Context | |||
| 17 | + | config auto.AutoConfigProvider | |||
| 18 | + | blobStorage out.BlobStorage | |||
| 19 | + | previewService *Service | |||
| 20 | + | } | |||
| 21 | + | ||||
| 22 | + | func NewAutoPreviewHandler( | |||
| 23 | + | serviceCtx context.Context, | |||
| 24 | + | config auto.AutoConfigProvider, | |||
| 25 | + | blobStorage out.BlobStorage, | |||
| 26 | + | previewService *Service, | |||
| 27 | + | ) *AutoPreviewHandler { | |||
| 28 | + | return &AutoPreviewHandler{ | |||
| 29 | + | serviceCtx: serviceCtx, | |||
| 30 | + | config: config, | |||
| 31 | + | blobStorage: blobStorage, | |||
| 32 | + | previewService: previewService, | |||
| 33 | + | } | |||
| 34 | + | } | |||
| 35 | + | ||||
| 36 | + | func (h *AutoPreviewHandler) CanHandle(t domain.EventType) bool { | |||
| 37 | + | return t == domain.EventImagePushed | |||
| 38 | + | } | |||
| 39 | + | ||||
| 40 | + | func (h *AutoPreviewHandler) Handle(ctx context.Context, event domain.Event) error { | |||
| 41 | + | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | |||
| 42 | + | zerowrap.FieldLayer: "usecase", | |||
| 43 | + | zerowrap.FieldHandler: "AutoPreviewHandler", | |||
| 44 | + | zerowrap.FieldEvent: string(event.Type), | |||
| 45 | + | "event_id": event.ID, | |||
| 46 | + | }) | |||
| 47 | + | log := zerowrap.FromCtx(ctx) | |||
| 48 | + | ||||
| 49 | + | payload, ok := event.Data.(domain.ImagePushedPayload) | |||
| 50 | + | if !ok { | |||
| 51 | + | log.Debug().Str("actual_type", fmt.Sprintf("%T", event.Data)).Msg("unexpected event data type, skipping") | |||
| 52 | + | return nil | |||
| 53 | + | } | |||
| 54 | + | ||||
| 55 | + | // Extract labels from manifest | |||
| 56 | + | labels, err := auto.ExtractLabels(ctx, payload.Manifest, h.blobStorage) | |||
| 57 | + | if err != nil { | |||
| 58 | + | log.Debug().Err(err).Msg("failed to extract labels, skipping preview handler") | |||
| 59 | + | return nil | |||
| 60 | + | } | |||
| 61 | + | ||||
| 62 | + | previewConfig := h.config.GetPreviewConfig() | |||
| 63 | + | previewName := ExtractPreviewName(payload.Reference, previewConfig.TagPatterns) | |||
| 64 | + | ||||
| 65 | + | // Resolve base route from labels | |||
| 66 | + | domains := collectDomains(labels) | |||
| 67 | + | if len(domains) == 0 { | |||
| 68 | + | log.Debug().Str("image", payload.Name).Msg("no gordon.domain label found, skipping preview handler") | |||
| 69 | + | return nil | |||
| 70 | + | } | |||
| 71 | + | baseRouteDomain := domains[0] | |||
| 72 | + | ||||
| 73 | + | // Validate allowlist | |||
| 74 | + | allowedDomains := h.config.GetAllowedDomains() | |||
| 75 | + | previewDomain, err := GeneratePreviewDomain(baseRouteDomain, previewName, previewConfig.Separator) | |||
| 76 | + | if err != nil { | |||
| 77 | + | log.Debug().Err(err).Str("base_domain", baseRouteDomain).Msg("failed to generate preview domain") | |||
| 78 | + | return nil | |||
| 79 | + | } | |||
| 80 | + | if !auto.MatchesDomainAllowlist(previewDomain, allowedDomains) { | |||
| 81 | + | log.Debug().Str("domain", previewDomain).Strs("patterns", allowedDomains).Msg("preview domain not in allowlist, skipping") | |||
| 82 | + | return nil | |||
| 83 | + | } | |||
| 84 | + | ||||
| 85 | + | imageName := payload.Name + ":" + payload.Reference | |||
| 86 | + | ||||
| 87 | + | // Launch async — volume cloning may exceed event bus timeout | |||
| 88 | + | go func() { | |||
| 89 | + | if err := h.previewService.CreatePreview(h.serviceCtx, CreatePreviewRequest{ | |||
| 90 | + | Name: previewName, | |||
| 91 | + | Domain: previewDomain, | |||
| 92 | + | BaseRoute: baseRouteDomain, | |||
| 93 | + | Image: imageName, | |||
| 94 | + | HTTPS: true, | |||
| 95 | + | PreviewConfig: previewConfig, | |||
| 96 | + | }); err != nil { | |||
| 97 | + | log.Error().Err(err).Str("preview", previewName).Msg("failed to create preview") | |||
| 98 | + | } | |||
| 99 | + | }() | |||
| 100 | + | ||||
| 101 | + | return nil | |||
| 102 | + | } | |||
| 103 | + | ||||
| 104 | + | func collectDomains(labels *domain.ImageLabels) []string { | |||
| 105 | + | var domains []string | |||
| 106 | + | if labels.Domain != "" { | |||
| 107 | + | domains = append(domains, labels.Domain) | |||
| 108 | + | } | |||
| 109 | + | domains = append(domains, labels.Domains...) | |||
| 110 | + | return domains | |||
| 111 | + | } | |||
A internal/usecase/auto/preview/handler_test.go +32 -0
| @@ -0,0 +1,32 @@ | |||||
| 1 | + | package preview | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/gordon/internal/domain" | |||
| 7 | + | "github.com/stretchr/testify/assert" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | func TestAutoPreviewHandler_CanHandle(t *testing.T) { | |||
| 11 | + | h := &AutoPreviewHandler{} | |||
| 12 | + | assert.True(t, h.CanHandle(domain.EventImagePushed)) | |||
| 13 | + | assert.False(t, h.CanHandle(domain.EventConfigReload)) | |||
| 14 | + | } | |||
| 15 | + | ||||
| 16 | + | func TestCollectDomains(t *testing.T) { | |||
| 17 | + | tests := []struct { | |||
| 18 | + | name string | |||
| 19 | + | labels *domain.ImageLabels | |||
| 20 | + | want []string | |||
| 21 | + | }{ | |||
| 22 | + | {"single domain", &domain.ImageLabels{Domain: "app.example.com"}, []string{"app.example.com"}}, | |||
| 23 | + | {"multiple domains", &domain.ImageLabels{Domains: []string{"a.com", "b.com"}}, []string{"a.com", "b.com"}}, | |||
| 24 | + | {"both", &domain.ImageLabels{Domain: "main.com", Domains: []string{"alt.com"}}, []string{"main.com", "alt.com"}}, | |||
| 25 | + | {"empty", &domain.ImageLabels{}, nil}, | |||
| 26 | + | } | |||
| 27 | + | for _, tt := range tests { | |||
| 28 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 29 | + | assert.Equal(t, tt.want, collectDomains(tt.labels)) | |||
| 30 | + | }) | |||
| 31 | + | } | |||
| 32 | + | } | |||
A internal/usecase/auto/preview/integration_test.go +93 -0
| @@ -0,0 +1,93 @@ | |||||
| 1 | + | package preview | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "testing" | |||
| 6 | + | "time" | |||
| 7 | + | ||||
| 8 | + | "github.com/stretchr/testify/assert" | |||
| 9 | + | "github.com/stretchr/testify/require" | |||
| 10 | + | ||||
| 11 | + | "github.com/bnema/gordon/internal/domain" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | func TestPreviewLifecycle_Integration(t *testing.T) { | |||
| 15 | + | store := &fakeStore{} | |||
| 16 | + | svc := NewService(store, 2*time.Second) | |||
| 17 | + | ||||
| 18 | + | ctx := context.Background() | |||
| 19 | + | ||||
| 20 | + | // Create | |||
| 21 | + | err := svc.CreatePreview(ctx, CreatePreviewRequest{ | |||
| 22 | + | Name: "test-feat", | |||
| 23 | + | Domain: "myapp--test-feat.example.com", | |||
| 24 | + | BaseRoute: "myapp.example.com", | |||
| 25 | + | Image: "myapp:preview-test-feat", | |||
| 26 | + | HTTPS: true, | |||
| 27 | + | PreviewConfig: domain.PreviewConfig{ | |||
| 28 | + | TTL: 2 * time.Second, | |||
| 29 | + | Separator: "--", | |||
| 30 | + | DataCopy: false, | |||
| 31 | + | }, | |||
| 32 | + | }) | |||
| 33 | + | require.NoError(t, err) | |||
| 34 | + | ||||
| 35 | + | // Verify created | |||
| 36 | + | all, err := svc.List(ctx) | |||
| 37 | + | require.NoError(t, err) | |||
| 38 | + | require.Len(t, all, 1) | |||
| 39 | + | assert.Equal(t, "test-feat", all[0].Name) | |||
| 40 | + | assert.Equal(t, "myapp--test-feat.example.com", all[0].Domain) | |||
| 41 | + | assert.Equal(t, "myapp.example.com", all[0].BaseRoute) | |||
| 42 | + | ||||
| 43 | + | // Extend | |||
| 44 | + | err = svc.Extend(ctx, "test-feat", 1*time.Hour) | |||
| 45 | + | require.NoError(t, err) | |||
| 46 | + | ||||
| 47 | + | p, err := svc.Get(ctx, "test-feat") | |||
| 48 | + | require.NoError(t, err) | |||
| 49 | + | assert.True(t, p.ExpiresAt.After(time.Now().Add(55*time.Minute))) | |||
| 50 | + | ||||
| 51 | + | // Delete | |||
| 52 | + | err = svc.Delete(ctx, "test-feat") | |||
| 53 | + | require.NoError(t, err) | |||
| 54 | + | ||||
| 55 | + | all, err = svc.List(ctx) | |||
| 56 | + | require.NoError(t, err) | |||
| 57 | + | assert.Empty(t, all) | |||
| 58 | + | } | |||
| 59 | + | ||||
| 60 | + | func TestPreviewLifecycle_CleanupExpired(t *testing.T) { | |||
| 61 | + | store := &fakeStore{} | |||
| 62 | + | svc := NewService(store, 1*time.Millisecond) | |||
| 63 | + | ||||
| 64 | + | ctx := context.Background() | |||
| 65 | + | ||||
| 66 | + | // Create with very short TTL | |||
| 67 | + | err := svc.CreatePreview(ctx, CreatePreviewRequest{ | |||
| 68 | + | Name: "short-lived", | |||
| 69 | + | Domain: "myapp--short-lived.example.com", | |||
| 70 | + | BaseRoute: "myapp.example.com", | |||
| 71 | + | Image: "myapp:preview-short-lived", | |||
| 72 | + | HTTPS: true, | |||
| 73 | + | PreviewConfig: domain.PreviewConfig{ | |||
| 74 | + | TTL: 1 * time.Millisecond, | |||
| 75 | + | Separator: "--", | |||
| 76 | + | DataCopy: false, | |||
| 77 | + | }, | |||
| 78 | + | }) | |||
| 79 | + | require.NoError(t, err) | |||
| 80 | + | ||||
| 81 | + | // Wait for expiry | |||
| 82 | + | time.Sleep(10 * time.Millisecond) | |||
| 83 | + | ||||
| 84 | + | // Cleanup should find it | |||
| 85 | + | expired := svc.CleanupExpired(ctx) | |||
| 86 | + | assert.Len(t, expired, 1) | |||
| 87 | + | assert.Equal(t, "short-lived", expired[0].Name) | |||
| 88 | + | ||||
| 89 | + | // Should be gone | |||
| 90 | + | all, err := svc.List(ctx) | |||
| 91 | + | require.NoError(t, err) | |||
| 92 | + | assert.Empty(t, all) | |||
| 93 | + | } | |||
A internal/usecase/auto/preview/naming.go +75 -0
| @@ -0,0 +1,75 @@ | |||||
| 1 | + | package preview | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "fmt" | |||
| 5 | + | "path/filepath" | |||
| 6 | + | "strings" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | // ExtractPreviewName strips the matched pattern prefix from a tag. | |||
| 10 | + | // Only simple trailing-wildcard patterns like "preview-*" or "pr-*" are supported. | |||
| 11 | + | // Complex globs (e.g., "pr-*-test", "*-preview") are not supported. | |||
| 12 | + | // "preview-login-redesign" with pattern "preview-*" → "login-redesign" | |||
| 13 | + | func ExtractPreviewName(tag string, patterns []string) string { | |||
| 14 | + | for _, pattern := range patterns { | |||
| 15 | + | matched, err := filepath.Match(pattern, tag) | |||
| 16 | + | if err != nil { | |||
| 17 | + | continue // skip malformed patterns | |||
| 18 | + | } | |||
| 19 | + | if matched { | |||
| 20 | + | prefix := strings.TrimSuffix(pattern, "*") | |||
| 21 | + | return strings.TrimPrefix(tag, prefix) | |||
| 22 | + | } | |||
| 23 | + | } | |||
| 24 | + | return tag | |||
| 25 | + | } | |||
| 26 | + | ||||
| 27 | + | // GeneratePreviewDomain creates the preview domain from a base route domain. | |||
| 28 | + | // "myapp.example.com" + "login" + "--" → "myapp--login.example.com" | |||
| 29 | + | func GeneratePreviewDomain(baseRoute, previewName, separator string) (string, error) { | |||
| 30 | + | dot := strings.Index(baseRoute, ".") | |||
| 31 | + | if dot < 0 { | |||
| 32 | + | return "", fmt.Errorf("invalid base route domain: %s", baseRoute) | |||
| 33 | + | } | |||
| 34 | + | app := baseRoute[:dot] | |||
| 35 | + | rest := baseRoute[dot:] // includes leading dot | |||
| 36 | + | return app + separator + previewName + rest, nil | |||
| 37 | + | } | |||
| 38 | + | ||||
| 39 | + | // SanitizeBranchName converts a git branch name to a valid DNS-safe preview name. | |||
| 40 | + | // "feat/login-redesign" → "login-redesign" | |||
| 41 | + | func SanitizeBranchName(branch string) string { | |||
| 42 | + | branch = strings.ToLower(branch) | |||
| 43 | + | for _, prefix := range []string{"feat/", "fix/", "feature/", "hotfix/", "release/", "chore/"} { | |||
| 44 | + | if strings.HasPrefix(branch, prefix) { | |||
| 45 | + | branch = strings.TrimPrefix(branch, prefix) | |||
| 46 | + | break | |||
| 47 | + | } | |||
| 48 | + | } | |||
| 49 | + | // Replace disallowed characters with dashes | |||
| 50 | + | branch = strings.ReplaceAll(branch, "/", "-") | |||
| 51 | + | branch = strings.ReplaceAll(branch, "_", "-") | |||
| 52 | + | branch = strings.ReplaceAll(branch, ".", "-") | |||
| 53 | + | ||||
| 54 | + | // Remove any remaining non DNS-safe characters | |||
| 55 | + | var result strings.Builder | |||
| 56 | + | for _, c := range branch { | |||
| 57 | + | if (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-' { | |||
| 58 | + | result.WriteRune(c) | |||
| 59 | + | } | |||
| 60 | + | } | |||
| 61 | + | branch = result.String() | |||
| 62 | + | ||||
| 63 | + | // Collapse consecutive dashes and trim | |||
| 64 | + | for strings.Contains(branch, "--") { | |||
| 65 | + | branch = strings.ReplaceAll(branch, "--", "-") | |||
| 66 | + | } | |||
| 67 | + | branch = strings.Trim(branch, "-") | |||
| 68 | + | ||||
| 69 | + | // Truncate to 63 chars (DNS label limit) | |||
| 70 | + | if len(branch) > 63 { | |||
| 71 | + | branch = branch[:63] | |||
| 72 | + | branch = strings.TrimRight(branch, "-") | |||
| 73 | + | } | |||
| 74 | + | return branch | |||
| 75 | + | } | |||
A internal/usecase/auto/preview/naming_test.go +80 -0
| @@ -0,0 +1,80 @@ | |||||
| 1 | + | package preview | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "strings" | |||
| 5 | + | "testing" | |||
| 6 | + | ||||
| 7 | + | "github.com/stretchr/testify/assert" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | func TestExtractPreviewName(t *testing.T) { | |||
| 11 | + | tests := []struct { | |||
| 12 | + | name string | |||
| 13 | + | tag string | |||
| 14 | + | patterns []string | |||
| 15 | + | want string | |||
| 16 | + | }{ | |||
| 17 | + | {"simple prefix", "preview-login", []string{"preview-*"}, "login"}, | |||
| 18 | + | {"pr prefix", "pr-42", []string{"pr-*"}, "42"}, | |||
| 19 | + | {"multi-word", "preview-login-redesign", []string{"preview-*"}, "login-redesign"}, | |||
| 20 | + | {"second pattern matches", "pr-99", []string{"preview-*", "pr-*"}, "99"}, | |||
| 21 | + | {"no match", "v1.0.0", []string{"preview-*"}, "v1.0.0"}, | |||
| 22 | + | } | |||
| 23 | + | for _, tt := range tests { | |||
| 24 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 25 | + | assert.Equal(t, tt.want, ExtractPreviewName(tt.tag, tt.patterns)) | |||
| 26 | + | }) | |||
| 27 | + | } | |||
| 28 | + | } | |||
| 29 | + | ||||
| 30 | + | func TestGeneratePreviewDomain(t *testing.T) { | |||
| 31 | + | tests := []struct { | |||
| 32 | + | name string | |||
| 33 | + | baseRoute string | |||
| 34 | + | previewName string | |||
| 35 | + | separator string | |||
| 36 | + | want string | |||
| 37 | + | wantErr bool | |||
| 38 | + | }{ | |||
| 39 | + | {"flat default", "myapp.example.com", "login", "--", "myapp--login.example.com", false}, | |||
| 40 | + | {"flat with dashes", "my-app.example.com", "feat-123", "--", "my-app--feat-123.example.com", false}, | |||
| 41 | + | {"multi-level base", "myapp.sub.example.com", "feat", "--", "myapp--feat.sub.example.com", false}, | |||
| 42 | + | {"invalid domain", "nodot", "feat", "--", "", true}, | |||
| 43 | + | } | |||
| 44 | + | for _, tt := range tests { | |||
| 45 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 46 | + | got, err := GeneratePreviewDomain(tt.baseRoute, tt.previewName, tt.separator) | |||
| 47 | + | if tt.wantErr { | |||
| 48 | + | assert.Error(t, err) | |||
| 49 | + | } else { | |||
| 50 | + | assert.NoError(t, err) | |||
| 51 | + | assert.Equal(t, tt.want, got) | |||
| 52 | + | } | |||
| 53 | + | }) | |||
| 54 | + | } | |||
| 55 | + | } | |||
| 56 | + | ||||
| 57 | + | func TestSanitizeBranchName(t *testing.T) { | |||
| 58 | + | tests := []struct { | |||
| 59 | + | name string | |||
| 60 | + | branch string | |||
| 61 | + | want string | |||
| 62 | + | }{ | |||
| 63 | + | {"feature branch", "feat/login-redesign", "login-redesign"}, | |||
| 64 | + | {"fix branch", "fix/bug-123", "bug-123"}, | |||
| 65 | + | {"simple", "main", "main"}, | |||
| 66 | + | {"nested", "feat/ui/button", "ui-button"}, | |||
| 67 | + | {"uppercase", "Feat/MyBranch", "mybranch"}, | |||
| 68 | + | {"dots and underscores", "feat/my_branch.name", "my-branch-name"}, | |||
| 69 | + | {"special chars", "feat/hello@world!", "helloworld"}, | |||
| 70 | + | {"long branch", strings.Repeat("a", 70), strings.Repeat("a", 63)}, | |||
| 71 | + | {"consecutive dashes", "feat/a--b--c", "a-b-c"}, | |||
| 72 | + | {"empty branch", "", ""}, | |||
| 73 | + | {"whitespace branch", " ", ""}, | |||
| 74 | + | } | |||
| 75 | + | for _, tt := range tests { | |||
| 76 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 77 | + | assert.Equal(t, tt.want, SanitizeBranchName(tt.branch)) | |||
| 78 | + | }) | |||
| 79 | + | } | |||
| 80 | + | } | |||
A internal/usecase/auto/preview/service.go +236 -0
| @@ -0,0 +1,236 @@ | |||||
| 1 | + | package preview | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "fmt" | |||
| 6 | + | "sync" | |||
| 7 | + | "time" | |||
| 8 | + | ||||
| 9 | + | "github.com/bnema/zerowrap" | |||
| 10 | + | ||||
| 11 | + | "github.com/bnema/gordon/internal/boundaries/out" | |||
| 12 | + | "github.com/bnema/gordon/internal/domain" | |||
| 13 | + | ) | |||
| 14 | + | ||||
| 15 | + | type nameLockEntry struct { | |||
| 16 | + | mu *sync.Mutex | |||
| 17 | + | refs int | |||
| 18 | + | } | |||
| 19 | + | ||||
| 20 | + | // Service manages preview lifecycle: CRUD, TTL, persistence. | |||
| 21 | + | type Service struct { | |||
| 22 | + | store out.PreviewStore | |||
| 23 | + | defaultTTL time.Duration | |||
| 24 | + | previews []domain.PreviewRoute | |||
| 25 | + | mu sync.RWMutex | |||
| 26 | + | nameLocks map[string]*nameLockEntry | |||
| 27 | + | nameLockMu sync.Mutex | |||
| 28 | + | } | |||
| 29 | + | ||||
| 30 | + | func NewService(store out.PreviewStore, defaultTTL time.Duration) *Service { | |||
| 31 | + | return &Service{ | |||
| 32 | + | store: store, | |||
| 33 | + | defaultTTL: defaultTTL, | |||
| 34 | + | nameLocks: make(map[string]*nameLockEntry), | |||
| 35 | + | } | |||
| 36 | + | } | |||
| 37 | + | ||||
| 38 | + | func (s *Service) Load(ctx context.Context) error { | |||
| 39 | + | previews, err := s.store.Load(ctx) | |||
| 40 | + | if err != nil { | |||
| 41 | + | return err | |||
| 42 | + | } | |||
| 43 | + | s.mu.Lock() | |||
| 44 | + | s.previews = previews | |||
| 45 | + | s.mu.Unlock() | |||
| 46 | + | return nil | |||
| 47 | + | } | |||
| 48 | + | ||||
| 49 | + | func (s *Service) Add(ctx context.Context, p domain.PreviewRoute) error { | |||
| 50 | + | s.mu.Lock() | |||
| 51 | + | s.previews = append(s.previews, p) | |||
| 52 | + | cp := make([]domain.PreviewRoute, len(s.previews)) | |||
| 53 | + | copy(cp, s.previews) | |||
| 54 | + | s.mu.Unlock() | |||
| 55 | + | return s.store.Save(ctx, cp) | |||
| 56 | + | } | |||
| 57 | + | ||||
| 58 | + | func (s *Service) Delete(ctx context.Context, name string) error { | |||
| 59 | + | s.mu.Lock() | |||
| 60 | + | found := false | |||
| 61 | + | filtered := make([]domain.PreviewRoute, 0, len(s.previews)) | |||
| 62 | + | for _, p := range s.previews { | |||
| 63 | + | if p.Name == name { | |||
| 64 | + | found = true | |||
| 65 | + | } else { | |||
| 66 | + | filtered = append(filtered, p) | |||
| 67 | + | } | |||
| 68 | + | } | |||
| 69 | + | if !found { | |||
| 70 | + | s.mu.Unlock() | |||
| 71 | + | return fmt.Errorf("preview %q: %w", name, domain.ErrPreviewNotFound) | |||
| 72 | + | } | |||
| 73 | + | s.previews = filtered | |||
| 74 | + | cp := make([]domain.PreviewRoute, len(s.previews)) | |||
| 75 | + | copy(cp, s.previews) | |||
| 76 | + | s.mu.Unlock() | |||
| 77 | + | return s.store.Save(ctx, cp) | |||
| 78 | + | } | |||
| 79 | + | ||||
| 80 | + | func (s *Service) Get(_ context.Context, name string) (*domain.PreviewRoute, error) { | |||
| 81 | + | s.mu.RLock() | |||
| 82 | + | defer s.mu.RUnlock() | |||
| 83 | + | for i := range s.previews { | |||
| 84 | + | if s.previews[i].Name == name { | |||
| 85 | + | p := s.previews[i] | |||
| 86 | + | return &p, nil | |||
| 87 | + | } | |||
| 88 | + | } | |||
| 89 | + | return nil, fmt.Errorf("preview %q: %w", name, domain.ErrPreviewNotFound) | |||
| 90 | + | } | |||
| 91 | + | ||||
| 92 | + | func (s *Service) List(_ context.Context) ([]domain.PreviewRoute, error) { | |||
| 93 | + | s.mu.RLock() | |||
| 94 | + | defer s.mu.RUnlock() | |||
| 95 | + | cp := make([]domain.PreviewRoute, len(s.previews)) | |||
| 96 | + | copy(cp, s.previews) | |||
| 97 | + | return cp, nil | |||
| 98 | + | } | |||
| 99 | + | ||||
| 100 | + | func (s *Service) Extend(ctx context.Context, name string, ttl time.Duration) error { | |||
| 101 | + | s.mu.Lock() | |||
| 102 | + | for i := range s.previews { | |||
| 103 | + | if s.previews[i].Name == name { | |||
| 104 | + | s.previews[i].ExpiresAt = time.Now().Add(ttl) | |||
| 105 | + | cp := make([]domain.PreviewRoute, len(s.previews)) | |||
| 106 | + | copy(cp, s.previews) | |||
| 107 | + | s.mu.Unlock() | |||
| 108 | + | return s.store.Save(ctx, cp) | |||
| 109 | + | } | |||
| 110 | + | } | |||
| 111 | + | s.mu.Unlock() | |||
| 112 | + | return fmt.Errorf("preview %q: %w", name, domain.ErrPreviewNotFound) | |||
| 113 | + | } | |||
| 114 | + | ||||
| 115 | + | func (s *Service) GetExpired() []domain.PreviewRoute { | |||
| 116 | + | s.mu.RLock() | |||
| 117 | + | defer s.mu.RUnlock() | |||
| 118 | + | var expired []domain.PreviewRoute | |||
| 119 | + | for _, p := range s.previews { | |||
| 120 | + | if p.IsExpired(time.Now()) { | |||
| 121 | + | expired = append(expired, p) | |||
| 122 | + | } | |||
| 123 | + | } | |||
| 124 | + | return expired | |||
| 125 | + | } | |||
| 126 | + | ||||
| 127 | + | // CleanupExpired removes expired previews from the service and persists. | |||
| 128 | + | // Returns the removed previews so the caller can teardown their resources. | |||
| 129 | + | func (s *Service) CleanupExpired(ctx context.Context) []domain.PreviewRoute { | |||
| 130 | + | s.mu.Lock() | |||
| 131 | + | var expired []domain.PreviewRoute | |||
| 132 | + | var active []domain.PreviewRoute | |||
| 133 | + | for _, p := range s.previews { | |||
| 134 | + | if p.IsExpired(time.Now()) { | |||
| 135 | + | expired = append(expired, p) | |||
| 136 | + | } else { | |||
| 137 | + | active = append(active, p) | |||
| 138 | + | } | |||
| 139 | + | } | |||
| 140 | + | s.previews = active | |||
| 141 | + | cp := make([]domain.PreviewRoute, len(active)) | |||
| 142 | + | copy(cp, active) | |||
| 143 | + | s.mu.Unlock() | |||
| 144 | + | ||||
| 145 | + | if len(expired) > 0 { | |||
| 146 | + | if err := s.store.Save(ctx, cp); err != nil { | |||
| 147 | + | // Log but don't fail — expired previews are already removed from memory | |||
| 148 | + | log := zerowrap.FromCtx(ctx) | |||
| 149 | + | log.Warn().Err(err).Msg("failed to persist preview state after cleanup") | |||
| 150 | + | } | |||
| 151 | + | } | |||
| 152 | + | return expired | |||
| 153 | + | } | |||
| 154 | + | ||||
| 155 | + | // StartTicker starts a background goroutine that checks for expired previews. | |||
| 156 | + | // The teardownFn is called for each expired preview to clean up containers/volumes. | |||
| 157 | + | func (s *Service) StartTicker(ctx context.Context, interval time.Duration, teardownFn func(context.Context, domain.PreviewRoute)) { | |||
| 158 | + | go func() { | |||
| 159 | + | ticker := time.NewTicker(interval) | |||
| 160 | + | defer ticker.Stop() | |||
| 161 | + | for { | |||
| 162 | + | select { | |||
| 163 | + | case <-ctx.Done(): | |||
| 164 | + | return | |||
| 165 | + | case <-ticker.C: | |||
| 166 | + | expired := s.CleanupExpired(ctx) | |||
| 167 | + | for _, p := range expired { | |||
| 168 | + | teardownFn(ctx, p) | |||
| 169 | + | } | |||
| 170 | + | } | |||
| 171 | + | } | |||
| 172 | + | }() | |||
| 173 | + | } | |||
| 174 | + | ||||
| 175 | + | func (s *Service) AcquireNameLock(name string) *sync.Mutex { | |||
| 176 | + | s.nameLockMu.Lock() | |||
| 177 | + | defer s.nameLockMu.Unlock() | |||
| 178 | + | entry, ok := s.nameLocks[name] | |||
| 179 | + | if !ok { | |||
| 180 | + | entry = &nameLockEntry{mu: &sync.Mutex{}} | |||
| 181 | + | s.nameLocks[name] = entry | |||
| 182 | + | } | |||
| 183 | + | entry.refs++ | |||
| 184 | + | return entry.mu | |||
| 185 | + | } | |||
| 186 | + | ||||
| 187 | + | // ReleaseNameLock decrements the refcount and removes the per-name mutex when no longer in use. | |||
| 188 | + | func (s *Service) ReleaseNameLock(name string) { | |||
| 189 | + | s.nameLockMu.Lock() | |||
| 190 | + | defer s.nameLockMu.Unlock() | |||
| 191 | + | entry, ok := s.nameLocks[name] | |||
| 192 | + | if !ok { | |||
| 193 | + | return | |||
| 194 | + | } | |||
| 195 | + | entry.refs-- | |||
| 196 | + | if entry.refs <= 0 { | |||
| 197 | + | delete(s.nameLocks, name) | |||
| 198 | + | } | |||
| 199 | + | } | |||
| 200 | + | ||||
| 201 | + | // CreatePreviewRequest contains parameters for creating a preview. | |||
| 202 | + | type CreatePreviewRequest struct { | |||
| 203 | + | Name string | |||
| 204 | + | Domain string | |||
| 205 | + | BaseRoute string | |||
| 206 | + | Image string | |||
| 207 | + | HTTPS bool | |||
| 208 | + | PreviewConfig domain.PreviewConfig | |||
| 209 | + | } | |||
| 210 | + | ||||
| 211 | + | // CreatePreview orchestrates the full preview creation: lock, clone volumes, start containers, register. | |||
| 212 | + | func (s *Service) CreatePreview(ctx context.Context, req CreatePreviewRequest) error { | |||
| 213 | + | lock := s.AcquireNameLock(req.Name) | |||
| 214 | + | lock.Lock() | |||
| 215 | + | defer func() { | |||
| 216 | + | lock.Unlock() | |||
| 217 | + | s.ReleaseNameLock(req.Name) | |||
| 218 | + | }() | |||
| 219 | + | ||||
| 220 | + | now := time.Now() | |||
| 221 | + | preview := domain.PreviewRoute{ | |||
| 222 | + | Domain: req.Domain, | |||
| 223 | + | Image: req.Image, | |||
| 224 | + | BaseRoute: req.BaseRoute, | |||
| 225 | + | Name: req.Name, | |||
| 226 | + | CreatedAt: now, | |||
| 227 | + | ExpiresAt: now.Add(req.PreviewConfig.TTL), | |||
| 228 | + | HTTPS: req.HTTPS, | |||
| 229 | + | } | |||
| 230 | + | ||||
| 231 | + | // TODO: Volume cloning and container startup will be wired in Task 13 | |||
| 232 | + | if err := s.Add(ctx, preview); err != nil { | |||
| 233 | + | return fmt.Errorf("register preview %q: %w", req.Name, err) | |||
| 234 | + | } | |||
| 235 | + | return nil | |||
| 236 | + | } | |||
A internal/usecase/auto/preview/service_test.go +109 -0
| @@ -0,0 +1,109 @@ | |||||
| 1 | + | package preview | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "testing" | |||
| 6 | + | "time" | |||
| 7 | + | ||||
| 8 | + | "github.com/stretchr/testify/assert" | |||
| 9 | + | "github.com/stretchr/testify/require" | |||
| 10 | + | ||||
| 11 | + | "github.com/bnema/gordon/internal/domain" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | type fakeStore struct { | |||
| 15 | + | previews []domain.PreviewRoute | |||
| 16 | + | } | |||
| 17 | + | ||||
| 18 | + | func (f *fakeStore) Load(_ context.Context) ([]domain.PreviewRoute, error) { | |||
| 19 | + | return f.previews, nil | |||
| 20 | + | } | |||
| 21 | + | func (f *fakeStore) Save(_ context.Context, p []domain.PreviewRoute) error { | |||
| 22 | + | f.previews = p | |||
| 23 | + | return nil | |||
| 24 | + | } | |||
| 25 | + | ||||
| 26 | + | func TestPreviewService_Add(t *testing.T) { | |||
| 27 | + | store := &fakeStore{} | |||
| 28 | + | svc := NewService(store, 48*time.Hour) | |||
| 29 | + | ||||
| 30 | + | p := domain.PreviewRoute{ | |||
| 31 | + | Domain: "myapp--feat.example.com", | |||
| 32 | + | Name: "feat", | |||
| 33 | + | BaseRoute: "myapp.example.com", | |||
| 34 | + | Image: "myapp:preview-feat", | |||
| 35 | + | } | |||
| 36 | + | err := svc.Add(context.Background(), p) | |||
| 37 | + | require.NoError(t, err) | |||
| 38 | + | ||||
| 39 | + | all, err := svc.List(context.Background()) | |||
| 40 | + | require.NoError(t, err) | |||
| 41 | + | assert.Len(t, all, 1) | |||
| 42 | + | assert.Equal(t, "feat", all[0].Name) | |||
| 43 | + | } | |||
| 44 | + | ||||
| 45 | + | func TestPreviewService_Delete(t *testing.T) { | |||
| 46 | + | store := &fakeStore{ | |||
| 47 | + | previews: []domain.PreviewRoute{ | |||
| 48 | + | {Name: "feat", Domain: "myapp--feat.example.com"}, | |||
| 49 | + | }, | |||
| 50 | + | } | |||
| 51 | + | svc := NewService(store, 48*time.Hour) | |||
| 52 | + | require.NoError(t, svc.Load(context.Background())) | |||
| 53 | + | ||||
| 54 | + | err := svc.Delete(context.Background(), "feat") | |||
| 55 | + | require.NoError(t, err) | |||
| 56 | + | ||||
| 57 | + | all, err := svc.List(context.Background()) | |||
| 58 | + | require.NoError(t, err) | |||
| 59 | + | assert.Empty(t, all) | |||
| 60 | + | } | |||
| 61 | + | ||||
| 62 | + | func TestPreviewService_Get(t *testing.T) { | |||
| 63 | + | store := &fakeStore{ | |||
| 64 | + | previews: []domain.PreviewRoute{ | |||
| 65 | + | {Name: "feat", Domain: "myapp--feat.example.com"}, | |||
| 66 | + | }, | |||
| 67 | + | } | |||
| 68 | + | svc := NewService(store, 48*time.Hour) | |||
| 69 | + | require.NoError(t, svc.Load(context.Background())) | |||
| 70 | + | ||||
| 71 | + | p, err := svc.Get(context.Background(), "feat") | |||
| 72 | + | require.NoError(t, err) | |||
| 73 | + | assert.Equal(t, "myapp--feat.example.com", p.Domain) | |||
| 74 | + | ||||
| 75 | + | _, err = svc.Get(context.Background(), "nonexistent") | |||
| 76 | + | assert.Error(t, err) | |||
| 77 | + | } | |||
| 78 | + | ||||
| 79 | + | func TestPreviewService_Extend(t *testing.T) { | |||
| 80 | + | now := time.Now() | |||
| 81 | + | store := &fakeStore{ | |||
| 82 | + | previews: []domain.PreviewRoute{ | |||
| 83 | + | {Name: "feat", ExpiresAt: now.Add(1 * time.Hour)}, | |||
| 84 | + | }, | |||
| 85 | + | } | |||
| 86 | + | svc := NewService(store, 48*time.Hour) | |||
| 87 | + | require.NoError(t, svc.Load(context.Background())) | |||
| 88 | + | ||||
| 89 | + | err := svc.Extend(context.Background(), "feat", 24*time.Hour) | |||
| 90 | + | require.NoError(t, err) | |||
| 91 | + | ||||
| 92 | + | p, _ := svc.Get(context.Background(), "feat") | |||
| 93 | + | assert.True(t, p.ExpiresAt.After(now.Add(23*time.Hour))) | |||
| 94 | + | } | |||
| 95 | + | ||||
| 96 | + | func TestPreviewService_GetExpired(t *testing.T) { | |||
| 97 | + | store := &fakeStore{ | |||
| 98 | + | previews: []domain.PreviewRoute{ | |||
| 99 | + | {Name: "expired", ExpiresAt: time.Now().Add(-1 * time.Hour)}, | |||
| 100 | + | {Name: "active", ExpiresAt: time.Now().Add(1 * time.Hour)}, | |||
| 101 | + | }, | |||
| 102 | + | } | |||
| 103 | + | svc := NewService(store, 48*time.Hour) | |||
| 104 | + | require.NoError(t, svc.Load(context.Background())) | |||
| 105 | + | ||||
| 106 | + | expired := svc.GetExpired() | |||
| 107 | + | assert.Len(t, expired, 1) | |||
| 108 | + | assert.Equal(t, "expired", expired[0].Name) | |||
| 109 | + | } | |||
A internal/usecase/auto/preview/ticker_test.go +32 -0
| @@ -0,0 +1,32 @@ | |||||
| 1 | + | package preview | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "testing" | |||
| 6 | + | "time" | |||
| 7 | + | ||||
| 8 | + | "github.com/stretchr/testify/assert" | |||
| 9 | + | "github.com/stretchr/testify/require" | |||
| 10 | + | ||||
| 11 | + | "github.com/bnema/gordon/internal/domain" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | func TestPreviewService_CleanupExpired(t *testing.T) { | |||
| 15 | + | store := &fakeStore{ | |||
| 16 | + | previews: []domain.PreviewRoute{ | |||
| 17 | + | {Name: "expired", Domain: "a--expired.example.com", ExpiresAt: time.Now().Add(-1 * time.Hour)}, | |||
| 18 | + | {Name: "active", Domain: "a--active.example.com", ExpiresAt: time.Now().Add(24 * time.Hour)}, | |||
| 19 | + | }, | |||
| 20 | + | } | |||
| 21 | + | svc := NewService(store, 48*time.Hour) | |||
| 22 | + | require.NoError(t, svc.Load(context.Background())) | |||
| 23 | + | ||||
| 24 | + | expired := svc.CleanupExpired(context.Background()) | |||
| 25 | + | assert.Len(t, expired, 1) | |||
| 26 | + | assert.Equal(t, "expired", expired[0].Name) | |||
| 27 | + | ||||
| 28 | + | all, err := svc.List(context.Background()) | |||
| 29 | + | require.NoError(t, err) | |||
| 30 | + | assert.Len(t, all, 1) | |||
| 31 | + | assert.Equal(t, "active", all[0].Name) | |||
| 32 | + | } | |||
A internal/usecase/auto/preview/volumes.go +93 -0
| @@ -0,0 +1,93 @@ | |||||
| 1 | + | package preview | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "fmt" | |||
| 6 | + | "strings" | |||
| 7 | + | ||||
| 8 | + | "github.com/bnema/gordon/internal/domain" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | // VolumeCloner abstracts container runtime operations needed for volume cloning. | |||
| 12 | + | type VolumeCloner interface { | |||
| 13 | + | CreateVolume(ctx context.Context, name string) error | |||
| 14 | + | RemoveVolume(ctx context.Context, name string, force bool) error | |||
| 15 | + | CreateContainer(ctx context.Context, config *domain.ContainerConfig) (*domain.Container, error) | |||
| 16 | + | StartContainer(ctx context.Context, containerID string) error | |||
| 17 | + | WaitForContainer(ctx context.Context, containerID string) error | |||
| 18 | + | RemoveContainer(ctx context.Context, containerID string, force bool) error | |||
| 19 | + | } | |||
| 20 | + | ||||
| 21 | + | // BuildCloneVolumeName generates a preview volume name. | |||
| 22 | + | func BuildCloneVolumeName(previewName, volumeName string) string { | |||
| 23 | + | return "preview-" + previewName + "-" + volumeName | |||
| 24 | + | } | |||
| 25 | + | ||||
| 26 | + | // BuildCloneContainerName generates a preview attachment container name from an image reference. | |||
| 27 | + | func BuildCloneContainerName(previewName, image string) string { | |||
| 28 | + | name := image | |||
| 29 | + | if i := strings.LastIndex(name, "/"); i >= 0 { | |||
| 30 | + | name = name[i+1:] | |||
| 31 | + | } | |||
| 32 | + | if i := strings.Index(name, ":"); i >= 0 { | |||
| 33 | + | name = name[:i] | |||
| 34 | + | } | |||
| 35 | + | return "preview-" + previewName + "-" + name | |||
| 36 | + | } | |||
| 37 | + | ||||
| 38 | + | // CloneVolumes copies volumes from source using a read-only helper container. | |||
| 39 | + | // sourceVolumes maps volume display names to actual Docker volume names. | |||
| 40 | + | func CloneVolumes(ctx context.Context, cloner VolumeCloner, previewName string, sourceVolumes map[string]string) ([]string, error) { | |||
| 41 | + | var clonedNames []string | |||
| 42 | + | for volName, sourceVolName := range sourceVolumes { | |||
| 43 | + | destName := BuildCloneVolumeName(previewName, volName) | |||
| 44 | + | ||||
| 45 | + | if err := cloner.CreateVolume(ctx, destName); err != nil { | |||
| 46 | + | cleanupVolumes(ctx, cloner, clonedNames) | |||
| 47 | + | return nil, fmt.Errorf("create volume %s: %w", destName, err) | |||
| 48 | + | } | |||
| 49 | + | ||||
| 50 | + | helperConfig := &domain.ContainerConfig{ | |||
| 51 | + | Image: "busybox:1.37", | |||
| 52 | + | Name: "gordon-vol-copy-" + destName, | |||
| 53 | + | Cmd: []string{"cp", "-a", "/src/.", "/dst/"}, | |||
| 54 | + | Volumes: map[string]string{ | |||
| 55 | + | "/dst": destName, | |||
| 56 | + | }, | |||
| 57 | + | ReadOnlyVolumes: map[string]string{ | |||
| 58 | + | "/src": sourceVolName, | |||
| 59 | + | }, | |||
| 60 | + | } | |||
| 61 | + | ||||
| 62 | + | created, err := cloner.CreateContainer(ctx, helperConfig) | |||
| 63 | + | if err != nil { | |||
| 64 | + | _ = cloner.RemoveVolume(ctx, destName, true) | |||
| 65 | + | cleanupVolumes(ctx, cloner, clonedNames) | |||
| 66 | + | return nil, fmt.Errorf("create copy helper for %s: %w", destName, err) | |||
| 67 | + | } | |||
| 68 | + | ||||
| 69 | + | if err := cloner.StartContainer(ctx, created.ID); err != nil { | |||
| 70 | + | _ = cloner.RemoveContainer(ctx, created.ID, true) | |||
| 71 | + | _ = cloner.RemoveVolume(ctx, destName, true) | |||
| 72 | + | cleanupVolumes(ctx, cloner, clonedNames) | |||
| 73 | + | return nil, fmt.Errorf("start copy helper for %s: %w", destName, err) | |||
| 74 | + | } | |||
| 75 | + | ||||
| 76 | + | if err := cloner.WaitForContainer(ctx, created.ID); err != nil { | |||
| 77 | + | _ = cloner.RemoveContainer(ctx, created.ID, true) | |||
| 78 | + | _ = cloner.RemoveVolume(ctx, destName, true) | |||
| 79 | + | cleanupVolumes(ctx, cloner, clonedNames) | |||
| 80 | + | return nil, fmt.Errorf("wait for copy helper %s: %w", destName, err) | |||
| 81 | + | } | |||
| 82 | + | ||||
| 83 | + | _ = cloner.RemoveContainer(ctx, created.ID, true) | |||
| 84 | + | clonedNames = append(clonedNames, destName) | |||
| 85 | + | } | |||
| 86 | + | return clonedNames, nil | |||
| 87 | + | } | |||
| 88 | + | ||||
| 89 | + | func cleanupVolumes(ctx context.Context, cloner VolumeCloner, names []string) { | |||
| 90 | + | for _, name := range names { | |||
| 91 | + | _ = cloner.RemoveVolume(ctx, name, true) | |||
| 92 | + | } | |||
| 93 | + | } | |||
A internal/usecase/auto/preview/volumes_test.go +18 -0
| @@ -0,0 +1,18 @@ | |||||
| 1 | + | package preview | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/stretchr/testify/assert" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | func TestBuildCloneVolumeName(t *testing.T) { | |||
| 10 | + | assert.Equal(t, "preview-feat-pgdata", BuildCloneVolumeName("feat", "pgdata")) | |||
| 11 | + | assert.Equal(t, "preview-pr-42-redis-data", BuildCloneVolumeName("pr-42", "redis-data")) | |||
| 12 | + | } | |||
| 13 | + | ||||
| 14 | + | func TestBuildCloneContainerName(t *testing.T) { | |||
| 15 | + | assert.Equal(t, "preview-feat-postgres", BuildCloneContainerName("feat", "postgres:17")) | |||
| 16 | + | assert.Equal(t, "preview-feat-redis", BuildCloneContainerName("feat", "redis:7")) | |||
| 17 | + | assert.Equal(t, "preview-feat-myapp", BuildCloneContainerName("feat", "org/myapp:latest")) | |||
| 18 | + | } | |||
A internal/usecase/auto/validation.go +58 -0
| @@ -0,0 +1,58 @@ | |||||
| 1 | + | package auto | |||
| 2 | + | ||||
| 3 | + | import "strings" | |||
| 4 | + | ||||
| 5 | + | // MatchesDomainAllowlist reports whether domain matches any of the given patterns. | |||
| 6 | + | // Patterns may be exact domains, wildcard subdomains (*.example.com), or "*" to | |||
| 7 | + | // allow everything. Wildcard patterns match a single subdomain level only, per | |||
| 8 | + | // DNS/TLS conventions. | |||
| 9 | + | func MatchesDomainAllowlist(domain string, patterns []string) bool { | |||
| 10 | + | domain = strings.ToLower(domain) | |||
| 11 | + | for _, pattern := range patterns { | |||
| 12 | + | pattern = strings.ToLower(strings.TrimSpace(pattern)) | |||
| 13 | + | if pattern == "" { | |||
| 14 | + | continue | |||
| 15 | + | } | |||
| 16 | + | if pattern == "*" { | |||
| 17 | + | return true | |||
| 18 | + | } | |||
| 19 | + | if pattern == domain { | |||
| 20 | + | return true | |||
| 21 | + | } | |||
| 22 | + | if !strings.HasPrefix(pattern, "*.") { | |||
| 23 | + | continue | |||
| 24 | + | } | |||
| 25 | + | // Wildcard patterns match single-level subdomains only (per DNS/TLS conventions). | |||
| 26 | + | // e.g., "*.example.com" matches "foo.example.com" but not "bar.foo.example.com" | |||
| 27 | + | suffix := strings.TrimPrefix(pattern, "*.") | |||
| 28 | + | if !strings.HasSuffix(domain, "."+suffix) { | |||
| 29 | + | continue | |||
| 30 | + | } | |||
| 31 | + | prefix := strings.TrimSuffix(domain, "."+suffix) | |||
| 32 | + | if prefix != "" && !strings.Contains(prefix, ".") { | |||
| 33 | + | return true | |||
| 34 | + | } | |||
| 35 | + | } | |||
| 36 | + | return false | |||
| 37 | + | } | |||
| 38 | + | ||||
| 39 | + | // ExtractRepoName strips the registry domain, tag, and digest from an image | |||
| 40 | + | // reference and returns the bare repository name (e.g. "org/myapp"). | |||
| 41 | + | func ExtractRepoName(imageRef, registryDomain string) string { | |||
| 42 | + | imageRef = strings.ToLower(imageRef) | |||
| 43 | + | if idx := strings.Index(imageRef, "@"); idx != -1 { | |||
| 44 | + | imageRef = imageRef[:idx] | |||
| 45 | + | } | |||
| 46 | + | if idx := strings.LastIndex(imageRef, ":"); idx != -1 { | |||
| 47 | + | slashIdx := strings.LastIndex(imageRef, "/") | |||
| 48 | + | if idx > slashIdx { | |||
| 49 | + | imageRef = imageRef[:idx] | |||
| 50 | + | } | |||
| 51 | + | } | |||
| 52 | + | registryPrefix := strings.ToLower(strings.TrimSuffix(registryDomain, "/")) | |||
| 53 | + | if registryPrefix != "" { | |||
| 54 | + | prefix := registryPrefix + "/" | |||
| 55 | + | imageRef = strings.TrimPrefix(imageRef, prefix) | |||
| 56 | + | } | |||
| 57 | + | return imageRef | |||
| 58 | + | } | |||
A internal/usecase/auto/validation_test.go +59 -0
| @@ -0,0 +1,59 @@ | |||||
| 1 | + | package auto | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/stretchr/testify/assert" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | func TestMatchesDomainAllowlist(t *testing.T) { | |||
| 10 | + | tests := []struct { | |||
| 11 | + | name string | |||
| 12 | + | domain string | |||
| 13 | + | patterns []string | |||
| 14 | + | want bool | |||
| 15 | + | }{ | |||
| 16 | + | {name: "exact match", domain: "app.example.com", patterns: []string{"app.example.com"}, want: true}, | |||
| 17 | + | {name: "no match", domain: "app.example.com", patterns: []string{"api.example.com"}, want: false}, | |||
| 18 | + | {name: "wildcard match", domain: "app.example.com", patterns: []string{"*.example.com"}, want: true}, | |||
| 19 | + | {name: "wildcard no match on root", domain: "example.com", patterns: []string{"*.example.com"}, want: false}, | |||
| 20 | + | {name: "wildcard matches one level only", domain: "api.app.example.com", patterns: []string{"*.example.com"}, want: false}, | |||
| 21 | + | {name: "empty allowlist", domain: "app.example.com", patterns: nil, want: false}, | |||
| 22 | + | {name: "case insensitive", domain: "App.Example.Com", patterns: []string{"APP.EXAMPLE.COM"}, want: true}, | |||
| 23 | + | {name: "multiple patterns", domain: "api.example.com", patterns: []string{"foo.com", "*.example.com"}, want: true}, | |||
| 24 | + | {name: "star allows all", domain: "anything.example.com", patterns: []string{"*"}, want: true}, | |||
| 25 | + | {name: "empty pattern skipped", domain: "app.example.com", patterns: []string{"", "app.example.com"}, want: true}, | |||
| 26 | + | {name: "whitespace trimmed in pattern", domain: "app.example.com", patterns: []string{" app.example.com "}, want: true}, | |||
| 27 | + | } | |||
| 28 | + | ||||
| 29 | + | for _, tt := range tests { | |||
| 30 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 31 | + | assert.Equal(t, tt.want, MatchesDomainAllowlist(tt.domain, tt.patterns)) | |||
| 32 | + | }) | |||
| 33 | + | } | |||
| 34 | + | } | |||
| 35 | + | ||||
| 36 | + | func TestExtractRepoName(t *testing.T) { | |||
| 37 | + | tests := []struct { | |||
| 38 | + | name string | |||
| 39 | + | imageRef string | |||
| 40 | + | registryDomain string | |||
| 41 | + | want string | |||
| 42 | + | }{ | |||
| 43 | + | {name: "simple tag", imageRef: "myapp:latest", want: "myapp"}, | |||
| 44 | + | {name: "version tag", imageRef: "myapp:v2.0.0", want: "myapp"}, | |||
| 45 | + | {name: "digest", imageRef: "myapp@sha256:abc123", want: "myapp"}, | |||
| 46 | + | {name: "strip registry", imageRef: "registry.example.com/myapp:latest", registryDomain: "registry.example.com", want: "myapp"}, | |||
| 47 | + | {name: "org image", imageRef: "org/myapp:latest", want: "org/myapp"}, | |||
| 48 | + | {name: "registry org image", imageRef: "registry.example.com/org/myapp:latest", registryDomain: "registry.example.com", want: "org/myapp"}, | |||
| 49 | + | {name: "lowercase", imageRef: "MyApp:Latest", want: "myapp"}, | |||
| 50 | + | {name: "no tag no digest", imageRef: "myapp", want: "myapp"}, | |||
| 51 | + | {name: "empty registry domain", imageRef: "myapp:latest", registryDomain: "", want: "myapp"}, | |||
| 52 | + | } | |||
| 53 | + | ||||
| 54 | + | for _, tt := range tests { | |||
| 55 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 56 | + | assert.Equal(t, tt.want, ExtractRepoName(tt.imageRef, tt.registryDomain)) | |||
| 57 | + | }) | |||
| 58 | + | } | |||
| 59 | + | } | |||
M internal/usecase/config/service.go +87 -4
| @@ -33,6 +33,11 @@ type Config struct { | |||||
| 33 | DataDir string | 33 | DataDir string | ||
| 34 | AutoRouteEnabled bool | 34 | AutoRouteEnabled bool | ||
| 35 | AutoRouteAllowedDomains []string `mapstructure:"auto_route_allowed_domains" json:"auto_route_allowed_domains,omitempty"` | 35 | AutoRouteAllowedDomains []string `mapstructure:"auto_route_allowed_domains" json:"auto_route_allowed_domains,omitempty"` | ||
| 36 | + | PreviewEnabled bool | |||
| 37 | + | PreviewTTL time.Duration | |||
| 38 | + | PreviewSeparator string | |||
| 39 | + | PreviewTagPatterns []string | |||
| 40 | + | PreviewDataCopy bool | |||
| 36 | NetworkIsolation bool | 41 | NetworkIsolation bool | ||
| 37 | NetworkPrefix string | 42 | NetworkPrefix string | ||
| 38 | Routes map[string]string | 43 | Routes map[string]string | ||
| @@ -122,13 +127,43 @@ func (s *Service) loadConfigValues() Config { | |||||
| 122 | registryDomain = s.viper.GetString("server.registry_domain") | 127 | registryDomain = s.viper.GetString("server.registry_domain") | ||
| 123 | } | 128 | } | ||
| 124 | 129 | ||||
| 130 | + | // Backward compat: try new keys first, fall back to old | |||
| 131 | + | autoEnabled := s.viper.GetBool("auto.enabled") | |||
| 132 | + | if !autoEnabled { | |||
| 133 | + | autoEnabled = s.viper.GetBool("auto_route.enabled") | |||
| 134 | + | } | |||
| 135 | + | allowedDomains := s.viper.GetStringSlice("auto.allowed_domains") | |||
| 136 | + | if len(allowedDomains) == 0 { | |||
| 137 | + | allowedDomains = s.viper.GetStringSlice("auto_route_allowed_domains") | |||
| 138 | + | } | |||
| 139 | + | ||||
| 140 | + | // Preview config | |||
| 141 | + | previewTTLStr := s.viper.GetString("auto.preview.ttl") | |||
| 142 | + | previewTTL, err := time.ParseDuration(previewTTLStr) | |||
| 143 | + | if err != nil && previewTTLStr != "" { | |||
| 144 | + | log := zerowrap.FromCtx(context.Background()) | |||
| 145 | + | log.Warn().Err(err).Str("ttl", previewTTLStr).Msg("invalid preview TTL format, using default 48h (use Go duration strings, e.g. \"168h\" for 7 days)") | |||
| 146 | + | } | |||
| 147 | + | if previewTTL == 0 { | |||
| 148 | + | previewTTL = 48 * time.Hour | |||
| 149 | + | } | |||
| 150 | + | previewSep := s.viper.GetString("auto.preview.separator") | |||
| 151 | + | if previewSep == "" { | |||
| 152 | + | previewSep = "--" | |||
| 153 | + | } | |||
| 154 | + | ||||
| 125 | return Config{ | 155 | return Config{ | ||
| 126 | ServerPort: s.viper.GetInt("server.port"), | 156 | ServerPort: s.viper.GetInt("server.port"), | ||
| 127 | RegistryPort: s.viper.GetInt("server.registry_port"), | 157 | RegistryPort: s.viper.GetInt("server.registry_port"), | ||
| 128 | RegistryDomain: registryDomain, | 158 | RegistryDomain: registryDomain, | ||
| 129 | DataDir: s.viper.GetString("server.data_dir"), | 159 | DataDir: s.viper.GetString("server.data_dir"), | ||
| 130 | - | AutoRouteEnabled: s.viper.GetBool("auto_route.enabled"), | 160 | + | AutoRouteEnabled: autoEnabled, |
| 131 | - | AutoRouteAllowedDomains: append([]string{}, s.viper.GetStringSlice("auto_route_allowed_domains")...), | 161 | + | AutoRouteAllowedDomains: append([]string{}, allowedDomains...), |
| 162 | + | PreviewEnabled: s.viper.GetBool("auto.preview.enabled"), | |||
| 163 | + | PreviewTTL: previewTTL, | |||
| 164 | + | PreviewSeparator: previewSep, | |||
| 165 | + | PreviewTagPatterns: append([]string{}, s.viper.GetStringSlice("auto.preview.tag_patterns")...), | |||
| 166 | + | PreviewDataCopy: s.viper.GetBool("auto.preview.data_copy"), | |||
| 132 | NetworkIsolation: s.viper.GetBool("network_isolation.enabled"), | 167 | NetworkIsolation: s.viper.GetBool("network_isolation.enabled"), | ||
| 133 | NetworkPrefix: s.viper.GetString("network_isolation.network_prefix"), | 168 | NetworkPrefix: s.viper.GetString("network_isolation.network_prefix"), | ||
| 134 | RegistryAuthEnabled: s.viper.GetBool("auth.enabled"), | 169 | RegistryAuthEnabled: s.viper.GetBool("auth.enabled"), | ||
| @@ -519,7 +554,7 @@ type configSnapshot struct { | |||||
| 519 | } | 554 | } | ||
| 520 | 555 | ||||
| 521 | // mutableSections lists config sections that are intentionally modified by service operations. | 556 | // mutableSections lists config sections that are intentionally modified by service operations. | ||
| 522 | - | var mutableSections = []string{"routes.", "external_routes.", "attachments.", "network_groups.", "auto_route_allowed_domains"} | 557 | + | var mutableSections = []string{"routes.", "external_routes.", "attachments.", "network_groups.", "auto_route_allowed_domains", "auto."} |
| 523 | 558 | ||||
| 524 | func isMutableKey(key string) bool { | 559 | func isMutableKey(key string) bool { | ||
| 525 | for _, prefix := range mutableSections { | 560 | for _, prefix := range mutableSections { | ||
| @@ -529,7 +564,7 @@ func isMutableKey(key string) bool { | |||||
| 529 | } | 564 | } | ||
| 530 | 565 | ||||
| 531 | switch key { | 566 | switch key { | ||
| 532 | - | case "routes", "external_routes", "attachments", "network_groups", "auto_route_allowed_domains": | 567 | + | case "routes", "external_routes", "attachments", "network_groups", "auto_route_allowed_domains", "auto": |
| 533 | return true | 568 | return true | ||
| 534 | } | 569 | } | ||
| 535 | 570 | ||||
| @@ -570,6 +605,7 @@ func (s *Service) writeConfigSurgical(configFile string) error { | |||||
| 570 | s.viper.Set("attachments", s.config.Attachments) | 605 | s.viper.Set("attachments", s.config.Attachments) | ||
| 571 | s.viper.Set("network_groups", s.config.NetworkGroups) | 606 | s.viper.Set("network_groups", s.config.NetworkGroups) | ||
| 572 | s.viper.Set("auto_route_allowed_domains", s.config.AutoRouteAllowedDomains) | 607 | s.viper.Set("auto_route_allowed_domains", s.config.AutoRouteAllowedDomains) | ||
| 608 | + | s.viper.Set("auto.allowed_domains", s.config.AutoRouteAllowedDomains) | |||
| 573 | return s.viper.WriteConfigAs(configFile) | 609 | return s.viper.WriteConfigAs(configFile) | ||
| 574 | } | 610 | } | ||
| 575 | return fmt.Errorf("failed to read config file: %w", err) | 611 | return fmt.Errorf("failed to read config file: %w", err) | ||
| @@ -589,7 +625,15 @@ func (s *Service) writeConfigSurgical(configFile string) error { | |||||
| 589 | config["external_routes"] = s.config.ExternalRoutes | 625 | config["external_routes"] = s.config.ExternalRoutes | ||
| 590 | config["attachments"] = s.config.Attachments | 626 | config["attachments"] = s.config.Attachments | ||
| 591 | config["network_groups"] = s.config.NetworkGroups | 627 | config["network_groups"] = s.config.NetworkGroups | ||
| 628 | + | // Write to both old and new keys for backward compat | |||
| 592 | config["auto_route_allowed_domains"] = s.config.AutoRouteAllowedDomains | 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 | out, err := toml.Marshal(config) | 638 | out, err := toml.Marshal(config) | ||
| 595 | if err != nil { | 639 | if err != nil { | ||
| @@ -1050,6 +1094,45 @@ func (s *Service) RemoveAttachment(ctx context.Context, domainOrGroup, image str | |||||
| 1050 | return nil | 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 | // GetAutoRouteAllowedDomains returns the configured auto-route domain allowlist. | 1136 | // GetAutoRouteAllowedDomains returns the configured auto-route domain allowlist. | ||
| 1054 | func (s *Service) GetAutoRouteAllowedDomains(_ context.Context) ([]string, error) { | 1137 | func (s *Service) GetAutoRouteAllowedDomains(_ context.Context) ([]string, error) { | ||
| 1055 | s.mu.RLock() | 1138 | s.mu.RLock() | ||
M internal/usecase/container/autoroute.go +7 -151
| @@ -4,9 +4,7 @@ import ( | |||||
| 4 | "bufio" | 4 | "bufio" | ||
| 5 | "bytes" | 5 | "bytes" | ||
| 6 | "context" | 6 | "context" | ||
| 7 | - | "encoding/json" | |||
| 8 | "fmt" | 7 | "fmt" | ||
| 9 | - | "io" | |||
| 10 | "os" | 8 | "os" | ||
| 11 | "path/filepath" | 9 | "path/filepath" | ||
| 12 | "sort" | 10 | "sort" | ||
| @@ -17,6 +15,7 @@ import ( | |||||
| 17 | "github.com/bnema/gordon/internal/boundaries/in" | 15 | "github.com/bnema/gordon/internal/boundaries/in" | ||
| 18 | "github.com/bnema/gordon/internal/boundaries/out" | 16 | "github.com/bnema/gordon/internal/boundaries/out" | ||
| 19 | "github.com/bnema/gordon/internal/domain" | 17 | "github.com/bnema/gordon/internal/domain" | ||
| 18 | + | "github.com/bnema/gordon/internal/usecase/auto" | |||
| 20 | ) | 19 | ) | ||
| 21 | 20 | ||||
| 22 | // EnvFileExtractor defines the interface for extracting env files from images. | 21 | // EnvFileExtractor defines the interface for extracting env files from images. | ||
| @@ -205,7 +204,7 @@ func (h *AutoRouteHandler) createOrUpdateRoute(ctx context.Context, routeDomain, | |||||
| 205 | existingRoutes := h.configSvc.GetRoutes(ctx) | 204 | existingRoutes := h.configSvc.GetRoutes(ctx) | ||
| 206 | for _, existing := range existingRoutes { | 205 | for _, existing := range existingRoutes { | ||
| 207 | if existing.Domain == routeDomain { | 206 | if existing.Domain == routeDomain { | ||
| 208 | - | if extractRepoName(existing.Image, h.registryDomain) != extractRepoName(imageName, h.registryDomain) { | 207 | + | if auto.ExtractRepoName(existing.Image, h.registryDomain) != auto.ExtractRepoName(imageName, h.registryDomain) { |
| 209 | log.Warn().Str("domain", routeDomain).Str("existing_image", existing.Image).Str("image", imageName).Msg("auto-route update rejected due to repository ownership mismatch") | 208 | log.Warn().Str("domain", routeDomain).Str("existing_image", existing.Image).Str("image", imageName).Msg("auto-route update rejected due to repository ownership mismatch") | ||
| 210 | return false | 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 | log.Warn().Str("domain", routeDomain).Strs("allowed_domains", allowedDomains).Msg("auto-route create rejected due to domain allowlist") | 225 | log.Warn().Str("domain", routeDomain).Strs("allowed_domains", allowedDomains).Msg("auto-route create rejected due to domain allowlist") | ||
| 227 | return false | 226 | return false | ||
| 228 | } | 227 | } | ||
| @@ -239,55 +238,6 @@ func (h *AutoRouteHandler) createOrUpdateRoute(ctx context.Context, routeDomain, | |||||
| 239 | return false | 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 | // triggerDeploy initiates deployment for a route. | 241 | // triggerDeploy initiates deployment for a route. | ||
| 292 | func (h *AutoRouteHandler) triggerDeploy(ctx context.Context, route domain.Route) { | 242 | func (h *AutoRouteHandler) triggerDeploy(ctx context.Context, route domain.Route) { | ||
| 293 | log := zerowrap.FromCtx(ctx) | 243 | log := zerowrap.FromCtx(ctx) | ||
| @@ -389,108 +339,14 @@ func (h *AutoRouteHandler) CanHandle(eventType domain.EventType) bool { | |||||
| 389 | func (h *AutoRouteHandler) extractLabels(ctx context.Context, manifestData []byte) (*domain.ImageLabels, error) { | 339 | func (h *AutoRouteHandler) extractLabels(ctx context.Context, manifestData []byte) (*domain.ImageLabels, error) { | ||
| 390 | log := zerowrap.FromCtx(ctx) | 340 | log := zerowrap.FromCtx(ctx) | ||
| 391 | 341 | ||||
| 392 | - | // Parse manifest to get config digest | 342 | + | labels, err := auto.ExtractLabels(ctx, manifestData, h.blobStorage) |
| 393 | - | configDigest, err := parseConfigDigest(manifestData) | |||
| 394 | if err != nil { | 343 | if err != nil { | ||
| 395 | - | return nil, fmt.Errorf("failed to parse manifest: %w", err) | |||
| 396 | - | } | |||
| 397 | - | ||||
| 398 | - | if configDigest == "" { | |||
| 399 | - | return nil, fmt.Errorf("no config digest found in manifest") | |||
| 400 | - | } | |||
| 401 | - | ||||
| 402 | - | log.Debug().Str("config_digest", configDigest).Msg("found config digest") | |||
| 403 | - | ||||
| 404 | - | // Read config blob | |||
| 405 | - | reader, err := h.blobStorage.GetBlob(configDigest) | |||
| 406 | - | if err != nil { | |||
| 407 | - | return nil, fmt.Errorf("failed to get config blob: %w", err) | |||
| 408 | - | } | |||
| 409 | - | defer reader.Close() | |||
| 410 | - | ||||
| 411 | - | configData, err := io.ReadAll(reader) | |||
| 412 | - | if err != nil { | |||
| 413 | - | return nil, fmt.Errorf("failed to read config blob: %w", err) | |||
| 414 | - | } | |||
| 415 | - | ||||
| 416 | - | // Parse config to extract labels | |||
| 417 | - | labels, err := parseImageLabels(configData) | |||
| 418 | - | if err != nil { | |||
| 419 | - | return nil, fmt.Errorf("failed to parse config: %w", err) | |||
| 420 | - | } | |||
| 421 | - | ||||
| 422 | - | return labels, nil | |||
| 423 | - | } | |||
| 424 | - | ||||
| 425 | - | // manifestSchema represents the relevant parts of an OCI/Docker manifest. | |||
| 426 | - | type manifestSchema struct { | |||
| 427 | - | SchemaVersion int `json:"schemaVersion"` | |||
| 428 | - | MediaType string `json:"mediaType"` | |||
| 429 | - | Config struct { | |||
| 430 | - | MediaType string `json:"mediaType"` | |||
| 431 | - | Digest string `json:"digest"` | |||
| 432 | - | Size int64 `json:"size"` | |||
| 433 | - | } `json:"config"` | |||
| 434 | - | } | |||
| 435 | - | ||||
| 436 | - | // parseConfigDigest extracts the config digest from a manifest. | |||
| 437 | - | func parseConfigDigest(manifestData []byte) (string, error) { | |||
| 438 | - | var manifest manifestSchema | |||
| 439 | - | if err := json.Unmarshal(manifestData, &manifest); err != nil { | |||
| 440 | - | return "", err | |||
| 441 | - | } | |||
| 442 | - | ||||
| 443 | - | return manifest.Config.Digest, nil | |||
| 444 | - | } | |||
| 445 | - | ||||
| 446 | - | // imageConfig represents the relevant parts of an OCI/Docker image config. | |||
| 447 | - | type imageConfig struct { | |||
| 448 | - | Config struct { | |||
| 449 | - | Labels map[string]string `json:"Labels"` | |||
| 450 | - | } `json:"config"` | |||
| 451 | - | } | |||
| 452 | - | ||||
| 453 | - | // parseImageLabels extracts Gordon labels from an image config. | |||
| 454 | - | func parseImageLabels(configData []byte) (*domain.ImageLabels, error) { | |||
| 455 | - | var config imageConfig | |||
| 456 | - | if err := json.Unmarshal(configData, &config); err != nil { | |||
| 457 | return nil, err | 344 | return nil, err | ||
| 458 | } | 345 | } | ||
| 459 | 346 | ||||
| 460 | - | labels := &domain.ImageLabels{} | 347 | + | // Log the config digest for observability (best-effort parse for logging only) |
| 461 | - | 348 | + | if digest, parseErr := auto.ParseConfigDigest(manifestData); parseErr == nil && digest != "" { | |
| 462 | - | if config.Config.Labels == nil { | 349 | + | log.Debug().Str("config_digest", digest).Msg("found config digest") |
| 463 | - | return labels, nil | |||
| 464 | - | } | |||
| 465 | - | ||||
| 466 | - | // Extract gordon.* labels | |||
| 467 | - | if v, ok := config.Config.Labels[domain.LabelDomain]; ok { | |||
| 468 | - | labels.Domain = strings.TrimSpace(v) | |||
| 469 | - | } | |||
| 470 | - | ||||
| 471 | - | if v, ok := config.Config.Labels[domain.LabelDomains]; ok { | |||
| 472 | - | // Parse comma-separated domains | |||
| 473 | - | for _, d := range strings.Split(v, ",") { | |||
| 474 | - | d = strings.TrimSpace(d) | |||
| 475 | - | if d != "" { | |||
| 476 | - | labels.Domains = append(labels.Domains, d) | |||
| 477 | - | } | |||
| 478 | - | } | |||
| 479 | - | } | |||
| 480 | - | ||||
| 481 | - | if v, ok := config.Config.Labels[domain.LabelHealth]; ok { | |||
| 482 | - | labels.Health = strings.TrimSpace(v) | |||
| 483 | - | } | |||
| 484 | - | ||||
| 485 | - | for _, key := range []string{domain.LabelProxyPort, domain.LabelPort} { | |||
| 486 | - | if v, ok := config.Config.Labels[key]; ok { | |||
| 487 | - | labels.Port = strings.TrimSpace(v) | |||
| 488 | - | break | |||
| 489 | - | } | |||
| 490 | - | } | |||
| 491 | - | ||||
| 492 | - | if v, ok := config.Config.Labels[domain.LabelEnvFile]; ok { | |||
| 493 | - | labels.EnvFile = strings.TrimSpace(v) | |||
| 494 | } | 350 | } | ||
| 495 | 351 | ||||
| 496 | return labels, nil | 352 | return labels, nil | ||
M internal/usecase/container/autoroute_test.go +10 -8
| @@ -15,6 +15,7 @@ import ( | |||||
| 15 | inmocks "github.com/bnema/gordon/internal/boundaries/in/mocks" | 15 | inmocks "github.com/bnema/gordon/internal/boundaries/in/mocks" | ||
| 16 | "github.com/bnema/gordon/internal/boundaries/out/mocks" | 16 | "github.com/bnema/gordon/internal/boundaries/out/mocks" | ||
| 17 | "github.com/bnema/gordon/internal/domain" | 17 | "github.com/bnema/gordon/internal/domain" | ||
| 18 | + | "github.com/bnema/gordon/internal/usecase/auto" | |||
| 18 | ) | 19 | ) | ||
| 19 | 20 | ||||
| 20 | // domainToEnvFileName tests | 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 | func TestParseConfigDigest(t *testing.T) { | 161 | func TestParseConfigDigest(t *testing.T) { | ||
| 160 | tests := []struct { | 162 | tests := []struct { | ||
| @@ -206,7 +208,7 @@ func TestParseConfigDigest(t *testing.T) { | |||||
| 206 | data, err := json.Marshal(tt.manifest) | 208 | data, err := json.Marshal(tt.manifest) | ||
| 207 | require.NoError(t, err) | 209 | require.NoError(t, err) | ||
| 208 | 210 | ||||
| 209 | - | result, err := parseConfigDigest(data) | 211 | + | result, err := auto.ParseConfigDigest(data) |
| 210 | 212 | ||||
| 211 | if tt.wantErr { | 213 | if tt.wantErr { | ||
| 212 | assert.Error(t, err) | 214 | assert.Error(t, err) | ||
| @@ -220,11 +222,11 @@ func TestParseConfigDigest(t *testing.T) { | |||||
| 220 | } | 222 | } | ||
| 221 | 223 | ||||
| 222 | func TestParseConfigDigest_InvalidJSON(t *testing.T) { | 224 | func TestParseConfigDigest_InvalidJSON(t *testing.T) { | ||
| 223 | - | _, err := parseConfigDigest([]byte("invalid json")) | 225 | + | _, err := auto.ParseConfigDigest([]byte("invalid json")) |
| 224 | assert.Error(t, err) | 226 | assert.Error(t, err) | ||
| 225 | } | 227 | } | ||
| 226 | 228 | ||||
| 227 | - | // parseImageLabels tests | 229 | + | // parseImageLabels tests — now delegated to auto package; tested there directly. |
| 228 | 230 | ||||
| 229 | func TestParseImageLabels(t *testing.T) { | 231 | func TestParseImageLabels(t *testing.T) { | ||
| 230 | tests := []struct { | 232 | tests := []struct { | ||
| @@ -317,7 +319,7 @@ func TestParseImageLabels(t *testing.T) { | |||||
| 317 | data, err := json.Marshal(tt.config) | 319 | data, err := json.Marshal(tt.config) | ||
| 318 | require.NoError(t, err) | 320 | require.NoError(t, err) | ||
| 319 | 321 | ||||
| 320 | - | result, err := parseImageLabels(data) | 322 | + | result, err := auto.ParseImageLabels(data) |
| 321 | require.NoError(t, err) | 323 | require.NoError(t, err) | ||
| 322 | 324 | ||||
| 323 | assert.Equal(t, tt.expected.Domain, result.Domain) | 325 | assert.Equal(t, tt.expected.Domain, result.Domain) | ||
| @@ -330,7 +332,7 @@ func TestParseImageLabels(t *testing.T) { | |||||
| 330 | } | 332 | } | ||
| 331 | 333 | ||||
| 332 | func TestParseImageLabels_InvalidJSON(t *testing.T) { | 334 | func TestParseImageLabels_InvalidJSON(t *testing.T) { | ||
| 333 | - | _, err := parseImageLabels([]byte("invalid json")) | 335 | + | _, err := auto.ParseImageLabels([]byte("invalid json")) |
| 334 | assert.Error(t, err) | 336 | assert.Error(t, err) | ||
| 335 | } | 337 | } | ||
| 336 | 338 | ||||
| @@ -353,7 +355,7 @@ func TestMatchesDomainAllowlist(t *testing.T) { | |||||
| 353 | 355 | ||||
| 354 | for _, tt := range tests { | 356 | for _, tt := range tests { | ||
| 355 | t.Run(tt.name, func(t *testing.T) { | 357 | t.Run(tt.name, func(t *testing.T) { | ||
| 356 | - | assert.Equal(t, tt.want, matchesDomainAllowlist(tt.domain, tt.patterns)) | 358 | + | assert.Equal(t, tt.want, auto.MatchesDomainAllowlist(tt.domain, tt.patterns)) |
| 357 | }) | 359 | }) | ||
| 358 | } | 360 | } | ||
| 359 | } | 361 | } | ||
| @@ -376,7 +378,7 @@ func TestExtractRepoName(t *testing.T) { | |||||
| 376 | 378 | ||||
| 377 | for _, tt := range tests { | 379 | for _, tt := range tests { | ||
| 378 | t.Run(tt.name, func(t *testing.T) { | 380 | t.Run(tt.name, func(t *testing.T) { | ||
| 379 | - | assert.Equal(t, tt.want, extractRepoName(tt.imageRef, tt.registryDomain)) | 381 | + | assert.Equal(t, tt.want, auto.ExtractRepoName(tt.imageRef, tt.registryDomain)) |
| 380 | }) | 382 | }) | ||
| 381 | } | 383 | } | ||
| 382 | } | 384 | } | ||
M pkg/runtime/interface.go +1 -0
| @@ -67,6 +67,7 @@ type Runtime interface { | |||||
| 67 | CreateContainer(ctx context.Context, config *ContainerConfig) (*Container, error) | 67 | CreateContainer(ctx context.Context, config *ContainerConfig) (*Container, error) | ||
| 68 | StartContainer(ctx context.Context, containerID string) error | 68 | StartContainer(ctx context.Context, containerID string) error | ||
| 69 | StopContainer(ctx context.Context, containerID string) error | 69 | StopContainer(ctx context.Context, containerID string) error | ||
| 70 | + | WaitForContainer(ctx context.Context, containerID string) error | |||
| 70 | RestartContainer(ctx context.Context, containerID string) error | 71 | RestartContainer(ctx context.Context, containerID string) error | ||
| 71 | RemoveContainer(ctx context.Context, containerID string, force bool) error | 72 | RemoveContainer(ctx context.Context, containerID string, force bool) error | ||
| 72 | 73 | ||||