feat: add attachment image push support to CLI and control plane
Add gordon attachments push command for building/tagging/pushing attachment images to the Gordon registry. This closes the gap where attachment-based deploys fail because the attachment image has no supported push path. - Add FindAttachmentTargetsByImage config lookup with shared normalization - Expose attachment-by-image lookup via admin API and control plane - Add attachments push CLI command with build/tag/push support - Update push error messaging to suggest attachments push for non-route images - Update CLI docs for attachments, push, bootstrap, and index Closes #103
- commit
- 8a929d8dd97946446df1903b17f8376b7b8520eb
- author
- brice · · 2026-03-14 09:16 UTC
- parent
- d1dc763
- signature
- unsigned
21 files · +953 -18
M docs/cli/attachments.md +59 -0
| @@ -19,6 +19,7 @@ The CLI will warn you if you try to add an attachment without network isolation | |||
| 19 | 19 | |------------|-------------| | |
| 20 | 20 | | `list` | List all attachments or attachments for a specific target | | |
| 21 | 21 | | `add` | Add an attachment to a domain or network group | | |
| 22 | + | | `push` | Build or push attachment images to the Gordon registry | | |
| 22 | 23 | | `remove` | Remove an attachment from a domain or network group | | |
| 23 | 24 | ||
| 24 | 25 | ### Alias | |
| @@ -128,6 +129,51 @@ gordon attachments add app.example.com postgres:18 --remote https://gordon.mydom | |||
| 128 | 129 | ||
| 129 | 130 | --- | |
| 130 | 131 | ||
| 132 | + | ## gordon attachments push | |
| 133 | + | ||
| 134 | + | Push attachment images to the Gordon registry. | |
| 135 | + | ||
| 136 | + | ```bash | |
| 137 | + | gordon attachments push <image> [options] | |
| 138 | + | ``` | |
| 139 | + | ||
| 140 | + | ### Arguments | |
| 141 | + | ||
| 142 | + | | Argument | Description | | |
| 143 | + | |----------|-------------| | |
| 144 | + | | `<image>` | The attachment image to build/tag and push | | |
| 145 | + | ||
| 146 | + | ### Options | |
| 147 | + | ||
| 148 | + | | Option | Description | | |
| 149 | + | |--------|-------------| | |
| 150 | + | | `--build` | Build the image first using `docker buildx` | | |
| 151 | + | | `-f, --file` | Path to Dockerfile (default: `./Dockerfile`, used with `--build`) | | |
| 152 | + | | `--platform` | Target platform for buildx (default: `linux/amd64`) | | |
| 153 | + | | `--build-arg` | Additional build args (repeatable, `KEY=VALUE`) | | |
| 154 | + | | `--tag` | Override version tag | | |
| 155 | + | | `--remote` | Remote Gordon URL | | |
| 156 | + | | `--token` | Authentication token for remote | | |
| 157 | + | ||
| 158 | + | ### Description | |
| 159 | + | ||
| 160 | + | `gordon attachments push` pushes attachment images such as databases and caches to the Gordon registry so they are available when routes deploy. It does not trigger deployment. The image must already be configured as an attachment first. | |
| 161 | + | ||
| 162 | + | ### Examples | |
| 163 | + | ||
| 164 | + | ```bash | |
| 165 | + | # Push a pre-built attachment image | |
| 166 | + | gordon attachments push pitlane-pgsql | |
| 167 | + | ||
| 168 | + | # Build and push an attachment image | |
| 169 | + | gordon attachments push pitlane-pgsql --build | |
| 170 | + | ||
| 171 | + | # Push with a specific tag | |
| 172 | + | gordon attachments push pitlane-pgsql --tag v18 | |
| 173 | + | ``` | |
| 174 | + | ||
| 175 | + | --- | |
| 176 | + | ||
| 131 | 177 | ## gordon attachments remove | |
| 132 | 178 | ||
| 133 | 179 | Remove an attachment. | |
| @@ -194,6 +240,19 @@ gordon attachments add backend redis:7-alpine | |||
| 194 | 240 | # Both app.example.com and api.example.com can now access the shared Redis | |
| 195 | 241 | ``` | |
| 196 | 242 | ||
| 243 | + | ### First Deploy with Custom Attachment Image | |
| 244 | + | ||
| 245 | + | ```bash | |
| 246 | + | # Configure the route and attachment | |
| 247 | + | gordon bootstrap app.example.com myapp:latest --attachment pitlane-pgsql | |
| 248 | + | ||
| 249 | + | # Push the custom attachment image first | |
| 250 | + | gordon attachments push pitlane-pgsql --build | |
| 251 | + | ||
| 252 | + | # Then push and deploy the route image | |
| 253 | + | gordon push app.example.com --build --no-confirm | |
| 254 | + | ``` | |
| 255 | + | ||
| 197 | 256 | ### CI/CD Integration | |
| 198 | 257 | ||
| 199 | 258 | ```bash | |
| @@ -19,6 +19,7 @@ The CLI will warn you if you try to add an attachment without network isolation | |||||
| 19 | |------------|-------------| | 19 | |------------|-------------| | ||
| 20 | | `list` | List all attachments or attachments for a specific target | | 20 | | `list` | List all attachments or attachments for a specific target | | ||
| 21 | | `add` | Add an attachment to a domain or network group | | 21 | | `add` | Add an attachment to a domain or network group | | ||
| 22 | + | | `push` | Build or push attachment images to the Gordon registry | | |||
| 22 | | `remove` | Remove an attachment from a domain or network group | | 23 | | `remove` | Remove an attachment from a domain or network group | | ||
| 23 | 24 | ||||
| 24 | ### Alias | 25 | ### Alias | ||
| @@ -128,6 +129,51 @@ gordon attachments add app.example.com postgres:18 --remote https://gordon.mydom | |||||
| 128 | 129 | ||||
| 129 | --- | 130 | --- | ||
| 130 | 131 | ||||
| 132 | + | ## gordon attachments push | |||
| 133 | + | ||||
| 134 | + | Push attachment images to the Gordon registry. | |||
| 135 | + | ||||
| 136 | + | ```bash | |||
| 137 | + | gordon attachments push <image> [options] | |||
| 138 | + | ``` | |||
| 139 | + | ||||
| 140 | + | ### Arguments | |||
| 141 | + | ||||
| 142 | + | | Argument | Description | | |||
| 143 | + | |----------|-------------| | |||
| 144 | + | | `<image>` | The attachment image to build/tag and push | | |||
| 145 | + | ||||
| 146 | + | ### Options | |||
| 147 | + | ||||
| 148 | + | | Option | Description | | |||
| 149 | + | |--------|-------------| | |||
| 150 | + | | `--build` | Build the image first using `docker buildx` | | |||
| 151 | + | | `-f, --file` | Path to Dockerfile (default: `./Dockerfile`, used with `--build`) | | |||
| 152 | + | | `--platform` | Target platform for buildx (default: `linux/amd64`) | | |||
| 153 | + | | `--build-arg` | Additional build args (repeatable, `KEY=VALUE`) | | |||
| 154 | + | | `--tag` | Override version tag | | |||
| 155 | + | | `--remote` | Remote Gordon URL | | |||
| 156 | + | | `--token` | Authentication token for remote | | |||
| 157 | + | ||||
| 158 | + | ### Description | |||
| 159 | + | ||||
| 160 | + | `gordon attachments push` pushes attachment images such as databases and caches to the Gordon registry so they are available when routes deploy. It does not trigger deployment. The image must already be configured as an attachment first. | |||
| 161 | + | ||||
| 162 | + | ### Examples | |||
| 163 | + | ||||
| 164 | + | ```bash | |||
| 165 | + | # Push a pre-built attachment image | |||
| 166 | + | gordon attachments push pitlane-pgsql | |||
| 167 | + | ||||
| 168 | + | # Build and push an attachment image | |||
| 169 | + | gordon attachments push pitlane-pgsql --build | |||
| 170 | + | ||||
| 171 | + | # Push with a specific tag | |||
| 172 | + | gordon attachments push pitlane-pgsql --tag v18 | |||
| 173 | + | ``` | |||
| 174 | + | ||||
| 175 | + | --- | |||
| 176 | + | ||||
| 131 | ## gordon attachments remove | 177 | ## gordon attachments remove | ||
| 132 | 178 | ||||
| 133 | Remove an attachment. | 179 | Remove an attachment. | ||
| @@ -194,6 +240,19 @@ gordon attachments add backend redis:7-alpine | |||||
| 194 | # Both app.example.com and api.example.com can now access the shared Redis | 240 | # Both app.example.com and api.example.com can now access the shared Redis | ||
| 195 | ``` | 241 | ``` | ||
| 196 | 242 | ||||
| 243 | + | ### First Deploy with Custom Attachment Image | |||
| 244 | + | ||||
| 245 | + | ```bash | |||
| 246 | + | # Configure the route and attachment | |||
| 247 | + | gordon bootstrap app.example.com myapp:latest --attachment pitlane-pgsql | |||
| 248 | + | ||||
| 249 | + | # Push the custom attachment image first | |||
| 250 | + | gordon attachments push pitlane-pgsql --build | |||
| 251 | + | ||||
| 252 | + | # Then push and deploy the route image | |||
| 253 | + | gordon push app.example.com --build --no-confirm | |||
| 254 | + | ``` | |||
| 255 | + | ||||
| 197 | ### CI/CD Integration | 256 | ### CI/CD Integration | ||
| 198 | 257 | ||||
| 199 | ```bash | 258 | ```bash | ||
M docs/cli/bootstrap.md +8 -0
| @@ -58,15 +58,23 @@ gordon bootstrap app.example.com myapp:latest \ | |||
| 58 | 58 | ||
| 59 | 59 | # Remote target | |
| 60 | 60 | gordon bootstrap app.example.com myapp:latest --remote https://gordon.mydomain.com --token $TOKEN | |
| 61 | + | ||
| 62 | + | # Push custom attachment image first | |
| 63 | + | gordon attachments push pitlane-pgsql --build | |
| 64 | + | ||
| 65 | + | # Then push and deploy the route image | |
| 66 | + | gordon push app.example.com --build --no-confirm | |
| 61 | 67 | ``` | |
| 62 | 68 | ||
| 63 | 69 | ### Notes | |
| 64 | 70 | ||
| 65 | 71 | - `gordon bootstrap` is idempotent for route configuration: rerunning it re-applies config instead of failing. | |
| 66 | 72 | - Run `gordon push` after bootstrap to upload and deploy the image. | |
| 73 | + | - If attachments use custom images that are not available from a public registry, push those attachment images with `gordon attachments push` before deploying the route image. | |
| 67 | 74 | ||
| 68 | 75 | ## Related | |
| 69 | 76 | ||
| 70 | 77 | - [CLI Overview](./index.md) | |
| 78 | + | - [Attachments Commands](./attachments.md) | |
| 71 | 79 | - [Routes Commands](./routes.md) | |
| 72 | 80 | - [Push Command](./push.md) | |
| @@ -58,15 +58,23 @@ gordon bootstrap app.example.com myapp:latest \ | |||||
| 58 | 58 | ||||
| 59 | # Remote target | 59 | # Remote target | ||
| 60 | gordon bootstrap app.example.com myapp:latest --remote https://gordon.mydomain.com --token $TOKEN | 60 | gordon bootstrap app.example.com myapp:latest --remote https://gordon.mydomain.com --token $TOKEN | ||
| 61 | + | ||||
| 62 | + | # Push custom attachment image first | |||
| 63 | + | gordon attachments push pitlane-pgsql --build | |||
| 64 | + | ||||
| 65 | + | # Then push and deploy the route image | |||
| 66 | + | gordon push app.example.com --build --no-confirm | |||
| 61 | ``` | 67 | ``` | ||
| 62 | 68 | ||||
| 63 | ### Notes | 69 | ### Notes | ||
| 64 | 70 | ||||
| 65 | - `gordon bootstrap` is idempotent for route configuration: rerunning it re-applies config instead of failing. | 71 | - `gordon bootstrap` is idempotent for route configuration: rerunning it re-applies config instead of failing. | ||
| 66 | - Run `gordon push` after bootstrap to upload and deploy the image. | 72 | - Run `gordon push` after bootstrap to upload and deploy the image. | ||
| 73 | + | - If attachments use custom images that are not available from a public registry, push those attachment images with `gordon attachments push` before deploying the route image. | |||
| 67 | 74 | ||||
| 68 | ## Related | 75 | ## Related | ||
| 69 | 76 | ||||
| 70 | - [CLI Overview](./index.md) | 77 | - [CLI Overview](./index.md) | ||
| 78 | + | - [Attachments Commands](./attachments.md) | |||
| 71 | - [Routes Commands](./routes.md) | 79 | - [Routes Commands](./routes.md) | ||
| 72 | - [Push Command](./push.md) | 80 | - [Push Command](./push.md) | ||
M docs/cli/index.md +1 -0
| @@ -30,6 +30,7 @@ Management commands run locally through in-process services by default. Add `--r | |||
| 30 | 30 | | `gordon restart` | Restart a running container | [restart](./restart.md) | | |
| 31 | 31 | | `gordon bootstrap` | Configure a route, attachments, and secrets for an app | [bootstrap](./bootstrap.md) | | |
| 32 | 32 | | `gordon push` | Tag, push, and optionally deploy an image | [push](./push.md) | | |
| 33 | + | | `gordon attachments push` | Build/push attachment images to registry | [attachments](./attachments.md) | | |
| 33 | 34 | | `gordon rollback` | Roll back to a previous image tag | [rollback](./rollback.md) | | |
| 34 | 35 | | `gordon reload` | Reload configuration and sync containers | [serve](./serve.md#gordon-reload) | | |
| 35 | 36 | | `gordon logs` | Display Gordon process or container logs | [serve](./serve.md#gordon-logs) | | |
| @@ -30,6 +30,7 @@ Management commands run locally through in-process services by default. Add `--r | |||||
| 30 | | `gordon restart` | Restart a running container | [restart](./restart.md) | | 30 | | `gordon restart` | Restart a running container | [restart](./restart.md) | | ||
| 31 | | `gordon bootstrap` | Configure a route, attachments, and secrets for an app | [bootstrap](./bootstrap.md) | | 31 | | `gordon bootstrap` | Configure a route, attachments, and secrets for an app | [bootstrap](./bootstrap.md) | | ||
| 32 | | `gordon push` | Tag, push, and optionally deploy an image | [push](./push.md) | | 32 | | `gordon push` | Tag, push, and optionally deploy an image | [push](./push.md) | | ||
| 33 | + | | `gordon attachments push` | Build/push attachment images to registry | [attachments](./attachments.md) | | |||
| 33 | | `gordon rollback` | Roll back to a previous image tag | [rollback](./rollback.md) | | 34 | | `gordon rollback` | Roll back to a previous image tag | [rollback](./rollback.md) | | ||
| 34 | | `gordon reload` | Reload configuration and sync containers | [serve](./serve.md#gordon-reload) | | 35 | | `gordon reload` | Reload configuration and sync containers | [serve](./serve.md#gordon-reload) | | ||
| 35 | | `gordon logs` | Display Gordon process or container logs | [serve](./serve.md#gordon-logs) | | 36 | | `gordon logs` | Display Gordon process or container logs | [serve](./serve.md#gordon-logs) | | ||
M docs/cli/push.md +3 -0
| @@ -35,6 +35,8 @@ gordon push <domain> [options] | |||
| 35 | 35 | `gordon push` resolves the route image for `<domain>`, tags the image for the | |
| 36 | 36 | Gordon registry, pushes it, and optionally deploys it. | |
| 37 | 37 | ||
| 38 | + | To push attachment images (databases, caches, etc.), use `gordon attachments push`. | |
| 39 | + | ||
| 38 | 40 | - For first deploys, run `gordon bootstrap` first to create or update the route, attachments, and secrets, then run `gordon push` to upload and deploy the image. | |
| 39 | 41 | - The registry and repository are derived from the route image on the server. | |
| 40 | 42 | - The version tag defaults to a CI tag ref (like `refs/tags/v1.2.3`) when available, | |
| @@ -74,5 +76,6 @@ gordon push myapp.example.com --build -f docker/app/Dockerfile | |||
| 74 | 76 | ## Related | |
| 75 | 77 | ||
| 76 | 78 | - [CLI Overview](./index.md) | |
| 79 | + | - [Attachments Commands](./attachments.md) | |
| 77 | 80 | - [Routes Command](./routes.md) | |
| 78 | 81 | - [Serve Command](./serve.md) | |
| @@ -35,6 +35,8 @@ gordon push <domain> [options] | |||||
| 35 | `gordon push` resolves the route image for `<domain>`, tags the image for the | 35 | `gordon push` resolves the route image for `<domain>`, tags the image for the | ||
| 36 | Gordon registry, pushes it, and optionally deploys it. | 36 | Gordon registry, pushes it, and optionally deploys it. | ||
| 37 | 37 | ||||
| 38 | + | To push attachment images (databases, caches, etc.), use `gordon attachments push`. | |||
| 39 | + | ||||
| 38 | - For first deploys, run `gordon bootstrap` first to create or update the route, attachments, and secrets, then run `gordon push` to upload and deploy the image. | 40 | - For first deploys, run `gordon bootstrap` first to create or update the route, attachments, and secrets, then run `gordon push` to upload and deploy the image. | ||
| 39 | - The registry and repository are derived from the route image on the server. | 41 | - The registry and repository are derived from the route image on the server. | ||
| 40 | - The version tag defaults to a CI tag ref (like `refs/tags/v1.2.3`) when available, | 42 | - The version tag defaults to a CI tag ref (like `refs/tags/v1.2.3`) when available, | ||
| @@ -74,5 +76,6 @@ gordon push myapp.example.com --build -f docker/app/Dockerfile | |||||
| 74 | ## Related | 76 | ## Related | ||
| 75 | 77 | ||||
| 76 | - [CLI Overview](./index.md) | 78 | - [CLI Overview](./index.md) | ||
| 79 | + | - [Attachments Commands](./attachments.md) | |||
| 77 | - [Routes Command](./routes.md) | 80 | - [Routes Command](./routes.md) | ||
| 78 | - [Serve Command](./serve.md) | 81 | - [Serve Command](./serve.md) | ||
M internal/adapters/dto/admin_attachments.go +6 -0
| @@ -12,6 +12,12 @@ type AttachmentConfigResponse struct { | |||
| 12 | 12 | Images []string `json:"images"` | |
| 13 | 13 | } | |
| 14 | 14 | ||
| 15 | + | // AttachmentTargetsByImageResponse represents attachment targets for a specific image. | |
| 16 | + | type AttachmentTargetsByImageResponse struct { | |
| 17 | + | Image string `json:"image"` | |
| 18 | + | Targets []string `json:"targets"` | |
| 19 | + | } | |
| 20 | + | ||
| 15 | 21 | // AttachmentAddRequest represents a request to add an attachment. | |
| 16 | 22 | type AttachmentAddRequest struct { | |
| 17 | 23 | Image string `json:"image"` | |
| @@ -12,6 +12,12 @@ type AttachmentConfigResponse struct { | |||||
| 12 | Images []string `json:"images"` | 12 | Images []string `json:"images"` | ||
| 13 | } | 13 | } | ||
| 14 | 14 | ||||
| 15 | + | // AttachmentTargetsByImageResponse represents attachment targets for a specific image. | |||
| 16 | + | type AttachmentTargetsByImageResponse struct { | |||
| 17 | + | Image string `json:"image"` | |||
| 18 | + | Targets []string `json:"targets"` | |||
| 19 | + | } | |||
| 20 | + | ||||
| 15 | // AttachmentAddRequest represents a request to add an attachment. | 21 | // AttachmentAddRequest represents a request to add an attachment. | ||
| 16 | type AttachmentAddRequest struct { | 22 | type AttachmentAddRequest struct { | ||
| 17 | Image string `json:"image"` | 23 | Image string `json:"image"` | ||
M internal/adapters/in/cli/attachments.go +1 -0
| @@ -32,6 +32,7 @@ the local Gordon configuration.`, | |||
| 32 | 32 | cmd.AddCommand(newAttachmentsListCmd()) | |
| 33 | 33 | cmd.AddCommand(newAttachmentsAddCmd()) | |
| 34 | 34 | cmd.AddCommand(newAttachmentsRemoveCmd()) | |
| 35 | + | cmd.AddCommand(newAttachmentsPushCmd()) | |
| 35 | 36 | ||
| 36 | 37 | return cmd | |
| 37 | 38 | } | |
| @@ -32,6 +32,7 @@ the local Gordon configuration.`, | |||||
| 32 | cmd.AddCommand(newAttachmentsListCmd()) | 32 | cmd.AddCommand(newAttachmentsListCmd()) | ||
| 33 | cmd.AddCommand(newAttachmentsAddCmd()) | 33 | cmd.AddCommand(newAttachmentsAddCmd()) | ||
| 34 | cmd.AddCommand(newAttachmentsRemoveCmd()) | 34 | cmd.AddCommand(newAttachmentsRemoveCmd()) | ||
| 35 | + | cmd.AddCommand(newAttachmentsPushCmd()) | |||
| 35 | 36 | ||||
| 36 | return cmd | 37 | return cmd | ||
| 37 | } | 38 | } | ||
A internal/adapters/in/cli/attachments_push.go +162 -0
| @@ -0,0 +1,162 @@ | |||
| 1 | + | package cli | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "fmt" | |
| 6 | + | "io" | |
| 7 | + | "sort" | |
| 8 | + | "strings" | |
| 9 | + | ||
| 10 | + | "github.com/spf13/cobra" | |
| 11 | + | ||
| 12 | + | "github.com/bnema/gordon/internal/adapters/in/cli/ui/styles" | |
| 13 | + | "github.com/bnema/gordon/pkg/validation" | |
| 14 | + | ) | |
| 15 | + | ||
| 16 | + | var ( | |
| 17 | + | resolveControlPlaneFn = resolveControlPlane | |
| 18 | + | tagAndPushFn = tagAndPush | |
| 19 | + | buildAndPushFn = buildAndPush | |
| 20 | + | determineVersionFn = determineVersion | |
| 21 | + | ) | |
| 22 | + | ||
| 23 | + | func newAttachmentsPushCmd() *cobra.Command { | |
| 24 | + | var ( | |
| 25 | + | build bool | |
| 26 | + | platform string | |
| 27 | + | tag string | |
| 28 | + | dockerfile string | |
| 29 | + | buildArgs []string | |
| 30 | + | ) | |
| 31 | + | ||
| 32 | + | cmd := &cobra.Command{ | |
| 33 | + | Use: "push <image>", | |
| 34 | + | Short: "Build, tag, and push an attachment image", | |
| 35 | + | Args: cobra.ExactArgs(1), | |
| 36 | + | RunE: func(cmd *cobra.Command, args []string) error { | |
| 37 | + | return runAttachmentsPush(cmd.Context(), args[0], tag, build, platform, dockerfile, buildArgs, cmd.OutOrStdout()) | |
| 38 | + | }, | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | cmd.Flags().BoolVar(&build, "build", false, "Build the image first using docker buildx") | |
| 42 | + | cmd.Flags().StringVar(&platform, "platform", "linux/amd64", "Target platform (used with --build)") | |
| 43 | + | cmd.Flags().StringVarP(&dockerfile, "file", "f", "", "Path to Dockerfile (default: ./Dockerfile, used with --build)") | |
| 44 | + | cmd.Flags().StringVar(&tag, "tag", "", "Override version tag (default: CI tag ref or git describe)") | |
| 45 | + | cmd.Flags().StringArrayVar(&buildArgs, "build-arg", nil, "Additional build args (used with --build)") | |
| 46 | + | ||
| 47 | + | return cmd | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | func runAttachmentsPush(ctx context.Context, imageArg, tag string, build bool, platform string, dockerfile string, buildArgs []string, out io.Writer) error { | |
| 51 | + | handle, err := resolveControlPlaneFn(configPath) | |
| 52 | + | if err != nil { | |
| 53 | + | return err | |
| 54 | + | } | |
| 55 | + | defer handle.close() | |
| 56 | + | ||
| 57 | + | dockerfile, err = resolveDockerfile(dockerfile, build) | |
| 58 | + | if err != nil { | |
| 59 | + | return err | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | for _, ba := range buildArgs { | |
| 63 | + | if err := validateBuildArg(ba); err != nil { | |
| 64 | + | return err | |
| 65 | + | } | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | registry, imageName, targets, err := resolveAttachmentImage(ctx, handle.plane, imageArg) | |
| 69 | + | if err != nil { | |
| 70 | + | return err | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | version, err := resolveVersionWithFn(ctx, tag) | |
| 74 | + | if err != nil { | |
| 75 | + | return err | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | versionRef, latestRef := resolveImageRefs(registry, imageName, version) | |
| 79 | + | ||
| 80 | + | cliWritef(out, "Attachment image: %s\n", styles.Theme.Bold.Render(imageArg)) | |
| 81 | + | cliWritef(out, "Targets: %s\n", styles.Theme.Bold.Render(formatAttachmentTargets(targets))) | |
| 82 | + | cliWritef(out, "Image: %s\n", styles.Theme.Bold.Render(versionRef)) | |
| 83 | + | if version != "latest" { | |
| 84 | + | cliWritef(out, "Also: %s\n", styles.Theme.Bold.Render(latestRef)) | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | if build { | |
| 88 | + | if err := buildAndPushFn(ctx, version, platform, dockerfile, buildArgs, versionRef, latestRef); err != nil { | |
| 89 | + | return err | |
| 90 | + | } | |
| 91 | + | } else { | |
| 92 | + | if err := tagAndPushFn(ctx, registry, imageName, version, versionRef, latestRef); err != nil { | |
| 93 | + | return err | |
| 94 | + | } | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | cliWriteLine(out, styles.RenderSuccess("Push complete")) | |
| 98 | + | return nil | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | func resolveAttachmentImage(ctx context.Context, cp ControlPlane, imageArg string) (registry, imageName string, targets []string, err error) { | |
| 102 | + | imageName = normalizeAttachmentImageName(imageArg) | |
| 103 | + | ||
| 104 | + | targets, err = cp.FindAttachmentTargetsByImage(ctx, imageArg) | |
| 105 | + | if err != nil { | |
| 106 | + | return "", "", nil, fmt.Errorf("failed to find attachment targets for image %q: %w", imageArg, err) | |
| 107 | + | } | |
| 108 | + | if len(targets) == 0 { | |
| 109 | + | return "", "", nil, fmt.Errorf("image %q is not configured as an attachment", imageArg) | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | status, err := cp.GetStatus(ctx) | |
| 113 | + | if err != nil { | |
| 114 | + | return "", "", nil, fmt.Errorf("failed to get status: %w", err) | |
| 115 | + | } | |
| 116 | + | ||
| 117 | + | return status.RegistryDomain, imageName, targets, nil | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | func normalizeAttachmentImageName(imageArg string) string { | |
| 121 | + | name := imageArg | |
| 122 | + | if idx := strings.Index(name, "@"); idx != -1 { | |
| 123 | + | name = name[:idx] | |
| 124 | + | } | |
| 125 | + | if idx := strings.LastIndex(name, ":"); idx != -1 { | |
| 126 | + | slashIdx := strings.LastIndex(name, "/") | |
| 127 | + | if idx > slashIdx { | |
| 128 | + | name = name[:idx] | |
| 129 | + | } | |
| 130 | + | } | |
| 131 | + | parts := strings.SplitN(name, "/", 2) | |
| 132 | + | if len(parts) == 2 { | |
| 133 | + | host := parts[0] | |
| 134 | + | if strings.ContainsAny(host, ".:") || host == "localhost" { | |
| 135 | + | return parts[1] | |
| 136 | + | } | |
| 137 | + | } | |
| 138 | + | return name | |
| 139 | + | } | |
| 140 | + | ||
| 141 | + | func formatAttachmentTargets(targets []string) string { | |
| 142 | + | cloned := append([]string(nil), targets...) | |
| 143 | + | sort.Strings(cloned) | |
| 144 | + | return fmt.Sprintf("%v", cloned) | |
| 145 | + | } | |
| 146 | + | ||
| 147 | + | func resolveVersionWithFn(ctx context.Context, tag string) (string, error) { | |
| 148 | + | version := determineVersionFn(ctx, tag) | |
| 149 | + | if version != "latest" { | |
| 150 | + | if err := validateVersionTag(version); err != nil { | |
| 151 | + | return "", err | |
| 152 | + | } | |
| 153 | + | } | |
| 154 | + | return version, nil | |
| 155 | + | } | |
| 156 | + | ||
| 157 | + | func validateVersionTag(version string) error { | |
| 158 | + | if err := validation.ValidateReference(version); err != nil { | |
| 159 | + | return fmt.Errorf("invalid version tag %q: %w", version, err) | |
| 160 | + | } | |
| 161 | + | return nil | |
| 162 | + | } | |
| @@ -0,0 +1,162 @@ | |||||
| 1 | + | package cli | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "fmt" | |||
| 6 | + | "io" | |||
| 7 | + | "sort" | |||
| 8 | + | "strings" | |||
| 9 | + | ||||
| 10 | + | "github.com/spf13/cobra" | |||
| 11 | + | ||||
| 12 | + | "github.com/bnema/gordon/internal/adapters/in/cli/ui/styles" | |||
| 13 | + | "github.com/bnema/gordon/pkg/validation" | |||
| 14 | + | ) | |||
| 15 | + | ||||
| 16 | + | var ( | |||
| 17 | + | resolveControlPlaneFn = resolveControlPlane | |||
| 18 | + | tagAndPushFn = tagAndPush | |||
| 19 | + | buildAndPushFn = buildAndPush | |||
| 20 | + | determineVersionFn = determineVersion | |||
| 21 | + | ) | |||
| 22 | + | ||||
| 23 | + | func newAttachmentsPushCmd() *cobra.Command { | |||
| 24 | + | var ( | |||
| 25 | + | build bool | |||
| 26 | + | platform string | |||
| 27 | + | tag string | |||
| 28 | + | dockerfile string | |||
| 29 | + | buildArgs []string | |||
| 30 | + | ) | |||
| 31 | + | ||||
| 32 | + | cmd := &cobra.Command{ | |||
| 33 | + | Use: "push <image>", | |||
| 34 | + | Short: "Build, tag, and push an attachment image", | |||
| 35 | + | Args: cobra.ExactArgs(1), | |||
| 36 | + | RunE: func(cmd *cobra.Command, args []string) error { | |||
| 37 | + | return runAttachmentsPush(cmd.Context(), args[0], tag, build, platform, dockerfile, buildArgs, cmd.OutOrStdout()) | |||
| 38 | + | }, | |||
| 39 | + | } | |||
| 40 | + | ||||
| 41 | + | cmd.Flags().BoolVar(&build, "build", false, "Build the image first using docker buildx") | |||
| 42 | + | cmd.Flags().StringVar(&platform, "platform", "linux/amd64", "Target platform (used with --build)") | |||
| 43 | + | cmd.Flags().StringVarP(&dockerfile, "file", "f", "", "Path to Dockerfile (default: ./Dockerfile, used with --build)") | |||
| 44 | + | cmd.Flags().StringVar(&tag, "tag", "", "Override version tag (default: CI tag ref or git describe)") | |||
| 45 | + | cmd.Flags().StringArrayVar(&buildArgs, "build-arg", nil, "Additional build args (used with --build)") | |||
| 46 | + | ||||
| 47 | + | return cmd | |||
| 48 | + | } | |||
| 49 | + | ||||
| 50 | + | func runAttachmentsPush(ctx context.Context, imageArg, tag string, build bool, platform string, dockerfile string, buildArgs []string, out io.Writer) error { | |||
| 51 | + | handle, err := resolveControlPlaneFn(configPath) | |||
| 52 | + | if err != nil { | |||
| 53 | + | return err | |||
| 54 | + | } | |||
| 55 | + | defer handle.close() | |||
| 56 | + | ||||
| 57 | + | dockerfile, err = resolveDockerfile(dockerfile, build) | |||
| 58 | + | if err != nil { | |||
| 59 | + | return err | |||
| 60 | + | } | |||
| 61 | + | ||||
| 62 | + | for _, ba := range buildArgs { | |||
| 63 | + | if err := validateBuildArg(ba); err != nil { | |||
| 64 | + | return err | |||
| 65 | + | } | |||
| 66 | + | } | |||
| 67 | + | ||||
| 68 | + | registry, imageName, targets, err := resolveAttachmentImage(ctx, handle.plane, imageArg) | |||
| 69 | + | if err != nil { | |||
| 70 | + | return err | |||
| 71 | + | } | |||
| 72 | + | ||||
| 73 | + | version, err := resolveVersionWithFn(ctx, tag) | |||
| 74 | + | if err != nil { | |||
| 75 | + | return err | |||
| 76 | + | } | |||
| 77 | + | ||||
| 78 | + | versionRef, latestRef := resolveImageRefs(registry, imageName, version) | |||
| 79 | + | ||||
| 80 | + | cliWritef(out, "Attachment image: %s\n", styles.Theme.Bold.Render(imageArg)) | |||
| 81 | + | cliWritef(out, "Targets: %s\n", styles.Theme.Bold.Render(formatAttachmentTargets(targets))) | |||
| 82 | + | cliWritef(out, "Image: %s\n", styles.Theme.Bold.Render(versionRef)) | |||
| 83 | + | if version != "latest" { | |||
| 84 | + | cliWritef(out, "Also: %s\n", styles.Theme.Bold.Render(latestRef)) | |||
| 85 | + | } | |||
| 86 | + | ||||
| 87 | + | if build { | |||
| 88 | + | if err := buildAndPushFn(ctx, version, platform, dockerfile, buildArgs, versionRef, latestRef); err != nil { | |||
| 89 | + | return err | |||
| 90 | + | } | |||
| 91 | + | } else { | |||
| 92 | + | if err := tagAndPushFn(ctx, registry, imageName, version, versionRef, latestRef); err != nil { | |||
| 93 | + | return err | |||
| 94 | + | } | |||
| 95 | + | } | |||
| 96 | + | ||||
| 97 | + | cliWriteLine(out, styles.RenderSuccess("Push complete")) | |||
| 98 | + | return nil | |||
| 99 | + | } | |||
| 100 | + | ||||
| 101 | + | func resolveAttachmentImage(ctx context.Context, cp ControlPlane, imageArg string) (registry, imageName string, targets []string, err error) { | |||
| 102 | + | imageName = normalizeAttachmentImageName(imageArg) | |||
| 103 | + | ||||
| 104 | + | targets, err = cp.FindAttachmentTargetsByImage(ctx, imageArg) | |||
| 105 | + | if err != nil { | |||
| 106 | + | return "", "", nil, fmt.Errorf("failed to find attachment targets for image %q: %w", imageArg, err) | |||
| 107 | + | } | |||
| 108 | + | if len(targets) == 0 { | |||
| 109 | + | return "", "", nil, fmt.Errorf("image %q is not configured as an attachment", imageArg) | |||
| 110 | + | } | |||
| 111 | + | ||||
| 112 | + | status, err := cp.GetStatus(ctx) | |||
| 113 | + | if err != nil { | |||
| 114 | + | return "", "", nil, fmt.Errorf("failed to get status: %w", err) | |||
| 115 | + | } | |||
| 116 | + | ||||
| 117 | + | return status.RegistryDomain, imageName, targets, nil | |||
| 118 | + | } | |||
| 119 | + | ||||
| 120 | + | func normalizeAttachmentImageName(imageArg string) string { | |||
| 121 | + | name := imageArg | |||
| 122 | + | if idx := strings.Index(name, "@"); idx != -1 { | |||
| 123 | + | name = name[:idx] | |||
| 124 | + | } | |||
| 125 | + | if idx := strings.LastIndex(name, ":"); idx != -1 { | |||
| 126 | + | slashIdx := strings.LastIndex(name, "/") | |||
| 127 | + | if idx > slashIdx { | |||
| 128 | + | name = name[:idx] | |||
| 129 | + | } | |||
| 130 | + | } | |||
| 131 | + | parts := strings.SplitN(name, "/", 2) | |||
| 132 | + | if len(parts) == 2 { | |||
| 133 | + | host := parts[0] | |||
| 134 | + | if strings.ContainsAny(host, ".:") || host == "localhost" { | |||
| 135 | + | return parts[1] | |||
| 136 | + | } | |||
| 137 | + | } | |||
| 138 | + | return name | |||
| 139 | + | } | |||
| 140 | + | ||||
| 141 | + | func formatAttachmentTargets(targets []string) string { | |||
| 142 | + | cloned := append([]string(nil), targets...) | |||
| 143 | + | sort.Strings(cloned) | |||
| 144 | + | return fmt.Sprintf("%v", cloned) | |||
| 145 | + | } | |||
| 146 | + | ||||
| 147 | + | func resolveVersionWithFn(ctx context.Context, tag string) (string, error) { | |||
| 148 | + | version := determineVersionFn(ctx, tag) | |||
| 149 | + | if version != "latest" { | |||
| 150 | + | if err := validateVersionTag(version); err != nil { | |||
| 151 | + | return "", err | |||
| 152 | + | } | |||
| 153 | + | } | |||
| 154 | + | return version, nil | |||
| 155 | + | } | |||
| 156 | + | ||||
| 157 | + | func validateVersionTag(version string) error { | |||
| 158 | + | if err := validation.ValidateReference(version); err != nil { | |||
| 159 | + | return fmt.Errorf("invalid version tag %q: %w", version, err) | |||
| 160 | + | } | |||
| 161 | + | return nil | |||
| 162 | + | } | |||
A internal/adapters/in/cli/attachments_push_test.go +303 -0
| @@ -0,0 +1,303 @@ | |||
| 1 | + | package cli | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bytes" | |
| 5 | + | "context" | |
| 6 | + | "testing" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/gordon/internal/adapters/dto" | |
| 9 | + | "github.com/bnema/gordon/internal/adapters/in/cli/remote" | |
| 10 | + | "github.com/bnema/gordon/internal/domain" | |
| 11 | + | "github.com/stretchr/testify/assert" | |
| 12 | + | "github.com/stretchr/testify/require" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | type attachmentPushTestControlPlane struct { | |
| 16 | + | findAttachmentTargets func(context.Context, string) ([]string, error) | |
| 17 | + | getStatus func(context.Context) (*remote.Status, error) | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | var _ ControlPlane = (*attachmentPushTestControlPlane)(nil) | |
| 21 | + | ||
| 22 | + | func (c *attachmentPushTestControlPlane) ListRoutesWithDetails(context.Context) ([]remote.RouteInfo, error) { | |
| 23 | + | panic("unexpected call") | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | func (c *attachmentPushTestControlPlane) GetHealth(context.Context) (map[string]*remote.RouteHealth, error) { | |
| 27 | + | panic("unexpected call") | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | func (c *attachmentPushTestControlPlane) GetRoute(context.Context, string) (*domain.Route, error) { | |
| 31 | + | panic("unexpected call") | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | func (c *attachmentPushTestControlPlane) FindRoutesByImage(context.Context, string) ([]domain.Route, error) { | |
| 35 | + | panic("unexpected call") | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | func (c *attachmentPushTestControlPlane) AddRoute(context.Context, domain.Route) error { | |
| 39 | + | panic("unexpected call") | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | func (c *attachmentPushTestControlPlane) UpdateRoute(context.Context, domain.Route) error { | |
| 43 | + | panic("unexpected call") | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | func (c *attachmentPushTestControlPlane) RemoveRoute(context.Context, string) error { | |
| 47 | + | panic("unexpected call") | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | func (c *attachmentPushTestControlPlane) Bootstrap(context.Context, dto.BootstrapRequest) (*dto.BootstrapResponse, error) { | |
| 51 | + | panic("unexpected call") | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | func (c *attachmentPushTestControlPlane) ListSecretsWithAttachments(context.Context, string) (*remote.SecretsListResult, error) { | |
| 55 | + | panic("unexpected call") | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | func (c *attachmentPushTestControlPlane) SetSecrets(context.Context, string, map[string]string) error { | |
| 59 | + | panic("unexpected call") | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | func (c *attachmentPushTestControlPlane) DeleteSecret(context.Context, string, string) error { | |
| 63 | + | panic("unexpected call") | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | func (c *attachmentPushTestControlPlane) SetAttachmentSecrets(context.Context, string, string, map[string]string) error { | |
| 67 | + | panic("unexpected call") | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | func (c *attachmentPushTestControlPlane) DeleteAttachmentSecret(context.Context, string, string, string) error { | |
| 71 | + | panic("unexpected call") | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | func (c *attachmentPushTestControlPlane) GetAllAttachmentsConfig(context.Context) (map[string][]string, error) { | |
| 75 | + | panic("unexpected call") | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | func (c *attachmentPushTestControlPlane) GetAttachmentsConfig(context.Context, string) ([]string, error) { | |
| 79 | + | panic("unexpected call") | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | func (c *attachmentPushTestControlPlane) FindAttachmentTargetsByImage(ctx context.Context, imageName string) ([]string, error) { | |
| 83 | + | if c.findAttachmentTargets != nil { | |
| 84 | + | return c.findAttachmentTargets(ctx, imageName) | |
| 85 | + | } | |
| 86 | + | return nil, nil | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | func (c *attachmentPushTestControlPlane) AddAttachment(context.Context, string, string) error { | |
| 90 | + | panic("unexpected call") | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | func (c *attachmentPushTestControlPlane) RemoveAttachment(context.Context, string, string) error { | |
| 94 | + | panic("unexpected call") | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | func (c *attachmentPushTestControlPlane) GetStatus(ctx context.Context) (*remote.Status, error) { | |
| 98 | + | if c.getStatus != nil { | |
| 99 | + | return c.getStatus(ctx) | |
| 100 | + | } | |
| 101 | + | panic("unexpected call") | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | func (c *attachmentPushTestControlPlane) Reload(context.Context) error { | |
| 105 | + | panic("unexpected call") | |
| 106 | + | } | |
| 107 | + | ||
| 108 | + | func (c *attachmentPushTestControlPlane) ListNetworks(context.Context) ([]*domain.NetworkInfo, error) { | |
| 109 | + | panic("unexpected call") | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | func (c *attachmentPushTestControlPlane) GetConfig(context.Context) (*remote.Config, error) { | |
| 113 | + | panic("unexpected call") | |
| 114 | + | } | |
| 115 | + | ||
| 116 | + | func (c *attachmentPushTestControlPlane) DeployIntent(context.Context, string) error { | |
| 117 | + | panic("unexpected call") | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | func (c *attachmentPushTestControlPlane) Deploy(context.Context, string) (*remote.DeployResult, error) { | |
| 121 | + | panic("unexpected call") | |
| 122 | + | } | |
| 123 | + | ||
| 124 | + | func (c *attachmentPushTestControlPlane) Restart(context.Context, string, bool) (*remote.RestartResult, error) { | |
| 125 | + | panic("unexpected call") | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | func (c *attachmentPushTestControlPlane) ListTags(context.Context, string) ([]string, error) { | |
| 129 | + | panic("unexpected call") | |
| 130 | + | } | |
| 131 | + | ||
| 132 | + | func (c *attachmentPushTestControlPlane) ListBackups(context.Context, string) ([]dto.BackupJob, error) { | |
| 133 | + | panic("unexpected call") | |
| 134 | + | } | |
| 135 | + | ||
| 136 | + | func (c *attachmentPushTestControlPlane) BackupStatus(context.Context) ([]dto.BackupJob, error) { | |
| 137 | + | panic("unexpected call") | |
| 138 | + | } | |
| 139 | + | ||
| 140 | + | func (c *attachmentPushTestControlPlane) RunBackup(context.Context, string, string) (*dto.BackupRunResponse, error) { | |
| 141 | + | panic("unexpected call") | |
| 142 | + | } | |
| 143 | + | ||
| 144 | + | func (c *attachmentPushTestControlPlane) DetectDatabases(context.Context, string) ([]dto.DatabaseInfo, error) { | |
| 145 | + | panic("unexpected call") | |
| 146 | + | } | |
| 147 | + | ||
| 148 | + | func (c *attachmentPushTestControlPlane) GetProcessLogs(context.Context, int) ([]string, error) { | |
| 149 | + | panic("unexpected call") | |
| 150 | + | } | |
| 151 | + | ||
| 152 | + | func (c *attachmentPushTestControlPlane) GetContainerLogs(context.Context, string, int) ([]string, error) { | |
| 153 | + | panic("unexpected call") | |
| 154 | + | } | |
| 155 | + | ||
| 156 | + | func (c *attachmentPushTestControlPlane) StreamProcessLogs(context.Context, int) (<-chan string, error) { | |
| 157 | + | panic("unexpected call") | |
| 158 | + | } | |
| 159 | + | ||
| 160 | + | func (c *attachmentPushTestControlPlane) StreamContainerLogs(context.Context, string, int) (<-chan string, error) { | |
| 161 | + | panic("unexpected call") | |
| 162 | + | } | |
| 163 | + | ||
| 164 | + | func TestResolveAttachmentImage_OneTarget(t *testing.T) { | |
| 165 | + | cp := &attachmentPushTestControlPlane{ | |
| 166 | + | findAttachmentTargets: func(context.Context, string) ([]string, error) { | |
| 167 | + | return []string{"app.example.com"}, nil | |
| 168 | + | }, | |
| 169 | + | getStatus: func(context.Context) (*remote.Status, error) { | |
| 170 | + | return &remote.Status{RegistryDomain: "registry.example.com"}, nil | |
| 171 | + | }, | |
| 172 | + | } | |
| 173 | + | ||
| 174 | + | registry, imageName, targets, err := resolveAttachmentImage(context.Background(), cp, "postgres") | |
| 175 | + | ||
| 176 | + | require.NoError(t, err) | |
| 177 | + | assert.Equal(t, "registry.example.com", registry) | |
| 178 | + | assert.Equal(t, "postgres", imageName) | |
| 179 | + | assert.Equal(t, []string{"app.example.com"}, targets) | |
| 180 | + | } | |
| 181 | + | ||
| 182 | + | func TestResolveAttachmentImage_MultipleTargets(t *testing.T) { | |
| 183 | + | cp := &attachmentPushTestControlPlane{ | |
| 184 | + | findAttachmentTargets: func(context.Context, string) ([]string, error) { | |
| 185 | + | return []string{"app.example.com", "backend"}, nil | |
| 186 | + | }, | |
| 187 | + | getStatus: func(context.Context) (*remote.Status, error) { | |
| 188 | + | return &remote.Status{RegistryDomain: "registry.example.com"}, nil | |
| 189 | + | }, | |
| 190 | + | } | |
| 191 | + | ||
| 192 | + | registry, imageName, targets, err := resolveAttachmentImage(context.Background(), cp, "redis") | |
| 193 | + | ||
| 194 | + | require.NoError(t, err) | |
| 195 | + | assert.Equal(t, "registry.example.com", registry) | |
| 196 | + | assert.Equal(t, "redis", imageName) | |
| 197 | + | assert.Equal(t, []string{"app.example.com", "backend"}, targets) | |
| 198 | + | } | |
| 199 | + | ||
| 200 | + | func TestResolveAttachmentImage_NotConfigured(t *testing.T) { | |
| 201 | + | cp := &attachmentPushTestControlPlane{ | |
| 202 | + | findAttachmentTargets: func(context.Context, string) ([]string, error) { | |
| 203 | + | return nil, nil | |
| 204 | + | }, | |
| 205 | + | } | |
| 206 | + | ||
| 207 | + | _, _, _, err := resolveAttachmentImage(context.Background(), cp, "postgres") | |
| 208 | + | ||
| 209 | + | require.Error(t, err) | |
| 210 | + | assert.Contains(t, err.Error(), "not configured as an attachment") | |
| 211 | + | } | |
| 212 | + | ||
| 213 | + | func TestAttachmentPushCmd_NoDeploy(t *testing.T) { | |
| 214 | + | origResolveControlPlane := resolveControlPlaneFn | |
| 215 | + | origTagAndPush := tagAndPushFn | |
| 216 | + | origDetermineVersion := determineVersionFn | |
| 217 | + | t.Cleanup(func() { | |
| 218 | + | resolveControlPlaneFn = origResolveControlPlane | |
| 219 | + | tagAndPushFn = origTagAndPush | |
| 220 | + | determineVersionFn = origDetermineVersion | |
| 221 | + | }) | |
| 222 | + | ||
| 223 | + | cp := &attachmentPushTestControlPlane{ | |
| 224 | + | findAttachmentTargets: func(context.Context, string) ([]string, error) { | |
| 225 | + | return []string{"app.example.com"}, nil | |
| 226 | + | }, | |
| 227 | + | getStatus: func(context.Context) (*remote.Status, error) { | |
| 228 | + | return &remote.Status{RegistryDomain: "registry.example.com"}, nil | |
| 229 | + | }, | |
| 230 | + | } | |
| 231 | + | ||
| 232 | + | resolveControlPlaneFn = func(string) (*controlPlaneHandle, error) { | |
| 233 | + | return &controlPlaneHandle{plane: cp}, nil | |
| 234 | + | } | |
| 235 | + | determineVersionFn = func(context.Context, string) string { return "v1.2.3" } | |
| 236 | + | called := false | |
| 237 | + | tagAndPushFn = func(context.Context, string, string, string, string, string) error { | |
| 238 | + | called = true | |
| 239 | + | return nil | |
| 240 | + | } | |
| 241 | + | ||
| 242 | + | cmd := newAttachmentsPushCmd() | |
| 243 | + | out := new(bytes.Buffer) | |
| 244 | + | cmd.SetOut(out) | |
| 245 | + | cmd.SetErr(out) | |
| 246 | + | cmd.SetArgs([]string{"postgres"}) | |
| 247 | + | ||
| 248 | + | err := cmd.Execute() | |
| 249 | + | ||
| 250 | + | require.NoError(t, err) | |
| 251 | + | assert.True(t, called) | |
| 252 | + | assert.Contains(t, out.String(), "Push complete") | |
| 253 | + | } | |
| 254 | + | ||
| 255 | + | func TestAttachmentPushCmd_TaggedImageInputBuildsValidRefs(t *testing.T) { | |
| 256 | + | origResolveControlPlane := resolveControlPlaneFn | |
| 257 | + | origTagAndPush := tagAndPushFn | |
| 258 | + | origDetermineVersion := determineVersionFn | |
| 259 | + | t.Cleanup(func() { | |
| 260 | + | resolveControlPlaneFn = origResolveControlPlane | |
| 261 | + | tagAndPushFn = origTagAndPush | |
| 262 | + | determineVersionFn = origDetermineVersion | |
| 263 | + | }) | |
| 264 | + | ||
| 265 | + | cp := &attachmentPushTestControlPlane{ | |
| 266 | + | findAttachmentTargets: func(context.Context, string) ([]string, error) { | |
| 267 | + | return []string{"app.example.com"}, nil | |
| 268 | + | }, | |
| 269 | + | getStatus: func(context.Context) (*remote.Status, error) { | |
| 270 | + | return &remote.Status{RegistryDomain: "registry.example.com"}, nil | |
| 271 | + | }, | |
| 272 | + | } | |
| 273 | + | ||
| 274 | + | resolveControlPlaneFn = func(string) (*controlPlaneHandle, error) { | |
| 275 | + | return &controlPlaneHandle{plane: cp}, nil | |
| 276 | + | } | |
| 277 | + | determineVersionFn = func(context.Context, string) string { return "v1.2.3" } | |
| 278 | + | ||
| 279 | + | var gotVersionRef string | |
| 280 | + | var gotLatestRef string | |
| 281 | + | tagAndPushFn = func(_ context.Context, registry, imageName, version, versionRef, latestRef string) error { | |
| 282 | + | gotVersionRef = versionRef | |
| 283 | + | gotLatestRef = latestRef | |
| 284 | + | return nil | |
| 285 | + | } | |
| 286 | + | ||
| 287 | + | cmd := newAttachmentsPushCmd() | |
| 288 | + | cmd.SetOut(new(bytes.Buffer)) | |
| 289 | + | cmd.SetErr(new(bytes.Buffer)) | |
| 290 | + | cmd.SetArgs([]string{"postgres:18"}) | |
| 291 | + | ||
| 292 | + | err := cmd.Execute() | |
| 293 | + | ||
| 294 | + | require.NoError(t, err) | |
| 295 | + | assert.Equal(t, "registry.example.com/postgres:v1.2.3", gotVersionRef) | |
| 296 | + | assert.Equal(t, "registry.example.com/postgres:latest", gotLatestRef) | |
| 297 | + | } | |
| 298 | + | ||
| 299 | + | func TestNormalizeAttachmentImageName_PreservesNamespaceWithoutRegistryHost(t *testing.T) { | |
| 300 | + | got := normalizeAttachmentImageName("myorg/postgres:18") | |
| 301 | + | ||
| 302 | + | assert.Equal(t, "myorg/postgres", got) | |
| 303 | + | } | |
| @@ -0,0 +1,303 @@ | |||||
| 1 | + | package cli | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "bytes" | |||
| 5 | + | "context" | |||
| 6 | + | "testing" | |||
| 7 | + | ||||
| 8 | + | "github.com/bnema/gordon/internal/adapters/dto" | |||
| 9 | + | "github.com/bnema/gordon/internal/adapters/in/cli/remote" | |||
| 10 | + | "github.com/bnema/gordon/internal/domain" | |||
| 11 | + | "github.com/stretchr/testify/assert" | |||
| 12 | + | "github.com/stretchr/testify/require" | |||
| 13 | + | ) | |||
| 14 | + | ||||
| 15 | + | type attachmentPushTestControlPlane struct { | |||
| 16 | + | findAttachmentTargets func(context.Context, string) ([]string, error) | |||
| 17 | + | getStatus func(context.Context) (*remote.Status, error) | |||
| 18 | + | } | |||
| 19 | + | ||||
| 20 | + | var _ ControlPlane = (*attachmentPushTestControlPlane)(nil) | |||
| 21 | + | ||||
| 22 | + | func (c *attachmentPushTestControlPlane) ListRoutesWithDetails(context.Context) ([]remote.RouteInfo, error) { | |||
| 23 | + | panic("unexpected call") | |||
| 24 | + | } | |||
| 25 | + | ||||
| 26 | + | func (c *attachmentPushTestControlPlane) GetHealth(context.Context) (map[string]*remote.RouteHealth, error) { | |||
| 27 | + | panic("unexpected call") | |||
| 28 | + | } | |||
| 29 | + | ||||
| 30 | + | func (c *attachmentPushTestControlPlane) GetRoute(context.Context, string) (*domain.Route, error) { | |||
| 31 | + | panic("unexpected call") | |||
| 32 | + | } | |||
| 33 | + | ||||
| 34 | + | func (c *attachmentPushTestControlPlane) FindRoutesByImage(context.Context, string) ([]domain.Route, error) { | |||
| 35 | + | panic("unexpected call") | |||
| 36 | + | } | |||
| 37 | + | ||||
| 38 | + | func (c *attachmentPushTestControlPlane) AddRoute(context.Context, domain.Route) error { | |||
| 39 | + | panic("unexpected call") | |||
| 40 | + | } | |||
| 41 | + | ||||
| 42 | + | func (c *attachmentPushTestControlPlane) UpdateRoute(context.Context, domain.Route) error { | |||
| 43 | + | panic("unexpected call") | |||
| 44 | + | } | |||
| 45 | + | ||||
| 46 | + | func (c *attachmentPushTestControlPlane) RemoveRoute(context.Context, string) error { | |||
| 47 | + | panic("unexpected call") | |||
| 48 | + | } | |||
| 49 | + | ||||
| 50 | + | func (c *attachmentPushTestControlPlane) Bootstrap(context.Context, dto.BootstrapRequest) (*dto.BootstrapResponse, error) { | |||
| 51 | + | panic("unexpected call") | |||
| 52 | + | } | |||
| 53 | + | ||||
| 54 | + | func (c *attachmentPushTestControlPlane) ListSecretsWithAttachments(context.Context, string) (*remote.SecretsListResult, error) { | |||
| 55 | + | panic("unexpected call") | |||
| 56 | + | } | |||
| 57 | + | ||||
| 58 | + | func (c *attachmentPushTestControlPlane) SetSecrets(context.Context, string, map[string]string) error { | |||
| 59 | + | panic("unexpected call") | |||
| 60 | + | } | |||
| 61 | + | ||||
| 62 | + | func (c *attachmentPushTestControlPlane) DeleteSecret(context.Context, string, string) error { | |||
| 63 | + | panic("unexpected call") | |||
| 64 | + | } | |||
| 65 | + | ||||
| 66 | + | func (c *attachmentPushTestControlPlane) SetAttachmentSecrets(context.Context, string, string, map[string]string) error { | |||
| 67 | + | panic("unexpected call") | |||
| 68 | + | } | |||
| 69 | + | ||||
| 70 | + | func (c *attachmentPushTestControlPlane) DeleteAttachmentSecret(context.Context, string, string, string) error { | |||
| 71 | + | panic("unexpected call") | |||
| 72 | + | } | |||
| 73 | + | ||||
| 74 | + | func (c *attachmentPushTestControlPlane) GetAllAttachmentsConfig(context.Context) (map[string][]string, error) { | |||
| 75 | + | panic("unexpected call") | |||
| 76 | + | } | |||
| 77 | + | ||||
| 78 | + | func (c *attachmentPushTestControlPlane) GetAttachmentsConfig(context.Context, string) ([]string, error) { | |||
| 79 | + | panic("unexpected call") | |||
| 80 | + | } | |||
| 81 | + | ||||
| 82 | + | func (c *attachmentPushTestControlPlane) FindAttachmentTargetsByImage(ctx context.Context, imageName string) ([]string, error) { | |||
| 83 | + | if c.findAttachmentTargets != nil { | |||
| 84 | + | return c.findAttachmentTargets(ctx, imageName) | |||
| 85 | + | } | |||
| 86 | + | return nil, nil | |||
| 87 | + | } | |||
| 88 | + | ||||
| 89 | + | func (c *attachmentPushTestControlPlane) AddAttachment(context.Context, string, string) error { | |||
| 90 | + | panic("unexpected call") | |||
| 91 | + | } | |||
| 92 | + | ||||
| 93 | + | func (c *attachmentPushTestControlPlane) RemoveAttachment(context.Context, string, string) error { | |||
| 94 | + | panic("unexpected call") | |||
| 95 | + | } | |||
| 96 | + | ||||
| 97 | + | func (c *attachmentPushTestControlPlane) GetStatus(ctx context.Context) (*remote.Status, error) { | |||
| 98 | + | if c.getStatus != nil { | |||
| 99 | + | return c.getStatus(ctx) | |||
| 100 | + | } | |||
| 101 | + | panic("unexpected call") | |||
| 102 | + | } | |||
| 103 | + | ||||
| 104 | + | func (c *attachmentPushTestControlPlane) Reload(context.Context) error { | |||
| 105 | + | panic("unexpected call") | |||
| 106 | + | } | |||
| 107 | + | ||||
| 108 | + | func (c *attachmentPushTestControlPlane) ListNetworks(context.Context) ([]*domain.NetworkInfo, error) { | |||
| 109 | + | panic("unexpected call") | |||
| 110 | + | } | |||
| 111 | + | ||||
| 112 | + | func (c *attachmentPushTestControlPlane) GetConfig(context.Context) (*remote.Config, error) { | |||
| 113 | + | panic("unexpected call") | |||
| 114 | + | } | |||
| 115 | + | ||||
| 116 | + | func (c *attachmentPushTestControlPlane) DeployIntent(context.Context, string) error { | |||
| 117 | + | panic("unexpected call") | |||
| 118 | + | } | |||
| 119 | + | ||||
| 120 | + | func (c *attachmentPushTestControlPlane) Deploy(context.Context, string) (*remote.DeployResult, error) { | |||
| 121 | + | panic("unexpected call") | |||
| 122 | + | } | |||
| 123 | + | ||||
| 124 | + | func (c *attachmentPushTestControlPlane) Restart(context.Context, string, bool) (*remote.RestartResult, error) { | |||
| 125 | + | panic("unexpected call") | |||
| 126 | + | } | |||
| 127 | + | ||||
| 128 | + | func (c *attachmentPushTestControlPlane) ListTags(context.Context, string) ([]string, error) { | |||
| 129 | + | panic("unexpected call") | |||
| 130 | + | } | |||
| 131 | + | ||||
| 132 | + | func (c *attachmentPushTestControlPlane) ListBackups(context.Context, string) ([]dto.BackupJob, error) { | |||
| 133 | + | panic("unexpected call") | |||
| 134 | + | } | |||
| 135 | + | ||||
| 136 | + | func (c *attachmentPushTestControlPlane) BackupStatus(context.Context) ([]dto.BackupJob, error) { | |||
| 137 | + | panic("unexpected call") | |||
| 138 | + | } | |||
| 139 | + | ||||
| 140 | + | func (c *attachmentPushTestControlPlane) RunBackup(context.Context, string, string) (*dto.BackupRunResponse, error) { | |||
| 141 | + | panic("unexpected call") | |||
| 142 | + | } | |||
| 143 | + | ||||
| 144 | + | func (c *attachmentPushTestControlPlane) DetectDatabases(context.Context, string) ([]dto.DatabaseInfo, error) { | |||
| 145 | + | panic("unexpected call") | |||
| 146 | + | } | |||
| 147 | + | ||||
| 148 | + | func (c *attachmentPushTestControlPlane) GetProcessLogs(context.Context, int) ([]string, error) { | |||
| 149 | + | panic("unexpected call") | |||
| 150 | + | } | |||
| 151 | + | ||||
| 152 | + | func (c *attachmentPushTestControlPlane) GetContainerLogs(context.Context, string, int) ([]string, error) { | |||
| 153 | + | panic("unexpected call") | |||
| 154 | + | } | |||
| 155 | + | ||||
| 156 | + | func (c *attachmentPushTestControlPlane) StreamProcessLogs(context.Context, int) (<-chan string, error) { | |||
| 157 | + | panic("unexpected call") | |||
| 158 | + | } | |||
| 159 | + | ||||
| 160 | + | func (c *attachmentPushTestControlPlane) StreamContainerLogs(context.Context, string, int) (<-chan string, error) { | |||
| 161 | + | panic("unexpected call") | |||
| 162 | + | } | |||
| 163 | + | ||||
| 164 | + | func TestResolveAttachmentImage_OneTarget(t *testing.T) { | |||
| 165 | + | cp := &attachmentPushTestControlPlane{ | |||
| 166 | + | findAttachmentTargets: func(context.Context, string) ([]string, error) { | |||
| 167 | + | return []string{"app.example.com"}, nil | |||
| 168 | + | }, | |||
| 169 | + | getStatus: func(context.Context) (*remote.Status, error) { | |||
| 170 | + | return &remote.Status{RegistryDomain: "registry.example.com"}, nil | |||
| 171 | + | }, | |||
| 172 | + | } | |||
| 173 | + | ||||
| 174 | + | registry, imageName, targets, err := resolveAttachmentImage(context.Background(), cp, "postgres") | |||
| 175 | + | ||||
| 176 | + | require.NoError(t, err) | |||
| 177 | + | assert.Equal(t, "registry.example.com", registry) | |||
| 178 | + | assert.Equal(t, "postgres", imageName) | |||
| 179 | + | assert.Equal(t, []string{"app.example.com"}, targets) | |||
| 180 | + | } | |||
| 181 | + | ||||
| 182 | + | func TestResolveAttachmentImage_MultipleTargets(t *testing.T) { | |||
| 183 | + | cp := &attachmentPushTestControlPlane{ | |||
| 184 | + | findAttachmentTargets: func(context.Context, string) ([]string, error) { | |||
| 185 | + | return []string{"app.example.com", "backend"}, nil | |||
| 186 | + | }, | |||
| 187 | + | getStatus: func(context.Context) (*remote.Status, error) { | |||
| 188 | + | return &remote.Status{RegistryDomain: "registry.example.com"}, nil | |||
| 189 | + | }, | |||
| 190 | + | } | |||
| 191 | + | ||||
| 192 | + | registry, imageName, targets, err := resolveAttachmentImage(context.Background(), cp, "redis") | |||
| 193 | + | ||||
| 194 | + | require.NoError(t, err) | |||
| 195 | + | assert.Equal(t, "registry.example.com", registry) | |||
| 196 | + | assert.Equal(t, "redis", imageName) | |||
| 197 | + | assert.Equal(t, []string{"app.example.com", "backend"}, targets) | |||
| 198 | + | } | |||
| 199 | + | ||||
| 200 | + | func TestResolveAttachmentImage_NotConfigured(t *testing.T) { | |||
| 201 | + | cp := &attachmentPushTestControlPlane{ | |||
| 202 | + | findAttachmentTargets: func(context.Context, string) ([]string, error) { | |||
| 203 | + | return nil, nil | |||
| 204 | + | }, | |||
| 205 | + | } | |||
| 206 | + | ||||
| 207 | + | _, _, _, err := resolveAttachmentImage(context.Background(), cp, "postgres") | |||
| 208 | + | ||||
| 209 | + | require.Error(t, err) | |||
| 210 | + | assert.Contains(t, err.Error(), "not configured as an attachment") | |||
| 211 | + | } | |||
| 212 | + | ||||
| 213 | + | func TestAttachmentPushCmd_NoDeploy(t *testing.T) { | |||
| 214 | + | origResolveControlPlane := resolveControlPlaneFn | |||
| 215 | + | origTagAndPush := tagAndPushFn | |||
| 216 | + | origDetermineVersion := determineVersionFn | |||
| 217 | + | t.Cleanup(func() { | |||
| 218 | + | resolveControlPlaneFn = origResolveControlPlane | |||
| 219 | + | tagAndPushFn = origTagAndPush | |||
| 220 | + | determineVersionFn = origDetermineVersion | |||
| 221 | + | }) | |||
| 222 | + | ||||
| 223 | + | cp := &attachmentPushTestControlPlane{ | |||
| 224 | + | findAttachmentTargets: func(context.Context, string) ([]string, error) { | |||
| 225 | + | return []string{"app.example.com"}, nil | |||
| 226 | + | }, | |||
| 227 | + | getStatus: func(context.Context) (*remote.Status, error) { | |||
| 228 | + | return &remote.Status{RegistryDomain: "registry.example.com"}, nil | |||
| 229 | + | }, | |||
| 230 | + | } | |||
| 231 | + | ||||
| 232 | + | resolveControlPlaneFn = func(string) (*controlPlaneHandle, error) { | |||
| 233 | + | return &controlPlaneHandle{plane: cp}, nil | |||
| 234 | + | } | |||
| 235 | + | determineVersionFn = func(context.Context, string) string { return "v1.2.3" } | |||
| 236 | + | called := false | |||
| 237 | + | tagAndPushFn = func(context.Context, string, string, string, string, string) error { | |||
| 238 | + | called = true | |||
| 239 | + | return nil | |||
| 240 | + | } | |||
| 241 | + | ||||
| 242 | + | cmd := newAttachmentsPushCmd() | |||
| 243 | + | out := new(bytes.Buffer) | |||
| 244 | + | cmd.SetOut(out) | |||
| 245 | + | cmd.SetErr(out) | |||
| 246 | + | cmd.SetArgs([]string{"postgres"}) | |||
| 247 | + | ||||
| 248 | + | err := cmd.Execute() | |||
| 249 | + | ||||
| 250 | + | require.NoError(t, err) | |||
| 251 | + | assert.True(t, called) | |||
| 252 | + | assert.Contains(t, out.String(), "Push complete") | |||
| 253 | + | } | |||
| 254 | + | ||||
| 255 | + | func TestAttachmentPushCmd_TaggedImageInputBuildsValidRefs(t *testing.T) { | |||
| 256 | + | origResolveControlPlane := resolveControlPlaneFn | |||
| 257 | + | origTagAndPush := tagAndPushFn | |||
| 258 | + | origDetermineVersion := determineVersionFn | |||
| 259 | + | t.Cleanup(func() { | |||
| 260 | + | resolveControlPlaneFn = origResolveControlPlane | |||
| 261 | + | tagAndPushFn = origTagAndPush | |||
| 262 | + | determineVersionFn = origDetermineVersion | |||
| 263 | + | }) | |||
| 264 | + | ||||
| 265 | + | cp := &attachmentPushTestControlPlane{ | |||
| 266 | + | findAttachmentTargets: func(context.Context, string) ([]string, error) { | |||
| 267 | + | return []string{"app.example.com"}, nil | |||
| 268 | + | }, | |||
| 269 | + | getStatus: func(context.Context) (*remote.Status, error) { | |||
| 270 | + | return &remote.Status{RegistryDomain: "registry.example.com"}, nil | |||
| 271 | + | }, | |||
| 272 | + | } | |||
| 273 | + | ||||
| 274 | + | resolveControlPlaneFn = func(string) (*controlPlaneHandle, error) { | |||
| 275 | + | return &controlPlaneHandle{plane: cp}, nil | |||
| 276 | + | } | |||
| 277 | + | determineVersionFn = func(context.Context, string) string { return "v1.2.3" } | |||
| 278 | + | ||||
| 279 | + | var gotVersionRef string | |||
| 280 | + | var gotLatestRef string | |||
| 281 | + | tagAndPushFn = func(_ context.Context, registry, imageName, version, versionRef, latestRef string) error { | |||
| 282 | + | gotVersionRef = versionRef | |||
| 283 | + | gotLatestRef = latestRef | |||
| 284 | + | return nil | |||
| 285 | + | } | |||
| 286 | + | ||||
| 287 | + | cmd := newAttachmentsPushCmd() | |||
| 288 | + | cmd.SetOut(new(bytes.Buffer)) | |||
| 289 | + | cmd.SetErr(new(bytes.Buffer)) | |||
| 290 | + | cmd.SetArgs([]string{"postgres:18"}) | |||
| 291 | + | ||||
| 292 | + | err := cmd.Execute() | |||
| 293 | + | ||||
| 294 | + | require.NoError(t, err) | |||
| 295 | + | assert.Equal(t, "registry.example.com/postgres:v1.2.3", gotVersionRef) | |||
| 296 | + | assert.Equal(t, "registry.example.com/postgres:latest", gotLatestRef) | |||
| 297 | + | } | |||
| 298 | + | ||||
| 299 | + | func TestNormalizeAttachmentImageName_PreservesNamespaceWithoutRegistryHost(t *testing.T) { | |||
| 300 | + | got := normalizeAttachmentImageName("myorg/postgres:18") | |||
| 301 | + | ||||
| 302 | + | assert.Equal(t, "myorg/postgres", got) | |||
| 303 | + | } | |||
M internal/adapters/in/cli/controlplane.go +1 -0
| @@ -30,6 +30,7 @@ type ControlPlane interface { | |||
| 30 | 30 | ||
| 31 | 31 | GetAllAttachmentsConfig(ctx context.Context) (map[string][]string, error) | |
| 32 | 32 | GetAttachmentsConfig(ctx context.Context, domainOrGroup string) ([]string, error) | |
| 33 | + | FindAttachmentTargetsByImage(ctx context.Context, imageName string) ([]string, error) | |
| 33 | 34 | AddAttachment(ctx context.Context, domainOrGroup, image string) error | |
| 34 | 35 | RemoveAttachment(ctx context.Context, domainOrGroup, image string) error | |
| 35 | 36 | ||
| @@ -30,6 +30,7 @@ type ControlPlane interface { | |||||
| 30 | 30 | ||||
| 31 | GetAllAttachmentsConfig(ctx context.Context) (map[string][]string, error) | 31 | GetAllAttachmentsConfig(ctx context.Context) (map[string][]string, error) | ||
| 32 | GetAttachmentsConfig(ctx context.Context, domainOrGroup string) ([]string, error) | 32 | GetAttachmentsConfig(ctx context.Context, domainOrGroup string) ([]string, error) | ||
| 33 | + | FindAttachmentTargetsByImage(ctx context.Context, imageName string) ([]string, error) | |||
| 33 | AddAttachment(ctx context.Context, domainOrGroup, image string) error | 34 | AddAttachment(ctx context.Context, domainOrGroup, image string) error | ||
| 34 | RemoveAttachment(ctx context.Context, domainOrGroup, image string) error | 35 | RemoveAttachment(ctx context.Context, domainOrGroup, image string) error | ||
| 35 | 36 | ||||
M internal/adapters/in/cli/controlplane_local.go +7 -0
| @@ -286,6 +286,13 @@ func (l *localControlPlane) GetAttachmentsConfig(ctx context.Context, domainOrGr | |||
| 286 | 286 | return l.configSvc.GetAttachmentsFor(ctx, domainOrGroup) | |
| 287 | 287 | } | |
| 288 | 288 | ||
| 289 | + | func (l *localControlPlane) FindAttachmentTargetsByImage(ctx context.Context, imageName string) ([]string, error) { | |
| 290 | + | if l.configSvc == nil { | |
| 291 | + | return nil, fmt.Errorf("local config service unavailable") | |
| 292 | + | } | |
| 293 | + | return l.configSvc.FindAttachmentTargetsByImage(ctx, imageName), nil | |
| 294 | + | } | |
| 295 | + | ||
| 289 | 296 | func (l *localControlPlane) AddAttachment(ctx context.Context, domainOrGroup, image string) error { | |
| 290 | 297 | if l.configSvc == nil { | |
| 291 | 298 | return fmt.Errorf("local config service unavailable") | |
| @@ -286,6 +286,13 @@ func (l *localControlPlane) GetAttachmentsConfig(ctx context.Context, domainOrGr | |||||
| 286 | return l.configSvc.GetAttachmentsFor(ctx, domainOrGroup) | 286 | return l.configSvc.GetAttachmentsFor(ctx, domainOrGroup) | ||
| 287 | } | 287 | } | ||
| 288 | 288 | ||||
| 289 | + | func (l *localControlPlane) FindAttachmentTargetsByImage(ctx context.Context, imageName string) ([]string, error) { | |||
| 290 | + | if l.configSvc == nil { | |||
| 291 | + | return nil, fmt.Errorf("local config service unavailable") | |||
| 292 | + | } | |||
| 293 | + | return l.configSvc.FindAttachmentTargetsByImage(ctx, imageName), nil | |||
| 294 | + | } | |||
| 295 | + | ||||
| 289 | func (l *localControlPlane) AddAttachment(ctx context.Context, domainOrGroup, image string) error { | 296 | func (l *localControlPlane) AddAttachment(ctx context.Context, domainOrGroup, image string) error { | ||
| 290 | if l.configSvc == nil { | 297 | if l.configSvc == nil { | ||
| 291 | return fmt.Errorf("local config service unavailable") | 298 | return fmt.Errorf("local config service unavailable") | ||
M internal/adapters/in/cli/controlplane_remote.go +4 -0
| @@ -76,6 +76,10 @@ func (r *remoteControlPlane) GetAttachmentsConfig(ctx context.Context, domainOrG | |||
| 76 | 76 | return r.client.GetAttachmentsConfig(ctx, domainOrGroup) | |
| 77 | 77 | } | |
| 78 | 78 | ||
| 79 | + | func (r *remoteControlPlane) FindAttachmentTargetsByImage(ctx context.Context, imageName string) ([]string, error) { | |
| 80 | + | return r.client.FindAttachmentTargetsByImage(ctx, imageName) | |
| 81 | + | } | |
| 82 | + | ||
| 79 | 83 | func (r *remoteControlPlane) AddAttachment(ctx context.Context, domainOrGroup, image string) error { | |
| 80 | 84 | return r.client.AddAttachment(ctx, domainOrGroup, image) | |
| 81 | 85 | } | |
| @@ -76,6 +76,10 @@ func (r *remoteControlPlane) GetAttachmentsConfig(ctx context.Context, domainOrG | |||||
| 76 | return r.client.GetAttachmentsConfig(ctx, domainOrGroup) | 76 | return r.client.GetAttachmentsConfig(ctx, domainOrGroup) | ||
| 77 | } | 77 | } | ||
| 78 | 78 | ||||
| 79 | + | func (r *remoteControlPlane) FindAttachmentTargetsByImage(ctx context.Context, imageName string) ([]string, error) { | |||
| 80 | + | return r.client.FindAttachmentTargetsByImage(ctx, imageName) | |||
| 81 | + | } | |||
| 82 | + | ||||
| 79 | func (r *remoteControlPlane) AddAttachment(ctx context.Context, domainOrGroup, image string) error { | 83 | func (r *remoteControlPlane) AddAttachment(ctx context.Context, domainOrGroup, image string) error { | ||
| 80 | return r.client.AddAttachment(ctx, domainOrGroup, image) | 84 | return r.client.AddAttachment(ctx, domainOrGroup, image) | ||
| 81 | } | 85 | } | ||
M internal/adapters/in/cli/push.go +2 -1
| @@ -208,7 +208,8 @@ func resolveFromImage(ctx context.Context, cp ControlPlane, imageArg, dockerfile | |||
| 208 | 208 | if len(routes) == 0 { | |
| 209 | 209 | // bootstrap command is registered in bootstrap.go (see issue #98) | |
| 210 | 210 | return "", "", "", fmt.Errorf( | |
| 211 | - | "no route configured for image %q\n\nFor a first deploy, use 'gordon bootstrap <domain> %s'\nOr configure the route directly with 'gordon routes add <domain> %s'", | |
| 211 | + | "no route configured for image %q\n\nFor a first deploy, use 'gordon bootstrap <domain> %s'\nOr configure the route directly with 'gordon routes add <domain> %s'\nIf this is an attachment image, use 'gordon attachments push %s'", | |
| 212 | + | imageArg, | |
| 212 | 213 | imageArg, | |
| 213 | 214 | imageArg, | |
| 214 | 215 | imageArg, | |
| @@ -208,7 +208,8 @@ func resolveFromImage(ctx context.Context, cp ControlPlane, imageArg, dockerfile | |||||
| 208 | if len(routes) == 0 { | 208 | if len(routes) == 0 { | ||
| 209 | // bootstrap command is registered in bootstrap.go (see issue #98) | 209 | // bootstrap command is registered in bootstrap.go (see issue #98) | ||
| 210 | return "", "", "", fmt.Errorf( | 210 | return "", "", "", fmt.Errorf( | ||
| 211 | - | "no route configured for image %q\n\nFor a first deploy, use 'gordon bootstrap <domain> %s'\nOr configure the route directly with 'gordon routes add <domain> %s'", | 211 | + | "no route configured for image %q\n\nFor a first deploy, use 'gordon bootstrap <domain> %s'\nOr configure the route directly with 'gordon routes add <domain> %s'\nIf this is an attachment image, use 'gordon attachments push %s'", |
| 212 | + | imageArg, | |||
| 212 | imageArg, | 213 | imageArg, | ||
| 213 | imageArg, | 214 | imageArg, | ||
| 214 | imageArg, | 215 | imageArg, | ||
M internal/adapters/in/cli/push_test.go +4 -0
| @@ -84,6 +84,10 @@ func (c *resolveFromImageTestControlPlane) GetAttachmentsConfig(context.Context, | |||
| 84 | 84 | panic("unexpected call") | |
| 85 | 85 | } | |
| 86 | 86 | ||
| 87 | + | func (c *resolveFromImageTestControlPlane) FindAttachmentTargetsByImage(context.Context, string) ([]string, error) { | |
| 88 | + | panic("unexpected call") | |
| 89 | + | } | |
| 90 | + | ||
| 87 | 91 | func (c *resolveFromImageTestControlPlane) AddAttachment(context.Context, string, string) error { | |
| 88 | 92 | panic("unexpected call") | |
| 89 | 93 | } | |
| @@ -84,6 +84,10 @@ func (c *resolveFromImageTestControlPlane) GetAttachmentsConfig(context.Context, | |||||
| 84 | panic("unexpected call") | 84 | panic("unexpected call") | ||
| 85 | } | 85 | } | ||
| 86 | 86 | ||||
| 87 | + | func (c *resolveFromImageTestControlPlane) FindAttachmentTargetsByImage(context.Context, string) ([]string, error) { | |||
| 88 | + | panic("unexpected call") | |||
| 89 | + | } | |||
| 90 | + | ||||
| 87 | func (c *resolveFromImageTestControlPlane) AddAttachment(context.Context, string, string) error { | 91 | func (c *resolveFromImageTestControlPlane) AddAttachment(context.Context, string, string) error { | ||
| 88 | panic("unexpected call") | 92 | panic("unexpected call") | ||
| 89 | } | 93 | } | ||
M internal/adapters/in/cli/remote/client.go +15 -0
| @@ -895,6 +895,21 @@ func (c *Client) GetAttachmentsConfig(ctx context.Context, domainOrGroup string) | |||
| 895 | 895 | return result.Images, nil | |
| 896 | 896 | } | |
| 897 | 897 | ||
| 898 | + | // FindAttachmentTargetsByImage returns all attachment targets associated with the given image name. | |
| 899 | + | func (c *Client) FindAttachmentTargetsByImage(ctx context.Context, imageName string) ([]string, error) { | |
| 900 | + | resp, err := c.request(ctx, http.MethodGet, "/attachments/by-image/"+url.PathEscape(imageName), nil) | |
| 901 | + | if err != nil { | |
| 902 | + | return nil, err | |
| 903 | + | } | |
| 904 | + | ||
| 905 | + | var result dto.AttachmentTargetsByImageResponse | |
| 906 | + | if err := parseResponse(resp, &result); err != nil { | |
| 907 | + | return nil, err | |
| 908 | + | } | |
| 909 | + | ||
| 910 | + | return result.Targets, nil | |
| 911 | + | } | |
| 912 | + | ||
| 898 | 913 | // AddAttachment adds an attachment to a domain/group. | |
| 899 | 914 | func (c *Client) AddAttachment(ctx context.Context, domainOrGroup, image string) error { | |
| 900 | 915 | if domainOrGroup == "" { | |
| @@ -895,6 +895,21 @@ func (c *Client) GetAttachmentsConfig(ctx context.Context, domainOrGroup string) | |||||
| 895 | return result.Images, nil | 895 | return result.Images, nil | ||
| 896 | } | 896 | } | ||
| 897 | 897 | ||||
| 898 | + | // FindAttachmentTargetsByImage returns all attachment targets associated with the given image name. | |||
| 899 | + | func (c *Client) FindAttachmentTargetsByImage(ctx context.Context, imageName string) ([]string, error) { | |||
| 900 | + | resp, err := c.request(ctx, http.MethodGet, "/attachments/by-image/"+url.PathEscape(imageName), nil) | |||
| 901 | + | if err != nil { | |||
| 902 | + | return nil, err | |||
| 903 | + | } | |||
| 904 | + | ||||
| 905 | + | var result dto.AttachmentTargetsByImageResponse | |||
| 906 | + | if err := parseResponse(resp, &result); err != nil { | |||
| 907 | + | return nil, err | |||
| 908 | + | } | |||
| 909 | + | ||||
| 910 | + | return result.Targets, nil | |||
| 911 | + | } | |||
| 912 | + | ||||
| 898 | // AddAttachment adds an attachment to a domain/group. | 913 | // AddAttachment adds an attachment to a domain/group. | ||
| 899 | func (c *Client) AddAttachment(ctx context.Context, domainOrGroup, image string) error { | 914 | func (c *Client) AddAttachment(ctx context.Context, domainOrGroup, image string) error { | ||
| 900 | if domainOrGroup == "" { | 915 | if domainOrGroup == "" { | ||
M internal/adapters/in/cli/remote/client_test.go +31 -0
| @@ -162,3 +162,34 @@ func TestClientPruneImages(t *testing.T) { | |||
| 162 | 162 | assert.Equal(t, 2, resp.Runtime.DeletedCount) | |
| 163 | 163 | assert.Equal(t, 3, resp.Registry.TagsRemoved) | |
| 164 | 164 | } | |
| 165 | + | ||
| 166 | + | func TestClientFindAttachmentTargetsByImage(t *testing.T) { | |
| 167 | + | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 168 | + | require.Equal(t, "/admin/attachments/by-image/postgres:16", r.URL.Path) | |
| 169 | + | require.Equal(t, "", r.URL.RawQuery) | |
| 170 | + | require.Equal(t, http.MethodGet, r.Method) | |
| 171 | + | w.Header().Set("Content-Type", "application/json") | |
| 172 | + | _, _ = w.Write([]byte(`{"image":"postgres:16","targets":["app.example.com","workers"]}`)) | |
| 173 | + | })) | |
| 174 | + | defer srv.Close() | |
| 175 | + | ||
| 176 | + | client := NewClient(srv.URL) | |
| 177 | + | targets, err := client.FindAttachmentTargetsByImage(context.Background(), "postgres:16") | |
| 178 | + | require.NoError(t, err) | |
| 179 | + | assert.Equal(t, []string{"app.example.com", "workers"}, targets) | |
| 180 | + | } | |
| 181 | + | ||
| 182 | + | func TestClientFindAttachmentTargetsByImage_WithSlashContainingImage(t *testing.T) { | |
| 183 | + | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 184 | + | require.Equal(t, "/admin/attachments/by-image/registry/org/image:tag", r.URL.Path) | |
| 185 | + | require.Equal(t, http.MethodGet, r.Method) | |
| 186 | + | w.Header().Set("Content-Type", "application/json") | |
| 187 | + | _, _ = w.Write([]byte(`{"image":"registry/org/image:tag","targets":["workers"]}`)) | |
| 188 | + | })) | |
| 189 | + | defer srv.Close() | |
| 190 | + | ||
| 191 | + | client := NewClient(srv.URL) | |
| 192 | + | targets, err := client.FindAttachmentTargetsByImage(context.Background(), "registry/org/image:tag") | |
| 193 | + | require.NoError(t, err) | |
| 194 | + | assert.Equal(t, []string{"workers"}, targets) | |
| 195 | + | } | |
| @@ -162,3 +162,34 @@ func TestClientPruneImages(t *testing.T) { | |||||
| 162 | assert.Equal(t, 2, resp.Runtime.DeletedCount) | 162 | assert.Equal(t, 2, resp.Runtime.DeletedCount) | ||
| 163 | assert.Equal(t, 3, resp.Registry.TagsRemoved) | 163 | assert.Equal(t, 3, resp.Registry.TagsRemoved) | ||
| 164 | } | 164 | } | ||
| 165 | + | ||||
| 166 | + | func TestClientFindAttachmentTargetsByImage(t *testing.T) { | |||
| 167 | + | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| 168 | + | require.Equal(t, "/admin/attachments/by-image/postgres:16", r.URL.Path) | |||
| 169 | + | require.Equal(t, "", r.URL.RawQuery) | |||
| 170 | + | require.Equal(t, http.MethodGet, r.Method) | |||
| 171 | + | w.Header().Set("Content-Type", "application/json") | |||
| 172 | + | _, _ = w.Write([]byte(`{"image":"postgres:16","targets":["app.example.com","workers"]}`)) | |||
| 173 | + | })) | |||
| 174 | + | defer srv.Close() | |||
| 175 | + | ||||
| 176 | + | client := NewClient(srv.URL) | |||
| 177 | + | targets, err := client.FindAttachmentTargetsByImage(context.Background(), "postgres:16") | |||
| 178 | + | require.NoError(t, err) | |||
| 179 | + | assert.Equal(t, []string{"app.example.com", "workers"}, targets) | |||
| 180 | + | } | |||
| 181 | + | ||||
| 182 | + | func TestClientFindAttachmentTargetsByImage_WithSlashContainingImage(t *testing.T) { | |||
| 183 | + | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| 184 | + | require.Equal(t, "/admin/attachments/by-image/registry/org/image:tag", r.URL.Path) | |||
| 185 | + | require.Equal(t, http.MethodGet, r.Method) | |||
| 186 | + | w.Header().Set("Content-Type", "application/json") | |||
| 187 | + | _, _ = w.Write([]byte(`{"image":"registry/org/image:tag","targets":["workers"]}`)) | |||
| 188 | + | })) | |||
| 189 | + | defer srv.Close() | |||
| 190 | + | ||||
| 191 | + | client := NewClient(srv.URL) | |||
| 192 | + | targets, err := client.FindAttachmentTargetsByImage(context.Background(), "registry/org/image:tag") | |||
| 193 | + | require.NoError(t, err) | |||
| 194 | + | assert.Equal(t, []string{"workers"}, targets) | |||
| 195 | + | } | |||
M internal/adapters/in/http/admin/handler.go +36 -0
| @@ -218,6 +218,7 @@ func (h *Handler) matchRoute(path string) (routeHandler, bool) { | |||
| 218 | 218 | handler routeHandler | |
| 219 | 219 | }{ | |
| 220 | 220 | {"/backups", h.handleBackups}, | |
| 221 | + | {"/attachments/by-image", h.handleAttachmentsByImage}, | |
| 221 | 222 | {"/attachments", h.handleAttachmentsConfig}, | |
| 222 | 223 | {"/routes/by-image", h.handleRoutesByImage}, | |
| 223 | 224 | {"/routes", h.handleRoutes}, | |
| @@ -238,6 +239,41 @@ func (h *Handler) matchRoute(path string) (routeHandler, bool) { | |||
| 238 | 239 | return nil, false | |
| 239 | 240 | } | |
| 240 | 241 | ||
| 242 | + | // handleAttachmentsByImage handles GET /admin/attachments/by-image/{image} endpoint. | |
| 243 | + | // Returns all attachment targets associated with the given image name. | |
| 244 | + | func (h *Handler) handleAttachmentsByImage(w http.ResponseWriter, r *http.Request, path string) { | |
| 245 | + | if r.Method != http.MethodGet { | |
| 246 | + | h.sendError(w, http.StatusMethodNotAllowed, "method not allowed") | |
| 247 | + | return | |
| 248 | + | } | |
| 249 | + | ||
| 250 | + | ctx := r.Context() | |
| 251 | + | ||
| 252 | + | if !HasAccess(ctx, domain.AdminResourceConfig, domain.AdminActionRead) { | |
| 253 | + | h.sendError(w, http.StatusForbidden, "insufficient permissions for config:read") | |
| 254 | + | return | |
| 255 | + | } | |
| 256 | + | ||
| 257 | + | imageName := strings.TrimPrefix(path, "/attachments/by-image/") | |
| 258 | + | if imageName == "" || imageName == "/attachments/by-image" { | |
| 259 | + | h.sendError(w, http.StatusBadRequest, "image name required in path") | |
| 260 | + | return | |
| 261 | + | } | |
| 262 | + | ||
| 263 | + | imageName, err := url.PathUnescape(imageName) | |
| 264 | + | if err != nil { | |
| 265 | + | h.sendError(w, http.StatusBadRequest, "invalid image name encoding") | |
| 266 | + | return | |
| 267 | + | } | |
| 268 | + | ||
| 269 | + | targets := h.configSvc.FindAttachmentTargetsByImage(ctx, imageName) | |
| 270 | + | ||
| 271 | + | h.sendJSON(w, http.StatusOK, dto.AttachmentTargetsByImageResponse{ | |
| 272 | + | Image: imageName, | |
| 273 | + | Targets: targets, | |
| 274 | + | }) | |
| 275 | + | } | |
| 276 | + | ||
| 241 | 277 | // handleDeployIntent handles /admin/deploy-intent/:image endpoint. | |
| 242 | 278 | // It registers a deploy intent, suppressing event-based deploys for the image. | |
| 243 | 279 | func (h *Handler) handleDeployIntent(w http.ResponseWriter, r *http.Request, path string) { | |
| @@ -218,6 +218,7 @@ func (h *Handler) matchRoute(path string) (routeHandler, bool) { | |||||
| 218 | handler routeHandler | 218 | handler routeHandler | ||
| 219 | }{ | 219 | }{ | ||
| 220 | {"/backups", h.handleBackups}, | 220 | {"/backups", h.handleBackups}, | ||
| 221 | + | {"/attachments/by-image", h.handleAttachmentsByImage}, | |||
| 221 | {"/attachments", h.handleAttachmentsConfig}, | 222 | {"/attachments", h.handleAttachmentsConfig}, | ||
| 222 | {"/routes/by-image", h.handleRoutesByImage}, | 223 | {"/routes/by-image", h.handleRoutesByImage}, | ||
| 223 | {"/routes", h.handleRoutes}, | 224 | {"/routes", h.handleRoutes}, | ||
| @@ -238,6 +239,41 @@ func (h *Handler) matchRoute(path string) (routeHandler, bool) { | |||||
| 238 | return nil, false | 239 | return nil, false | ||
| 239 | } | 240 | } | ||
| 240 | 241 | ||||
| 242 | + | // handleAttachmentsByImage handles GET /admin/attachments/by-image/{image} endpoint. | |||
| 243 | + | // Returns all attachment targets associated with the given image name. | |||
| 244 | + | func (h *Handler) handleAttachmentsByImage(w http.ResponseWriter, r *http.Request, path string) { | |||
| 245 | + | if r.Method != http.MethodGet { | |||
| 246 | + | h.sendError(w, http.StatusMethodNotAllowed, "method not allowed") | |||
| 247 | + | return | |||
| 248 | + | } | |||
| 249 | + | ||||
| 250 | + | ctx := r.Context() | |||
| 251 | + | ||||
| 252 | + | if !HasAccess(ctx, domain.AdminResourceConfig, domain.AdminActionRead) { | |||
| 253 | + | h.sendError(w, http.StatusForbidden, "insufficient permissions for config:read") | |||
| 254 | + | return | |||
| 255 | + | } | |||
| 256 | + | ||||
| 257 | + | imageName := strings.TrimPrefix(path, "/attachments/by-image/") | |||
| 258 | + | if imageName == "" || imageName == "/attachments/by-image" { | |||
| 259 | + | h.sendError(w, http.StatusBadRequest, "image name required in path") | |||
| 260 | + | return | |||
| 261 | + | } | |||
| 262 | + | ||||
| 263 | + | imageName, err := url.PathUnescape(imageName) | |||
| 264 | + | if err != nil { | |||
| 265 | + | h.sendError(w, http.StatusBadRequest, "invalid image name encoding") | |||
| 266 | + | return | |||
| 267 | + | } | |||
| 268 | + | ||||
| 269 | + | targets := h.configSvc.FindAttachmentTargetsByImage(ctx, imageName) | |||
| 270 | + | ||||
| 271 | + | h.sendJSON(w, http.StatusOK, dto.AttachmentTargetsByImageResponse{ | |||
| 272 | + | Image: imageName, | |||
| 273 | + | Targets: targets, | |||
| 274 | + | }) | |||
| 275 | + | } | |||
| 276 | + | ||||
| 241 | // handleDeployIntent handles /admin/deploy-intent/:image endpoint. | 277 | // handleDeployIntent handles /admin/deploy-intent/:image endpoint. | ||
| 242 | // It registers a deploy intent, suppressing event-based deploys for the image. | 278 | // It registers a deploy intent, suppressing event-based deploys for the image. | ||
| 243 | func (h *Handler) handleDeployIntent(w http.ResponseWriter, r *http.Request, path string) { | 279 | func (h *Handler) handleDeployIntent(w http.ResponseWriter, r *http.Request, path string) { | ||
M internal/adapters/in/http/admin/handler_test.go +136 -0
| @@ -8,6 +8,7 @@ import ( | |||
| 8 | 8 | "fmt" | |
| 9 | 9 | "net/http" | |
| 10 | 10 | "net/http/httptest" | |
| 11 | + | "net/url" | |
| 11 | 12 | "strings" | |
| 12 | 13 | "testing" | |
| 13 | 14 | "time" | |
| @@ -264,6 +265,141 @@ func TestHandler_RoutesPut_AllowsRouteUpdateWithoutRegistryManifest(t *testing.T | |||
| 264 | 265 | registrySvc.AssertNotCalled(t, "GetManifest", mock.Anything, mock.Anything, mock.Anything) | |
| 265 | 266 | } | |
| 266 | 267 | ||
| 268 | + | func TestHandler_AttachmentsByImageGet_OneTarget(t *testing.T) { | |
| 269 | + | configSvc := inmocks.NewMockConfigService(t) | |
| 270 | + | authSvc := inmocks.NewMockAuthService(t) | |
| 271 | + | containerSvc := inmocks.NewMockContainerService(t) | |
| 272 | + | secretSvc := inmocks.NewMockSecretService(t) | |
| 273 | + | ||
| 274 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil) | |
| 275 | + | ||
| 276 | + | configSvc.EXPECT().FindAttachmentTargetsByImage(mock.Anything, "postgres:16").Return([]string{"app.example.com"}).Once() | |
| 277 | + | ||
| 278 | + | req := httptest.NewRequest("GET", "/admin/attachments/by-image/postgres:16", nil) | |
| 279 | + | req = req.WithContext(ctxWithScopes("admin:config:read")) | |
| 280 | + | rec := httptest.NewRecorder() | |
| 281 | + | ||
| 282 | + | handler.ServeHTTP(rec, req) | |
| 283 | + | ||
| 284 | + | assert.Equal(t, http.StatusOK, rec.Code) | |
| 285 | + | assert.JSONEq(t, `{"image":"postgres:16","targets":["app.example.com"]}`, rec.Body.String()) | |
| 286 | + | } | |
| 287 | + | ||
| 288 | + | func TestHandler_AttachmentsByImageGet_MultipleTargets(t *testing.T) { | |
| 289 | + | configSvc := inmocks.NewMockConfigService(t) | |
| 290 | + | authSvc := inmocks.NewMockAuthService(t) | |
| 291 | + | containerSvc := inmocks.NewMockContainerService(t) | |
| 292 | + | secretSvc := inmocks.NewMockSecretService(t) | |
| 293 | + | ||
| 294 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil) | |
| 295 | + | ||
| 296 | + | configSvc.EXPECT().FindAttachmentTargetsByImage(mock.Anything, "postgres").Return([]string{"app.example.com", "workers"}).Once() | |
| 297 | + | ||
| 298 | + | req := httptest.NewRequest("GET", "/admin/attachments/by-image/postgres", nil) | |
| 299 | + | req = req.WithContext(ctxWithScopes("admin:config:read")) | |
| 300 | + | rec := httptest.NewRecorder() | |
| 301 | + | ||
| 302 | + | handler.ServeHTTP(rec, req) | |
| 303 | + | ||
| 304 | + | assert.Equal(t, http.StatusOK, rec.Code) | |
| 305 | + | assert.JSONEq(t, `{"image":"postgres","targets":["app.example.com","workers"]}`, rec.Body.String()) | |
| 306 | + | } | |
| 307 | + | ||
| 308 | + | func TestHandler_AttachmentsByImageGet_URLDecodedImage(t *testing.T) { | |
| 309 | + | configSvc := inmocks.NewMockConfigService(t) | |
| 310 | + | authSvc := inmocks.NewMockAuthService(t) | |
| 311 | + | containerSvc := inmocks.NewMockContainerService(t) | |
| 312 | + | secretSvc := inmocks.NewMockSecretService(t) | |
| 313 | + | ||
| 314 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil) | |
| 315 | + | ||
| 316 | + | configSvc.EXPECT().FindAttachmentTargetsByImage(mock.Anything, "registry/org/image:tag").Return([]string{"app.example.com"}).Once() | |
| 317 | + | ||
| 318 | + | req := httptest.NewRequest("GET", "/admin/attachments/by-image/registry%2Forg%2Fimage:tag", nil) | |
| 319 | + | req = req.WithContext(ctxWithScopes("admin:config:read")) | |
| 320 | + | rec := httptest.NewRecorder() | |
| 321 | + | ||
| 322 | + | handler.ServeHTTP(rec, req) | |
| 323 | + | ||
| 324 | + | assert.Equal(t, http.StatusOK, rec.Code) | |
| 325 | + | assert.JSONEq(t, `{"image":"registry/org/image:tag","targets":["app.example.com"]}`, rec.Body.String()) | |
| 326 | + | } | |
| 327 | + | ||
| 328 | + | func TestHandler_AttachmentsByImageGet_EmptyResultSet(t *testing.T) { | |
| 329 | + | configSvc := inmocks.NewMockConfigService(t) | |
| 330 | + | authSvc := inmocks.NewMockAuthService(t) | |
| 331 | + | containerSvc := inmocks.NewMockContainerService(t) | |
| 332 | + | secretSvc := inmocks.NewMockSecretService(t) | |
| 333 | + | ||
| 334 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil) | |
| 335 | + | ||
| 336 | + | configSvc.EXPECT().FindAttachmentTargetsByImage(mock.Anything, "missing:latest").Return([]string{}).Once() | |
| 337 | + | ||
| 338 | + | req := httptest.NewRequest("GET", "/admin/attachments/by-image/missing:latest", nil) | |
| 339 | + | req = req.WithContext(ctxWithScopes("admin:config:read")) | |
| 340 | + | rec := httptest.NewRecorder() | |
| 341 | + | ||
| 342 | + | handler.ServeHTTP(rec, req) | |
| 343 | + | ||
| 344 | + | assert.Equal(t, http.StatusOK, rec.Code) | |
| 345 | + | assert.JSONEq(t, `{"image":"missing:latest","targets":[]}`, rec.Body.String()) | |
| 346 | + | } | |
| 347 | + | ||
| 348 | + | func TestHandler_AttachmentsByImageGet_MissingImagePath(t *testing.T) { | |
| 349 | + | configSvc := inmocks.NewMockConfigService(t) | |
| 350 | + | authSvc := inmocks.NewMockAuthService(t) | |
| 351 | + | containerSvc := inmocks.NewMockContainerService(t) | |
| 352 | + | secretSvc := inmocks.NewMockSecretService(t) | |
| 353 | + | ||
| 354 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil) | |
| 355 | + | ||
| 356 | + | req := httptest.NewRequest("GET", "/admin/attachments/by-image", nil) | |
| 357 | + | req = req.WithContext(ctxWithScopes("admin:config:read")) | |
| 358 | + | rec := httptest.NewRecorder() | |
| 359 | + | ||
| 360 | + | handler.ServeHTTP(rec, req) | |
| 361 | + | ||
| 362 | + | assert.Equal(t, http.StatusBadRequest, rec.Code) | |
| 363 | + | assert.JSONEq(t, `{"error":"image name required in path"}`, rec.Body.String()) | |
| 364 | + | } | |
| 365 | + | ||
| 366 | + | func TestHandler_AttachmentsByImageGet_InvalidEncoding(t *testing.T) { | |
| 367 | + | configSvc := inmocks.NewMockConfigService(t) | |
| 368 | + | authSvc := inmocks.NewMockAuthService(t) | |
| 369 | + | containerSvc := inmocks.NewMockContainerService(t) | |
| 370 | + | secretSvc := inmocks.NewMockSecretService(t) | |
| 371 | + | ||
| 372 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil) | |
| 373 | + | ||
| 374 | + | req := httptest.NewRequest("GET", "/admin/attachments/by-image/placeholder", nil) | |
| 375 | + | req.URL = &url.URL{Path: "/admin/attachments/by-image/%zz"} | |
| 376 | + | req = req.WithContext(ctxWithScopes("admin:config:read")) | |
| 377 | + | rec := httptest.NewRecorder() | |
| 378 | + | ||
| 379 | + | handler.ServeHTTP(rec, req) | |
| 380 | + | ||
| 381 | + | assert.Equal(t, http.StatusBadRequest, rec.Code) | |
| 382 | + | assert.JSONEq(t, `{"error":"invalid image name encoding"}`, rec.Body.String()) | |
| 383 | + | } | |
| 384 | + | ||
| 385 | + | func TestHandler_AttachmentsByImageGet_RequiresReadScope(t *testing.T) { | |
| 386 | + | configSvc := inmocks.NewMockConfigService(t) | |
| 387 | + | authSvc := inmocks.NewMockAuthService(t) | |
| 388 | + | containerSvc := inmocks.NewMockContainerService(t) | |
| 389 | + | secretSvc := inmocks.NewMockSecretService(t) | |
| 390 | + | ||
| 391 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil) | |
| 392 | + | ||
| 393 | + | req := httptest.NewRequest("GET", "/admin/attachments/by-image/postgres:16", nil) | |
| 394 | + | req = req.WithContext(ctxWithScopes("admin:routes:read")) | |
| 395 | + | rec := httptest.NewRecorder() | |
| 396 | + | ||
| 397 | + | handler.ServeHTTP(rec, req) | |
| 398 | + | ||
| 399 | + | assert.Equal(t, http.StatusForbidden, rec.Code) | |
| 400 | + | assert.JSONEq(t, `{"error":"insufficient permissions for config:read"}`, rec.Body.String()) | |
| 401 | + | } | |
| 402 | + | ||
| 267 | 403 | func TestHandler_RoutesDelete_RequiresWriteScope(t *testing.T) { | |
| 268 | 404 | configSvc := inmocks.NewMockConfigService(t) | |
| 269 | 405 | authSvc := inmocks.NewMockAuthService(t) | |
| @@ -8,6 +8,7 @@ import ( | |||||
| 8 | "fmt" | 8 | "fmt" | ||
| 9 | "net/http" | 9 | "net/http" | ||
| 10 | "net/http/httptest" | 10 | "net/http/httptest" | ||
| 11 | + | "net/url" | |||
| 11 | "strings" | 12 | "strings" | ||
| 12 | "testing" | 13 | "testing" | ||
| 13 | "time" | 14 | "time" | ||
| @@ -264,6 +265,141 @@ func TestHandler_RoutesPut_AllowsRouteUpdateWithoutRegistryManifest(t *testing.T | |||||
| 264 | registrySvc.AssertNotCalled(t, "GetManifest", mock.Anything, mock.Anything, mock.Anything) | 265 | registrySvc.AssertNotCalled(t, "GetManifest", mock.Anything, mock.Anything, mock.Anything) | ||
| 265 | } | 266 | } | ||
| 266 | 267 | ||||
| 268 | + | func TestHandler_AttachmentsByImageGet_OneTarget(t *testing.T) { | |||
| 269 | + | configSvc := inmocks.NewMockConfigService(t) | |||
| 270 | + | authSvc := inmocks.NewMockAuthService(t) | |||
| 271 | + | containerSvc := inmocks.NewMockContainerService(t) | |||
| 272 | + | secretSvc := inmocks.NewMockSecretService(t) | |||
| 273 | + | ||||
| 274 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil) | |||
| 275 | + | ||||
| 276 | + | configSvc.EXPECT().FindAttachmentTargetsByImage(mock.Anything, "postgres:16").Return([]string{"app.example.com"}).Once() | |||
| 277 | + | ||||
| 278 | + | req := httptest.NewRequest("GET", "/admin/attachments/by-image/postgres:16", nil) | |||
| 279 | + | req = req.WithContext(ctxWithScopes("admin:config:read")) | |||
| 280 | + | rec := httptest.NewRecorder() | |||
| 281 | + | ||||
| 282 | + | handler.ServeHTTP(rec, req) | |||
| 283 | + | ||||
| 284 | + | assert.Equal(t, http.StatusOK, rec.Code) | |||
| 285 | + | assert.JSONEq(t, `{"image":"postgres:16","targets":["app.example.com"]}`, rec.Body.String()) | |||
| 286 | + | } | |||
| 287 | + | ||||
| 288 | + | func TestHandler_AttachmentsByImageGet_MultipleTargets(t *testing.T) { | |||
| 289 | + | configSvc := inmocks.NewMockConfigService(t) | |||
| 290 | + | authSvc := inmocks.NewMockAuthService(t) | |||
| 291 | + | containerSvc := inmocks.NewMockContainerService(t) | |||
| 292 | + | secretSvc := inmocks.NewMockSecretService(t) | |||
| 293 | + | ||||
| 294 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil) | |||
| 295 | + | ||||
| 296 | + | configSvc.EXPECT().FindAttachmentTargetsByImage(mock.Anything, "postgres").Return([]string{"app.example.com", "workers"}).Once() | |||
| 297 | + | ||||
| 298 | + | req := httptest.NewRequest("GET", "/admin/attachments/by-image/postgres", nil) | |||
| 299 | + | req = req.WithContext(ctxWithScopes("admin:config:read")) | |||
| 300 | + | rec := httptest.NewRecorder() | |||
| 301 | + | ||||
| 302 | + | handler.ServeHTTP(rec, req) | |||
| 303 | + | ||||
| 304 | + | assert.Equal(t, http.StatusOK, rec.Code) | |||
| 305 | + | assert.JSONEq(t, `{"image":"postgres","targets":["app.example.com","workers"]}`, rec.Body.String()) | |||
| 306 | + | } | |||
| 307 | + | ||||
| 308 | + | func TestHandler_AttachmentsByImageGet_URLDecodedImage(t *testing.T) { | |||
| 309 | + | configSvc := inmocks.NewMockConfigService(t) | |||
| 310 | + | authSvc := inmocks.NewMockAuthService(t) | |||
| 311 | + | containerSvc := inmocks.NewMockContainerService(t) | |||
| 312 | + | secretSvc := inmocks.NewMockSecretService(t) | |||
| 313 | + | ||||
| 314 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil) | |||
| 315 | + | ||||
| 316 | + | configSvc.EXPECT().FindAttachmentTargetsByImage(mock.Anything, "registry/org/image:tag").Return([]string{"app.example.com"}).Once() | |||
| 317 | + | ||||
| 318 | + | req := httptest.NewRequest("GET", "/admin/attachments/by-image/registry%2Forg%2Fimage:tag", nil) | |||
| 319 | + | req = req.WithContext(ctxWithScopes("admin:config:read")) | |||
| 320 | + | rec := httptest.NewRecorder() | |||
| 321 | + | ||||
| 322 | + | handler.ServeHTTP(rec, req) | |||
| 323 | + | ||||
| 324 | + | assert.Equal(t, http.StatusOK, rec.Code) | |||
| 325 | + | assert.JSONEq(t, `{"image":"registry/org/image:tag","targets":["app.example.com"]}`, rec.Body.String()) | |||
| 326 | + | } | |||
| 327 | + | ||||
| 328 | + | func TestHandler_AttachmentsByImageGet_EmptyResultSet(t *testing.T) { | |||
| 329 | + | configSvc := inmocks.NewMockConfigService(t) | |||
| 330 | + | authSvc := inmocks.NewMockAuthService(t) | |||
| 331 | + | containerSvc := inmocks.NewMockContainerService(t) | |||
| 332 | + | secretSvc := inmocks.NewMockSecretService(t) | |||
| 333 | + | ||||
| 334 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil) | |||
| 335 | + | ||||
| 336 | + | configSvc.EXPECT().FindAttachmentTargetsByImage(mock.Anything, "missing:latest").Return([]string{}).Once() | |||
| 337 | + | ||||
| 338 | + | req := httptest.NewRequest("GET", "/admin/attachments/by-image/missing:latest", nil) | |||
| 339 | + | req = req.WithContext(ctxWithScopes("admin:config:read")) | |||
| 340 | + | rec := httptest.NewRecorder() | |||
| 341 | + | ||||
| 342 | + | handler.ServeHTTP(rec, req) | |||
| 343 | + | ||||
| 344 | + | assert.Equal(t, http.StatusOK, rec.Code) | |||
| 345 | + | assert.JSONEq(t, `{"image":"missing:latest","targets":[]}`, rec.Body.String()) | |||
| 346 | + | } | |||
| 347 | + | ||||
| 348 | + | func TestHandler_AttachmentsByImageGet_MissingImagePath(t *testing.T) { | |||
| 349 | + | configSvc := inmocks.NewMockConfigService(t) | |||
| 350 | + | authSvc := inmocks.NewMockAuthService(t) | |||
| 351 | + | containerSvc := inmocks.NewMockContainerService(t) | |||
| 352 | + | secretSvc := inmocks.NewMockSecretService(t) | |||
| 353 | + | ||||
| 354 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil) | |||
| 355 | + | ||||
| 356 | + | req := httptest.NewRequest("GET", "/admin/attachments/by-image", nil) | |||
| 357 | + | req = req.WithContext(ctxWithScopes("admin:config:read")) | |||
| 358 | + | rec := httptest.NewRecorder() | |||
| 359 | + | ||||
| 360 | + | handler.ServeHTTP(rec, req) | |||
| 361 | + | ||||
| 362 | + | assert.Equal(t, http.StatusBadRequest, rec.Code) | |||
| 363 | + | assert.JSONEq(t, `{"error":"image name required in path"}`, rec.Body.String()) | |||
| 364 | + | } | |||
| 365 | + | ||||
| 366 | + | func TestHandler_AttachmentsByImageGet_InvalidEncoding(t *testing.T) { | |||
| 367 | + | configSvc := inmocks.NewMockConfigService(t) | |||
| 368 | + | authSvc := inmocks.NewMockAuthService(t) | |||
| 369 | + | containerSvc := inmocks.NewMockContainerService(t) | |||
| 370 | + | secretSvc := inmocks.NewMockSecretService(t) | |||
| 371 | + | ||||
| 372 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil) | |||
| 373 | + | ||||
| 374 | + | req := httptest.NewRequest("GET", "/admin/attachments/by-image/placeholder", nil) | |||
| 375 | + | req.URL = &url.URL{Path: "/admin/attachments/by-image/%zz"} | |||
| 376 | + | req = req.WithContext(ctxWithScopes("admin:config:read")) | |||
| 377 | + | rec := httptest.NewRecorder() | |||
| 378 | + | ||||
| 379 | + | handler.ServeHTTP(rec, req) | |||
| 380 | + | ||||
| 381 | + | assert.Equal(t, http.StatusBadRequest, rec.Code) | |||
| 382 | + | assert.JSONEq(t, `{"error":"invalid image name encoding"}`, rec.Body.String()) | |||
| 383 | + | } | |||
| 384 | + | ||||
| 385 | + | func TestHandler_AttachmentsByImageGet_RequiresReadScope(t *testing.T) { | |||
| 386 | + | configSvc := inmocks.NewMockConfigService(t) | |||
| 387 | + | authSvc := inmocks.NewMockAuthService(t) | |||
| 388 | + | containerSvc := inmocks.NewMockContainerService(t) | |||
| 389 | + | secretSvc := inmocks.NewMockSecretService(t) | |||
| 390 | + | ||||
| 391 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil) | |||
| 392 | + | ||||
| 393 | + | req := httptest.NewRequest("GET", "/admin/attachments/by-image/postgres:16", nil) | |||
| 394 | + | req = req.WithContext(ctxWithScopes("admin:routes:read")) | |||
| 395 | + | rec := httptest.NewRecorder() | |||
| 396 | + | ||||
| 397 | + | handler.ServeHTTP(rec, req) | |||
| 398 | + | ||||
| 399 | + | assert.Equal(t, http.StatusForbidden, rec.Code) | |||
| 400 | + | assert.JSONEq(t, `{"error":"insufficient permissions for config:read"}`, rec.Body.String()) | |||
| 401 | + | } | |||
| 402 | + | ||||
| 267 | func TestHandler_RoutesDelete_RequiresWriteScope(t *testing.T) { | 403 | func TestHandler_RoutesDelete_RequiresWriteScope(t *testing.T) { | ||
| 268 | configSvc := inmocks.NewMockConfigService(t) | 404 | configSvc := inmocks.NewMockConfigService(t) | ||
| 269 | authSvc := inmocks.NewMockAuthService(t) | 405 | authSvc := inmocks.NewMockAuthService(t) | ||
M internal/boundaries/in/config.go +3 -0
| @@ -71,6 +71,9 @@ type ConfigService interface { | |||
| 71 | 71 | // GetAttachmentsFor returns attachments for a specific domain or network group. | |
| 72 | 72 | GetAttachmentsFor(ctx context.Context, domainOrGroup string) ([]string, error) | |
| 73 | 73 | ||
| 74 | + | // FindAttachmentTargetsByImage returns all attachment targets that match the given image name. | |
| 75 | + | FindAttachmentTargetsByImage(ctx context.Context, imageName string) []string | |
| 76 | + | ||
| 74 | 77 | // AddAttachment adds an image to a domain/group's attachments. | |
| 75 | 78 | AddAttachment(ctx context.Context, domainOrGroup, image string) error | |
| 76 | 79 | ||
| @@ -71,6 +71,9 @@ type ConfigService interface { | |||||
| 71 | // GetAttachmentsFor returns attachments for a specific domain or network group. | 71 | // GetAttachmentsFor returns attachments for a specific domain or network group. | ||
| 72 | GetAttachmentsFor(ctx context.Context, domainOrGroup string) ([]string, error) | 72 | GetAttachmentsFor(ctx context.Context, domainOrGroup string) ([]string, error) | ||
| 73 | 73 | ||||
| 74 | + | // FindAttachmentTargetsByImage returns all attachment targets that match the given image name. | |||
| 75 | + | FindAttachmentTargetsByImage(ctx context.Context, imageName string) []string | |||
| 76 | + | ||||
| 74 | // AddAttachment adds an image to a domain/group's attachments. | 77 | // AddAttachment adds an image to a domain/group's attachments. | ||
| 75 | AddAttachment(ctx context.Context, domainOrGroup, image string) error | 78 | AddAttachment(ctx context.Context, domainOrGroup, image string) error | ||
| 76 | 79 | ||||
M internal/boundaries/in/mocks/mock_config_service.go +59 -0
| @@ -217,6 +217,65 @@ func (_c *MockConfigService_FindRoutesByImage_Call) RunAndReturn(run func(ctx co | |||
| 217 | 217 | return _c | |
| 218 | 218 | } | |
| 219 | 219 | ||
| 220 | + | // FindAttachmentTargetsByImage provides a mock function for the type MockConfigService | |
| 221 | + | func (_mock *MockConfigService) FindAttachmentTargetsByImage(ctx context.Context, imageName string) []string { | |
| 222 | + | ret := _mock.Called(ctx, imageName) | |
| 223 | + | ||
| 224 | + | if len(ret) == 0 { | |
| 225 | + | panic("no return value specified for FindAttachmentTargetsByImage") | |
| 226 | + | } | |
| 227 | + | ||
| 228 | + | var r0 []string | |
| 229 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string) []string); ok { | |
| 230 | + | r0 = returnFunc(ctx, imageName) | |
| 231 | + | } else { | |
| 232 | + | if ret.Get(0) != nil { | |
| 233 | + | r0 = ret.Get(0).([]string) | |
| 234 | + | } | |
| 235 | + | } | |
| 236 | + | return r0 | |
| 237 | + | } | |
| 238 | + | ||
| 239 | + | // MockConfigService_FindAttachmentTargetsByImage_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'FindAttachmentTargetsByImage' | |
| 240 | + | type MockConfigService_FindAttachmentTargetsByImage_Call struct { | |
| 241 | + | *mock.Call | |
| 242 | + | } | |
| 243 | + | ||
| 244 | + | // FindAttachmentTargetsByImage is a helper method to define mock.On call | |
| 245 | + | // - ctx context.Context | |
| 246 | + | // - imageName string | |
| 247 | + | func (_e *MockConfigService_Expecter) FindAttachmentTargetsByImage(ctx interface{}, imageName interface{}) *MockConfigService_FindAttachmentTargetsByImage_Call { | |
| 248 | + | return &MockConfigService_FindAttachmentTargetsByImage_Call{Call: _e.mock.On("FindAttachmentTargetsByImage", ctx, imageName)} | |
| 249 | + | } | |
| 250 | + | ||
| 251 | + | func (_c *MockConfigService_FindAttachmentTargetsByImage_Call) Run(run func(ctx context.Context, imageName string)) *MockConfigService_FindAttachmentTargetsByImage_Call { | |
| 252 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 253 | + | var arg0 context.Context | |
| 254 | + | if args[0] != nil { | |
| 255 | + | arg0 = args[0].(context.Context) | |
| 256 | + | } | |
| 257 | + | var arg1 string | |
| 258 | + | if args[1] != nil { | |
| 259 | + | arg1 = args[1].(string) | |
| 260 | + | } | |
| 261 | + | run( | |
| 262 | + | arg0, | |
| 263 | + | arg1, | |
| 264 | + | ) | |
| 265 | + | }) | |
| 266 | + | return _c | |
| 267 | + | } | |
| 268 | + | ||
| 269 | + | func (_c *MockConfigService_FindAttachmentTargetsByImage_Call) Return(targets []string) *MockConfigService_FindAttachmentTargetsByImage_Call { | |
| 270 | + | _c.Call.Return(targets) | |
| 271 | + | return _c | |
| 272 | + | } | |
| 273 | + | ||
| 274 | + | func (_c *MockConfigService_FindAttachmentTargetsByImage_Call) RunAndReturn(run func(ctx context.Context, imageName string) []string) *MockConfigService_FindAttachmentTargetsByImage_Call { | |
| 275 | + | _c.Call.Return(run) | |
| 276 | + | return _c | |
| 277 | + | } | |
| 278 | + | ||
| 220 | 279 | // GetAllAttachments provides a mock function for the type MockConfigService | |
| 221 | 280 | func (_mock *MockConfigService) GetAllAttachments(ctx context.Context) map[string][]string { | |
| 222 | 281 | ret := _mock.Called(ctx) | |
| @@ -217,6 +217,65 @@ func (_c *MockConfigService_FindRoutesByImage_Call) RunAndReturn(run func(ctx co | |||||
| 217 | return _c | 217 | return _c | ||
| 218 | } | 218 | } | ||
| 219 | 219 | ||||
| 220 | + | // FindAttachmentTargetsByImage provides a mock function for the type MockConfigService | |||
| 221 | + | func (_mock *MockConfigService) FindAttachmentTargetsByImage(ctx context.Context, imageName string) []string { | |||
| 222 | + | ret := _mock.Called(ctx, imageName) | |||
| 223 | + | ||||
| 224 | + | if len(ret) == 0 { | |||
| 225 | + | panic("no return value specified for FindAttachmentTargetsByImage") | |||
| 226 | + | } | |||
| 227 | + | ||||
| 228 | + | var r0 []string | |||
| 229 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string) []string); ok { | |||
| 230 | + | r0 = returnFunc(ctx, imageName) | |||
| 231 | + | } else { | |||
| 232 | + | if ret.Get(0) != nil { | |||
| 233 | + | r0 = ret.Get(0).([]string) | |||
| 234 | + | } | |||
| 235 | + | } | |||
| 236 | + | return r0 | |||
| 237 | + | } | |||
| 238 | + | ||||
| 239 | + | // MockConfigService_FindAttachmentTargetsByImage_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'FindAttachmentTargetsByImage' | |||
| 240 | + | type MockConfigService_FindAttachmentTargetsByImage_Call struct { | |||
| 241 | + | *mock.Call | |||
| 242 | + | } | |||
| 243 | + | ||||
| 244 | + | // FindAttachmentTargetsByImage is a helper method to define mock.On call | |||
| 245 | + | // - ctx context.Context | |||
| 246 | + | // - imageName string | |||
| 247 | + | func (_e *MockConfigService_Expecter) FindAttachmentTargetsByImage(ctx interface{}, imageName interface{}) *MockConfigService_FindAttachmentTargetsByImage_Call { | |||
| 248 | + | return &MockConfigService_FindAttachmentTargetsByImage_Call{Call: _e.mock.On("FindAttachmentTargetsByImage", ctx, imageName)} | |||
| 249 | + | } | |||
| 250 | + | ||||
| 251 | + | func (_c *MockConfigService_FindAttachmentTargetsByImage_Call) Run(run func(ctx context.Context, imageName string)) *MockConfigService_FindAttachmentTargetsByImage_Call { | |||
| 252 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 253 | + | var arg0 context.Context | |||
| 254 | + | if args[0] != nil { | |||
| 255 | + | arg0 = args[0].(context.Context) | |||
| 256 | + | } | |||
| 257 | + | var arg1 string | |||
| 258 | + | if args[1] != nil { | |||
| 259 | + | arg1 = args[1].(string) | |||
| 260 | + | } | |||
| 261 | + | run( | |||
| 262 | + | arg0, | |||
| 263 | + | arg1, | |||
| 264 | + | ) | |||
| 265 | + | }) | |||
| 266 | + | return _c | |||
| 267 | + | } | |||
| 268 | + | ||||
| 269 | + | func (_c *MockConfigService_FindAttachmentTargetsByImage_Call) Return(targets []string) *MockConfigService_FindAttachmentTargetsByImage_Call { | |||
| 270 | + | _c.Call.Return(targets) | |||
| 271 | + | return _c | |||
| 272 | + | } | |||
| 273 | + | ||||
| 274 | + | func (_c *MockConfigService_FindAttachmentTargetsByImage_Call) RunAndReturn(run func(ctx context.Context, imageName string) []string) *MockConfigService_FindAttachmentTargetsByImage_Call { | |||
| 275 | + | _c.Call.Return(run) | |||
| 276 | + | return _c | |||
| 277 | + | } | |||
| 278 | + | ||||
| 220 | // GetAllAttachments provides a mock function for the type MockConfigService | 279 | // GetAllAttachments provides a mock function for the type MockConfigService | ||
| 221 | func (_mock *MockConfigService) GetAllAttachments(ctx context.Context) map[string][]string { | 280 | func (_mock *MockConfigService) GetAllAttachments(ctx context.Context) map[string][]string { | ||
| 222 | ret := _mock.Called(ctx) | 281 | ret := _mock.Called(ctx) | ||
M internal/usecase/config/service.go +32 -17
| @@ -241,25 +241,9 @@ func (s *Service) FindRoutesByImage(_ context.Context, imageName string) []domai | |||
| 241 | 241 | s.mu.RLock() | |
| 242 | 242 | defer s.mu.RUnlock() | |
| 243 | 243 | ||
| 244 | - | registryDomain := s.config.RegistryDomain | |
| 245 | - | normalizedImage := NormalizeRegistryImage(imageName, registryDomain) | |
| 246 | - | inputName, inputHasTag := splitImageNameTag(normalizedImage) | |
| 247 | - | ||
| 248 | 244 | var routes []domain.Route | |
| 249 | 245 | for domainName, image := range s.config.Routes { | |
| 250 | - | normalizedRouteImage := NormalizeRegistryImage(image, registryDomain) | |
| 251 | - | ||
| 252 | - | match := false | |
| 253 | - | if inputHasTag { | |
| 254 | - | // Exact match when the caller provided a tag (e.g. event handler). | |
| 255 | - | match = strings.EqualFold(normalizedRouteImage, normalizedImage) | |
| 256 | - | } else { | |
| 257 | - | // Name-only match when no tag was provided (e.g. CLI push). | |
| 258 | - | routeName, _ := splitImageNameTag(normalizedRouteImage) | |
| 259 | - | match = strings.EqualFold(routeName, inputName) | |
| 260 | - | } | |
| 261 | - | ||
| 262 | - | if match { | |
| 246 | + | if matchesImageName(imageName, image, s.config.RegistryDomain) { | |
| 263 | 247 | route := domain.Route{ | |
| 264 | 248 | Image: image, | |
| 265 | 249 | HTTPS: true, | |
| @@ -277,6 +261,37 @@ func (s *Service) FindRoutesByImage(_ context.Context, imageName string) []domai | |||
| 277 | 261 | return routes | |
| 278 | 262 | } | |
| 279 | 263 | ||
| 264 | + | // FindAttachmentTargetsByImage returns all attachment targets whose image matches the given image name. | |
| 265 | + | func (s *Service) FindAttachmentTargetsByImage(_ context.Context, imageName string) []string { | |
| 266 | + | s.mu.RLock() | |
| 267 | + | defer s.mu.RUnlock() | |
| 268 | + | ||
| 269 | + | var targets []string | |
| 270 | + | for target, images := range s.config.Attachments { | |
| 271 | + | for _, image := range images { | |
| 272 | + | if matchesImageName(imageName, image, s.config.RegistryDomain) { | |
| 273 | + | targets = append(targets, target) | |
| 274 | + | break | |
| 275 | + | } | |
| 276 | + | } | |
| 277 | + | } | |
| 278 | + | ||
| 279 | + | return targets | |
| 280 | + | } | |
| 281 | + | ||
| 282 | + | func matchesImageName(inputImage, candidateImage, registryDomain string) bool { | |
| 283 | + | normalizedInput := NormalizeRegistryImage(inputImage, registryDomain) | |
| 284 | + | inputName, inputHasTag := splitImageNameTag(normalizedInput) | |
| 285 | + | normalizedCandidate := NormalizeRegistryImage(candidateImage, registryDomain) | |
| 286 | + | ||
| 287 | + | if inputHasTag { | |
| 288 | + | return strings.EqualFold(normalizedCandidate, normalizedInput) | |
| 289 | + | } | |
| 290 | + | ||
| 291 | + | candidateName, _ := splitImageNameTag(normalizedCandidate) | |
| 292 | + | return strings.EqualFold(candidateName, inputName) | |
| 293 | + | } | |
| 294 | + | ||
| 280 | 295 | // splitImageNameTag splits "name:tag" into ("name", true) or ("name", false) when no tag is present. | |
| 281 | 296 | func splitImageNameTag(image string) (name string, hasTag bool) { | |
| 282 | 297 | if idx := strings.LastIndex(image, ":"); idx != -1 { | |
| @@ -241,25 +241,9 @@ func (s *Service) FindRoutesByImage(_ context.Context, imageName string) []domai | |||||
| 241 | s.mu.RLock() | 241 | s.mu.RLock() | ||
| 242 | defer s.mu.RUnlock() | 242 | defer s.mu.RUnlock() | ||
| 243 | 243 | ||||
| 244 | - | registryDomain := s.config.RegistryDomain | |||
| 245 | - | normalizedImage := NormalizeRegistryImage(imageName, registryDomain) | |||
| 246 | - | inputName, inputHasTag := splitImageNameTag(normalizedImage) | |||
| 247 | - | ||||
| 248 | var routes []domain.Route | 244 | var routes []domain.Route | ||
| 249 | for domainName, image := range s.config.Routes { | 245 | for domainName, image := range s.config.Routes { | ||
| 250 | - | normalizedRouteImage := NormalizeRegistryImage(image, registryDomain) | 246 | + | if matchesImageName(imageName, image, s.config.RegistryDomain) { |
| 251 | - | ||||
| 252 | - | match := false | |||
| 253 | - | if inputHasTag { | |||
| 254 | - | // Exact match when the caller provided a tag (e.g. event handler). | |||
| 255 | - | match = strings.EqualFold(normalizedRouteImage, normalizedImage) | |||
| 256 | - | } else { | |||
| 257 | - | // Name-only match when no tag was provided (e.g. CLI push). | |||
| 258 | - | routeName, _ := splitImageNameTag(normalizedRouteImage) | |||
| 259 | - | match = strings.EqualFold(routeName, inputName) | |||
| 260 | - | } | |||
| 261 | - | ||||
| 262 | - | if match { | |||
| 263 | route := domain.Route{ | 247 | route := domain.Route{ | ||
| 264 | Image: image, | 248 | Image: image, | ||
| 265 | HTTPS: true, | 249 | HTTPS: true, | ||
| @@ -277,6 +261,37 @@ func (s *Service) FindRoutesByImage(_ context.Context, imageName string) []domai | |||||
| 277 | return routes | 261 | return routes | ||
| 278 | } | 262 | } | ||
| 279 | 263 | ||||
| 264 | + | // FindAttachmentTargetsByImage returns all attachment targets whose image matches the given image name. | |||
| 265 | + | func (s *Service) FindAttachmentTargetsByImage(_ context.Context, imageName string) []string { | |||
| 266 | + | s.mu.RLock() | |||
| 267 | + | defer s.mu.RUnlock() | |||
| 268 | + | ||||
| 269 | + | var targets []string | |||
| 270 | + | for target, images := range s.config.Attachments { | |||
| 271 | + | for _, image := range images { | |||
| 272 | + | if matchesImageName(imageName, image, s.config.RegistryDomain) { | |||
| 273 | + | targets = append(targets, target) | |||
| 274 | + | break | |||
| 275 | + | } | |||
| 276 | + | } | |||
| 277 | + | } | |||
| 278 | + | ||||
| 279 | + | return targets | |||
| 280 | + | } | |||
| 281 | + | ||||
| 282 | + | func matchesImageName(inputImage, candidateImage, registryDomain string) bool { | |||
| 283 | + | normalizedInput := NormalizeRegistryImage(inputImage, registryDomain) | |||
| 284 | + | inputName, inputHasTag := splitImageNameTag(normalizedInput) | |||
| 285 | + | normalizedCandidate := NormalizeRegistryImage(candidateImage, registryDomain) | |||
| 286 | + | ||||
| 287 | + | if inputHasTag { | |||
| 288 | + | return strings.EqualFold(normalizedCandidate, normalizedInput) | |||
| 289 | + | } | |||
| 290 | + | ||||
| 291 | + | candidateName, _ := splitImageNameTag(normalizedCandidate) | |||
| 292 | + | return strings.EqualFold(candidateName, inputName) | |||
| 293 | + | } | |||
| 294 | + | ||||
| 280 | // splitImageNameTag splits "name:tag" into ("name", true) or ("name", false) when no tag is present. | 295 | // splitImageNameTag splits "name:tag" into ("name", true) or ("name", false) when no tag is present. | ||
| 281 | func splitImageNameTag(image string) (name string, hasTag bool) { | 296 | func splitImageNameTag(image string) (name string, hasTag bool) { | ||
| 282 | if idx := strings.LastIndex(image, ":"); idx != -1 { | 297 | if idx := strings.LastIndex(image, ":"); idx != -1 { | ||
M internal/usecase/config/service_test.go +80 -0
| @@ -1155,6 +1155,86 @@ func TestService_FindRoutesByImage(t *testing.T) { | |||
| 1155 | 1155 | }) | |
| 1156 | 1156 | } | |
| 1157 | 1157 | ||
| 1158 | + | func TestService_FindAttachmentTargetsByImage(t *testing.T) { | |
| 1159 | + | t.Run("exact tagged matches", func(t *testing.T) { | |
| 1160 | + | v := viper.New() | |
| 1161 | + | v.Set("attachments", map[string]interface{}{ | |
| 1162 | + | "app.example.com": []interface{}{"redis:latest", "postgres:18"}, | |
| 1163 | + | }) | |
| 1164 | + | ||
| 1165 | + | eventBus := mocks.NewMockEventPublisher(t) | |
| 1166 | + | svc := NewService(v, eventBus) | |
| 1167 | + | ctx := testContext() | |
| 1168 | + | _ = svc.Load(ctx) | |
| 1169 | + | ||
| 1170 | + | targets := svc.FindAttachmentTargetsByImage(ctx, "postgres:18") | |
| 1171 | + | assert.Equal(t, []string{"app.example.com"}, targets) | |
| 1172 | + | }) | |
| 1173 | + | ||
| 1174 | + | t.Run("bare name matches tagged attachments", func(t *testing.T) { | |
| 1175 | + | v := viper.New() | |
| 1176 | + | v.Set("attachments", map[string]interface{}{ | |
| 1177 | + | "app.example.com": []interface{}{"postgres:18"}, | |
| 1178 | + | }) | |
| 1179 | + | ||
| 1180 | + | eventBus := mocks.NewMockEventPublisher(t) | |
| 1181 | + | svc := NewService(v, eventBus) | |
| 1182 | + | ctx := testContext() | |
| 1183 | + | _ = svc.Load(ctx) | |
| 1184 | + | ||
| 1185 | + | targets := svc.FindAttachmentTargetsByImage(ctx, "postgres") | |
| 1186 | + | assert.Equal(t, []string{"app.example.com"}, targets) | |
| 1187 | + | }) | |
| 1188 | + | ||
| 1189 | + | t.Run("registry qualified input is normalized", func(t *testing.T) { | |
| 1190 | + | v := viper.New() | |
| 1191 | + | v.Set("server.registry_domain", "reg.example.com") | |
| 1192 | + | v.Set("attachments", map[string]interface{}{ | |
| 1193 | + | "backend": []interface{}{"reg.example.com/postgres:18"}, | |
| 1194 | + | }) | |
| 1195 | + | ||
| 1196 | + | eventBus := mocks.NewMockEventPublisher(t) | |
| 1197 | + | svc := NewService(v, eventBus) | |
| 1198 | + | ctx := testContext() | |
| 1199 | + | _ = svc.Load(ctx) | |
| 1200 | + | ||
| 1201 | + | targets := svc.FindAttachmentTargetsByImage(ctx, "reg.example.com/postgres:18") | |
| 1202 | + | assert.Equal(t, []string{"backend"}, targets) | |
| 1203 | + | }) | |
| 1204 | + | ||
| 1205 | + | t.Run("shared attachment used by multiple targets", func(t *testing.T) { | |
| 1206 | + | v := viper.New() | |
| 1207 | + | v.Set("attachments", map[string]interface{}{ | |
| 1208 | + | "app.example.com": []interface{}{"redis:latest"}, | |
| 1209 | + | "backend": []interface{}{"redis:latest", "postgres:18"}, | |
| 1210 | + | "worker": []interface{}{"rabbitmq:3"}, | |
| 1211 | + | }) | |
| 1212 | + | ||
| 1213 | + | eventBus := mocks.NewMockEventPublisher(t) | |
| 1214 | + | svc := NewService(v, eventBus) | |
| 1215 | + | ctx := testContext() | |
| 1216 | + | _ = svc.Load(ctx) | |
| 1217 | + | ||
| 1218 | + | targets := svc.FindAttachmentTargetsByImage(ctx, "redis") | |
| 1219 | + | assert.ElementsMatch(t, []string{"app.example.com", "backend"}, targets) | |
| 1220 | + | }) | |
| 1221 | + | ||
| 1222 | + | t.Run("no matches", func(t *testing.T) { | |
| 1223 | + | v := viper.New() | |
| 1224 | + | v.Set("attachments", map[string]interface{}{ | |
| 1225 | + | "app.example.com": []interface{}{"redis:latest"}, | |
| 1226 | + | }) | |
| 1227 | + | ||
| 1228 | + | eventBus := mocks.NewMockEventPublisher(t) | |
| 1229 | + | svc := NewService(v, eventBus) | |
| 1230 | + | ctx := testContext() | |
| 1231 | + | _ = svc.Load(ctx) | |
| 1232 | + | ||
| 1233 | + | targets := svc.FindAttachmentTargetsByImage(ctx, "postgres") | |
| 1234 | + | assert.Empty(t, targets) | |
| 1235 | + | }) | |
| 1236 | + | } | |
| 1237 | + | ||
| 1158 | 1238 | func TestSavePreservesAllConfigFields(t *testing.T) { | |
| 1159 | 1239 | t.Run("with defaults does not corrupt config", func(t *testing.T) { | |
| 1160 | 1240 | tmpDir := t.TempDir() | |
| @@ -1155,6 +1155,86 @@ func TestService_FindRoutesByImage(t *testing.T) { | |||||
| 1155 | }) | 1155 | }) | ||
| 1156 | } | 1156 | } | ||
| 1157 | 1157 | ||||
| 1158 | + | func TestService_FindAttachmentTargetsByImage(t *testing.T) { | |||
| 1159 | + | t.Run("exact tagged matches", func(t *testing.T) { | |||
| 1160 | + | v := viper.New() | |||
| 1161 | + | v.Set("attachments", map[string]interface{}{ | |||
| 1162 | + | "app.example.com": []interface{}{"redis:latest", "postgres:18"}, | |||
| 1163 | + | }) | |||
| 1164 | + | ||||
| 1165 | + | eventBus := mocks.NewMockEventPublisher(t) | |||
| 1166 | + | svc := NewService(v, eventBus) | |||
| 1167 | + | ctx := testContext() | |||
| 1168 | + | _ = svc.Load(ctx) | |||
| 1169 | + | ||||
| 1170 | + | targets := svc.FindAttachmentTargetsByImage(ctx, "postgres:18") | |||
| 1171 | + | assert.Equal(t, []string{"app.example.com"}, targets) | |||
| 1172 | + | }) | |||
| 1173 | + | ||||
| 1174 | + | t.Run("bare name matches tagged attachments", func(t *testing.T) { | |||
| 1175 | + | v := viper.New() | |||
| 1176 | + | v.Set("attachments", map[string]interface{}{ | |||
| 1177 | + | "app.example.com": []interface{}{"postgres:18"}, | |||
| 1178 | + | }) | |||
| 1179 | + | ||||
| 1180 | + | eventBus := mocks.NewMockEventPublisher(t) | |||
| 1181 | + | svc := NewService(v, eventBus) | |||
| 1182 | + | ctx := testContext() | |||
| 1183 | + | _ = svc.Load(ctx) | |||
| 1184 | + | ||||
| 1185 | + | targets := svc.FindAttachmentTargetsByImage(ctx, "postgres") | |||
| 1186 | + | assert.Equal(t, []string{"app.example.com"}, targets) | |||
| 1187 | + | }) | |||
| 1188 | + | ||||
| 1189 | + | t.Run("registry qualified input is normalized", func(t *testing.T) { | |||
| 1190 | + | v := viper.New() | |||
| 1191 | + | v.Set("server.registry_domain", "reg.example.com") | |||
| 1192 | + | v.Set("attachments", map[string]interface{}{ | |||
| 1193 | + | "backend": []interface{}{"reg.example.com/postgres:18"}, | |||
| 1194 | + | }) | |||
| 1195 | + | ||||
| 1196 | + | eventBus := mocks.NewMockEventPublisher(t) | |||
| 1197 | + | svc := NewService(v, eventBus) | |||
| 1198 | + | ctx := testContext() | |||
| 1199 | + | _ = svc.Load(ctx) | |||
| 1200 | + | ||||
| 1201 | + | targets := svc.FindAttachmentTargetsByImage(ctx, "reg.example.com/postgres:18") | |||
| 1202 | + | assert.Equal(t, []string{"backend"}, targets) | |||
| 1203 | + | }) | |||
| 1204 | + | ||||
| 1205 | + | t.Run("shared attachment used by multiple targets", func(t *testing.T) { | |||
| 1206 | + | v := viper.New() | |||
| 1207 | + | v.Set("attachments", map[string]interface{}{ | |||
| 1208 | + | "app.example.com": []interface{}{"redis:latest"}, | |||
| 1209 | + | "backend": []interface{}{"redis:latest", "postgres:18"}, | |||
| 1210 | + | "worker": []interface{}{"rabbitmq:3"}, | |||
| 1211 | + | }) | |||
| 1212 | + | ||||
| 1213 | + | eventBus := mocks.NewMockEventPublisher(t) | |||
| 1214 | + | svc := NewService(v, eventBus) | |||
| 1215 | + | ctx := testContext() | |||
| 1216 | + | _ = svc.Load(ctx) | |||
| 1217 | + | ||||
| 1218 | + | targets := svc.FindAttachmentTargetsByImage(ctx, "redis") | |||
| 1219 | + | assert.ElementsMatch(t, []string{"app.example.com", "backend"}, targets) | |||
| 1220 | + | }) | |||
| 1221 | + | ||||
| 1222 | + | t.Run("no matches", func(t *testing.T) { | |||
| 1223 | + | v := viper.New() | |||
| 1224 | + | v.Set("attachments", map[string]interface{}{ | |||
| 1225 | + | "app.example.com": []interface{}{"redis:latest"}, | |||
| 1226 | + | }) | |||
| 1227 | + | ||||
| 1228 | + | eventBus := mocks.NewMockEventPublisher(t) | |||
| 1229 | + | svc := NewService(v, eventBus) | |||
| 1230 | + | ctx := testContext() | |||
| 1231 | + | _ = svc.Load(ctx) | |||
| 1232 | + | ||||
| 1233 | + | targets := svc.FindAttachmentTargetsByImage(ctx, "postgres") | |||
| 1234 | + | assert.Empty(t, targets) | |||
| 1235 | + | }) | |||
| 1236 | + | } | |||
| 1237 | + | ||||
| 1158 | func TestSavePreservesAllConfigFields(t *testing.T) { | 1238 | func TestSavePreservesAllConfigFields(t *testing.T) { | ||
| 1159 | t.Run("with defaults does not corrupt config", func(t *testing.T) { | 1239 | t.Run("with defaults does not corrupt config", func(t *testing.T) { | ||
| 1160 | tmpDir := t.TempDir() | 1240 | tmpDir := t.TempDir() | ||