detached 8a929d8

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
1919 |------------|-------------|
2020 | `list` | List all attachments or attachments for a specific target |
2121 | `add` | Add an attachment to a domain or network group |
22+| `push` | Build or push attachment images to the Gordon registry |
2223 | `remove` | Remove an attachment from a domain or network group |
2324
2425 ### Alias
@@ -128,6 +129,51 @@ gordon attachments add app.example.com postgres:18 --remote https://gordon.mydom
128129
129130 ---
130131
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+
131177 ## gordon attachments remove
132178
133179 Remove an attachment.
@@ -194,6 +240,19 @@ gordon attachments add backend redis:7-alpine
194240 # Both app.example.com and api.example.com can now access the shared Redis
195241 ```
196242
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+
197256 ### CI/CD Integration
198257
199258 ```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 ### Alias25 ### 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 remove177 ## 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 Redis240 # 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 Integration256 ### CI/CD Integration
198 257
199 ```bash258 ```bash
M docs/cli/bootstrap.md +8 -0
@@ -58,15 +58,23 @@ gordon bootstrap app.example.com myapp:latest \
5858
5959 # Remote target
6060 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
6167 ```
6268
6369 ### Notes
6470
6571 - `gordon bootstrap` is idempotent for route configuration: rerunning it re-applies config instead of failing.
6672 - 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.
6774
6875 ## Related
6976
7077 - [CLI Overview](./index.md)
78+- [Attachments Commands](./attachments.md)
7179 - [Routes Commands](./routes.md)
7280 - [Push Command](./push.md)
@@ -58,15 +58,23 @@ gordon bootstrap app.example.com myapp:latest \
58 58
59 # Remote target59 # Remote target
60 gordon bootstrap app.example.com myapp:latest --remote https://gordon.mydomain.com --token $TOKEN60 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 ### Notes69 ### 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 ## Related75 ## 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
3030 | `gordon restart` | Restart a running container | [restart](./restart.md) |
3131 | `gordon bootstrap` | Configure a route, attachments, and secrets for an app | [bootstrap](./bootstrap.md) |
3232 | `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) |
3334 | `gordon rollback` | Roll back to a previous image tag | [rollback](./rollback.md) |
3435 | `gordon reload` | Reload configuration and sync containers | [serve](./serve.md#gordon-reload) |
3536 | `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]
3535 `gordon push` resolves the route image for `<domain>`, tags the image for the
3636 Gordon registry, pushes it, and optionally deploys it.
3737
38+To push attachment images (databases, caches, etc.), use `gordon attachments push`.
39+
3840 - 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.
3941 - The registry and repository are derived from the route image on the server.
4042 - 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
7476 ## Related
7577
7678 - [CLI Overview](./index.md)
79+- [Attachments Commands](./attachments.md)
7780 - [Routes Command](./routes.md)
7881 - [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 the35 `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 ## Related76 ## 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 {
1212 Images []string `json:"images"`
1313 }
1414
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+
1521 // AttachmentAddRequest represents a request to add an attachment.
1622 type AttachmentAddRequest struct {
1723 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.`,
3232 cmd.AddCommand(newAttachmentsListCmd())
3333 cmd.AddCommand(newAttachmentsAddCmd())
3434 cmd.AddCommand(newAttachmentsRemoveCmd())
35+ cmd.AddCommand(newAttachmentsPushCmd())
3536
3637 return cmd
3738 }
@@ -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 cmd37 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 {
3030
3131 GetAllAttachmentsConfig(ctx context.Context) (map[string][]string, error)
3232 GetAttachmentsConfig(ctx context.Context, domainOrGroup string) ([]string, error)
33+ FindAttachmentTargetsByImage(ctx context.Context, imageName string) ([]string, error)
3334 AddAttachment(ctx context.Context, domainOrGroup, image string) error
3435 RemoveAttachment(ctx context.Context, domainOrGroup, image string) error
3536
@@ -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) error34 AddAttachment(ctx context.Context, domainOrGroup, image string) error
34 RemoveAttachment(ctx context.Context, domainOrGroup, image string) error35 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
286286 return l.configSvc.GetAttachmentsFor(ctx, domainOrGroup)
287287 }
288288
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+
289296 func (l *localControlPlane) AddAttachment(ctx context.Context, domainOrGroup, image string) error {
290297 if l.configSvc == nil {
291298 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
7676 return r.client.GetAttachmentsConfig(ctx, domainOrGroup)
7777 }
7878
79+func (r *remoteControlPlane) FindAttachmentTargetsByImage(ctx context.Context, imageName string) ([]string, error) {
80+ return r.client.FindAttachmentTargetsByImage(ctx, imageName)
81+}
82+
7983 func (r *remoteControlPlane) AddAttachment(ctx context.Context, domainOrGroup, image string) error {
8084 return r.client.AddAttachment(ctx, domainOrGroup, image)
8185 }
@@ -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
208208 if len(routes) == 0 {
209209 // bootstrap command is registered in bootstrap.go (see issue #98)
210210 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,
212213 imageArg,
213214 imageArg,
214215 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,
8484 panic("unexpected call")
8585 }
8686
87+func (c *resolveFromImageTestControlPlane) FindAttachmentTargetsByImage(context.Context, string) ([]string, error) {
88+ panic("unexpected call")
89+}
90+
8791 func (c *resolveFromImageTestControlPlane) AddAttachment(context.Context, string, string) error {
8892 panic("unexpected call")
8993 }
@@ -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)
895895 return result.Images, nil
896896 }
897897
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+
898913 // AddAttachment adds an attachment to a domain/group.
899914 func (c *Client) AddAttachment(ctx context.Context, domainOrGroup, image string) error {
900915 if domainOrGroup == "" {
@@ -895,6 +895,21 @@ func (c *Client) GetAttachmentsConfig(ctx context.Context, domainOrGroup string)
895 return result.Images, nil895 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) {
162162 assert.Equal(t, 2, resp.Runtime.DeletedCount)
163163 assert.Equal(t, 3, resp.Registry.TagsRemoved)
164164 }
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) {
218218 handler routeHandler
219219 }{
220220 {"/backups", h.handleBackups},
221+ {"/attachments/by-image", h.handleAttachmentsByImage},
221222 {"/attachments", h.handleAttachmentsConfig},
222223 {"/routes/by-image", h.handleRoutesByImage},
223224 {"/routes", h.handleRoutes},
@@ -238,6 +239,41 @@ func (h *Handler) matchRoute(path string) (routeHandler, bool) {
238239 return nil, false
239240 }
240241
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+
241277 // handleDeployIntent handles /admin/deploy-intent/:image endpoint.
242278 // It registers a deploy intent, suppressing event-based deploys for the image.
243279 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 routeHandler218 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, false239 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 (
88 "fmt"
99 "net/http"
1010 "net/http/httptest"
11+ "net/url"
1112 "strings"
1213 "testing"
1314 "time"
@@ -264,6 +265,141 @@ func TestHandler_RoutesPut_AllowsRouteUpdateWithoutRegistryManifest(t *testing.T
264265 registrySvc.AssertNotCalled(t, "GetManifest", mock.Anything, mock.Anything, mock.Anything)
265266 }
266267
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+
267403 func TestHandler_RoutesDelete_RequiresWriteScope(t *testing.T) {
268404 configSvc := inmocks.NewMockConfigService(t)
269405 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 {
7171 // GetAttachmentsFor returns attachments for a specific domain or network group.
7272 GetAttachmentsFor(ctx context.Context, domainOrGroup string) ([]string, error)
7373
74+ // FindAttachmentTargetsByImage returns all attachment targets that match the given image name.
75+ FindAttachmentTargetsByImage(ctx context.Context, imageName string) []string
76+
7477 // AddAttachment adds an image to a domain/group's attachments.
7578 AddAttachment(ctx context.Context, domainOrGroup, image string) error
7679
@@ -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) error78 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
217217 return _c
218218 }
219219
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+
220279 // GetAllAttachments provides a mock function for the type MockConfigService
221280 func (_mock *MockConfigService) GetAllAttachments(ctx context.Context) map[string][]string {
222281 ret := _mock.Called(ctx)
@@ -217,6 +217,65 @@ func (_c *MockConfigService_FindRoutesByImage_Call) RunAndReturn(run func(ctx co
217 return _c217 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 MockConfigService279 // 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
241241 s.mu.RLock()
242242 defer s.mu.RUnlock()
243243
244- registryDomain := s.config.RegistryDomain
245- normalizedImage := NormalizeRegistryImage(imageName, registryDomain)
246- inputName, inputHasTag := splitImageNameTag(normalizedImage)
247-
248244 var routes []domain.Route
249245 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) {
263247 route := domain.Route{
264248 Image: image,
265249 HTTPS: true,
@@ -277,6 +261,37 @@ func (s *Service) FindRoutesByImage(_ context.Context, imageName string) []domai
277261 return routes
278262 }
279263
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+
280295 // splitImageNameTag splits "name:tag" into ("name", true) or ("name", false) when no tag is present.
281296 func splitImageNameTag(image string) (name string, hasTag bool) {
282297 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.Route244 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 routes261 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) {
11551155 })
11561156 }
11571157
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+
11581238 func TestSavePreservesAllConfigFields(t *testing.T) {
11591239 t.Run("with defaults does not corrupt config", func(t *testing.T) {
11601240 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()

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close