detached 924ea04

Merge pull request #104 from bnema/feat/attachment-image-push-support

feat: add attachment image push support to CLI and control plane
commit
924ea04a11c73d97a3e13e1f2c32a2121cad9eea
author
bnema · · 2026-03-14 09:50 UTC
committer
GitHub · · 2026-03-14 09:50 UTC
merge of
22312a8 + aa8f6c2 · diff against the first parent
signature
unsigned
21 files · +1110 -23
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 +177 -0
@@ -0,0 +1,177 @@
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+ if err := printAttachmentPushInfo(out, imageArg, targets, versionRef, latestRef, version); err != nil {
81+ return err
82+ }
83+
84+ if err := performAttachmentPush(ctx, build, version, platform, dockerfile, buildArgs, registry, imageName, versionRef, latestRef); err != nil {
85+ return err
86+ }
87+
88+ return cliWriteLine(out, styles.RenderSuccess("Push complete"))
89+}
90+
91+func printAttachmentPushInfo(out io.Writer, imageArg string, targets []string, versionRef, latestRef, version string) error {
92+ if err := cliWritef(out, "Attachment image: %s\n", styles.Theme.Bold.Render(imageArg)); err != nil {
93+ return err
94+ }
95+ if err := cliWritef(out, "Targets: %s\n", styles.Theme.Bold.Render(formatAttachmentTargets(targets))); err != nil {
96+ return err
97+ }
98+ if err := cliWritef(out, "Image: %s\n", styles.Theme.Bold.Render(versionRef)); err != nil {
99+ return err
100+ }
101+ if version != "latest" {
102+ if err := cliWritef(out, "Also: %s\n", styles.Theme.Bold.Render(latestRef)); err != nil {
103+ return err
104+ }
105+ }
106+ return nil
107+}
108+
109+func performAttachmentPush(ctx context.Context, build bool, version, platform, dockerfile string, buildArgs []string, registry, imageName, versionRef, latestRef string) error {
110+ if build {
111+ return buildAndPushFn(ctx, version, platform, dockerfile, buildArgs, versionRef, latestRef)
112+ }
113+ return tagAndPushFn(ctx, registry, imageName, version, versionRef, latestRef)
114+}
115+
116+func resolveAttachmentImage(ctx context.Context, cp ControlPlane, imageArg string) (registry, imageName string, targets []string, err error) {
117+ imageName = normalizeAttachmentImageName(imageArg)
118+
119+ targets, err = cp.FindAttachmentTargetsByImage(ctx, imageArg)
120+ if err != nil {
121+ return "", "", nil, fmt.Errorf("failed to find attachment targets for image %q: %w", imageArg, err)
122+ }
123+ if len(targets) == 0 {
124+ return "", "", nil, fmt.Errorf("image %q is not configured as an attachment", imageArg)
125+ }
126+
127+ status, err := cp.GetStatus(ctx)
128+ if err != nil {
129+ return "", "", nil, fmt.Errorf("failed to get status: %w", err)
130+ }
131+
132+ return status.RegistryDomain, imageName, targets, nil
133+}
134+
135+func normalizeAttachmentImageName(imageArg string) string {
136+ name := imageArg
137+ if idx := strings.Index(name, "@"); idx != -1 {
138+ name = name[:idx]
139+ }
140+ if idx := strings.LastIndex(name, ":"); idx != -1 {
141+ slashIdx := strings.LastIndex(name, "/")
142+ if idx > slashIdx {
143+ name = name[:idx]
144+ }
145+ }
146+ parts := strings.SplitN(name, "/", 2)
147+ if len(parts) == 2 {
148+ host := parts[0]
149+ if strings.ContainsAny(host, ".:") || host == "localhost" {
150+ return parts[1]
151+ }
152+ }
153+ return name
154+}
155+
156+func formatAttachmentTargets(targets []string) string {
157+ cloned := append([]string(nil), targets...)
158+ sort.Strings(cloned)
159+ return fmt.Sprintf("%v", cloned)
160+}
161+
162+func resolveVersionWithFn(ctx context.Context, tag string) (string, error) {
163+ version := determineVersionFn(ctx, tag)
164+ if version != "latest" {
165+ if err := validateVersionTag(version); err != nil {
166+ return "", err
167+ }
168+ }
169+ return version, nil
170+}
171+
172+func validateVersionTag(version string) error {
173+ if err := validation.ValidateReference(version); err != nil {
174+ return fmt.Errorf("invalid version tag %q: %w", version, err)
175+ }
176+ return nil
177+}
@@ -0,0 +1,177 @@
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+ if err := printAttachmentPushInfo(out, imageArg, targets, versionRef, latestRef, version); err != nil {
81+ return err
82+ }
83+
84+ if err := performAttachmentPush(ctx, build, version, platform, dockerfile, buildArgs, registry, imageName, versionRef, latestRef); err != nil {
85+ return err
86+ }
87+
88+ return cliWriteLine(out, styles.RenderSuccess("Push complete"))
89+}
90+
91+func printAttachmentPushInfo(out io.Writer, imageArg string, targets []string, versionRef, latestRef, version string) error {
92+ if err := cliWritef(out, "Attachment image: %s\n", styles.Theme.Bold.Render(imageArg)); err != nil {
93+ return err
94+ }
95+ if err := cliWritef(out, "Targets: %s\n", styles.Theme.Bold.Render(formatAttachmentTargets(targets))); err != nil {
96+ return err
97+ }
98+ if err := cliWritef(out, "Image: %s\n", styles.Theme.Bold.Render(versionRef)); err != nil {
99+ return err
100+ }
101+ if version != "latest" {
102+ if err := cliWritef(out, "Also: %s\n", styles.Theme.Bold.Render(latestRef)); err != nil {
103+ return err
104+ }
105+ }
106+ return nil
107+}
108+
109+func performAttachmentPush(ctx context.Context, build bool, version, platform, dockerfile string, buildArgs []string, registry, imageName, versionRef, latestRef string) error {
110+ if build {
111+ return buildAndPushFn(ctx, version, platform, dockerfile, buildArgs, versionRef, latestRef)
112+ }
113+ return tagAndPushFn(ctx, registry, imageName, version, versionRef, latestRef)
114+}
115+
116+func resolveAttachmentImage(ctx context.Context, cp ControlPlane, imageArg string) (registry, imageName string, targets []string, err error) {
117+ imageName = normalizeAttachmentImageName(imageArg)
118+
119+ targets, err = cp.FindAttachmentTargetsByImage(ctx, imageArg)
120+ if err != nil {
121+ return "", "", nil, fmt.Errorf("failed to find attachment targets for image %q: %w", imageArg, err)
122+ }
123+ if len(targets) == 0 {
124+ return "", "", nil, fmt.Errorf("image %q is not configured as an attachment", imageArg)
125+ }
126+
127+ status, err := cp.GetStatus(ctx)
128+ if err != nil {
129+ return "", "", nil, fmt.Errorf("failed to get status: %w", err)
130+ }
131+
132+ return status.RegistryDomain, imageName, targets, nil
133+}
134+
135+func normalizeAttachmentImageName(imageArg string) string {
136+ name := imageArg
137+ if idx := strings.Index(name, "@"); idx != -1 {
138+ name = name[:idx]
139+ }
140+ if idx := strings.LastIndex(name, ":"); idx != -1 {
141+ slashIdx := strings.LastIndex(name, "/")
142+ if idx > slashIdx {
143+ name = name[:idx]
144+ }
145+ }
146+ parts := strings.SplitN(name, "/", 2)
147+ if len(parts) == 2 {
148+ host := parts[0]
149+ if strings.ContainsAny(host, ".:") || host == "localhost" {
150+ return parts[1]
151+ }
152+ }
153+ return name
154+}
155+
156+func formatAttachmentTargets(targets []string) string {
157+ cloned := append([]string(nil), targets...)
158+ sort.Strings(cloned)
159+ return fmt.Sprintf("%v", cloned)
160+}
161+
162+func resolveVersionWithFn(ctx context.Context, tag string) (string, error) {
163+ version := determineVersionFn(ctx, tag)
164+ if version != "latest" {
165+ if err := validateVersionTag(version); err != nil {
166+ return "", err
167+ }
168+ }
169+ return version, nil
170+}
171+
172+func validateVersionTag(version string) error {
173+ if err := validation.ValidateReference(version); err != nil {
174+ return fmt.Errorf("invalid version tag %q: %w", version, err)
175+ }
176+ return nil
177+}
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 +127 -18
@@ -2,10 +2,14 @@
22 package config
33
44 import (
5+ "cmp"
56 "context"
67 "fmt"
78 "os"
9+ "path/filepath"
810 "reflect"
11+ "slices"
12+ "strconv"
913 "strings"
1014 "sync"
1115 "sync/atomic"
@@ -237,25 +241,9 @@ func (s *Service) FindRoutesByImage(_ context.Context, imageName string) []domai
237241 s.mu.RLock()
238242 defer s.mu.RUnlock()
239243
240- registryDomain := s.config.RegistryDomain
241- normalizedImage := NormalizeRegistryImage(imageName, registryDomain)
242- inputName, inputHasTag := splitImageNameTag(normalizedImage)
243-
244244 var routes []domain.Route
245245 for domainName, image := range s.config.Routes {
246- normalizedRouteImage := NormalizeRegistryImage(image, registryDomain)
247-
248- match := false
249- if inputHasTag {
250- // Exact match when the caller provided a tag (e.g. event handler).
251- match = strings.EqualFold(normalizedRouteImage, normalizedImage)
252- } else {
253- // Name-only match when no tag was provided (e.g. CLI push).
254- routeName, _ := splitImageNameTag(normalizedRouteImage)
255- match = strings.EqualFold(routeName, inputName)
256- }
257-
258- if match {
246+ if matchesImageName(imageName, image, s.config.RegistryDomain) {
259247 route := domain.Route{
260248 Image: image,
261249 HTTPS: true,
@@ -273,6 +261,37 @@ func (s *Service) FindRoutesByImage(_ context.Context, imageName string) []domai
273261 return routes
274262 }
275263
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+
276295 // splitImageNameTag splits "name:tag" into ("name", true) or ("name", false) when no tag is present.
277296 func splitImageNameTag(image string) (name string, hasTag bool) {
278297 if idx := strings.LastIndex(image, ":"); idx != -1 {
@@ -593,16 +612,106 @@ func backupConfigFile(configFile string) error {
593612 return fmt.Errorf("failed to read config for backup: %w", err)
594613 }
595614
596- backupPath := configFile + ".bak"
615+ backupPath := fmt.Sprintf("%s.bak.%d", configFile, time.Now().UnixNano())
597616 if err := os.WriteFile(backupPath, src, 0600); err != nil {
598617 return fmt.Errorf("failed to write backup config: %w", err)
599618 }
619+ if err := cleanupOldBackups(configFile, 5); err != nil {
620+ return fmt.Errorf("failed to clean up old backups: %w", err)
621+ }
600622
601623 return nil
602624 }
603625
626+// cleanupOldBackups removes old backup files, keeping only the most recent `keep` backups.
627+// It only removes files matching the pattern `<configFile>.bak.<numeric-timestamp>`.
628+func cleanupOldBackups(configFile string, keep int) error {
629+ dir := filepath.Dir(configFile)
630+ base := filepath.Base(configFile)
631+ entries, err := os.ReadDir(dir)
632+ if err != nil {
633+ return fmt.Errorf("failed to read backup directory: %w", err)
634+ }
635+
636+ prefix := base + ".bak."
637+ type tsBackup struct {
638+ ts int64
639+ name string
640+ }
641+ var backups []tsBackup
642+ for _, entry := range entries {
643+ if entry.IsDir() {
644+ continue
645+ }
646+ name := entry.Name()
647+ if !strings.HasPrefix(name, prefix) {
648+ continue
649+ }
650+ suffix := strings.TrimPrefix(name, prefix)
651+ ts, err := strconv.ParseInt(suffix, 10, 64)
652+ if err != nil {
653+ continue
654+ }
655+ backups = append(backups, tsBackup{ts: ts, name: name})
656+ }
657+
658+ slices.SortFunc(backups, func(a, b tsBackup) int {
659+ return cmp.Compare(b.ts, a.ts)
660+ })
661+ if len(backups) <= keep {
662+ return nil
663+ }
664+
665+ var firstErr error
666+ for _, backup := range backups[keep:] {
667+ backupPath := filepath.Join(dir, backup.name)
668+ if err := os.Remove(backupPath); err != nil && firstErr == nil {
669+ firstErr = fmt.Errorf("failed to remove old backup %s: %w", backupPath, err)
670+ }
671+ }
672+
673+ return firstErr
674+}
675+
604676 func restoreConfigBackup(configFile string) error {
677+ dir := filepath.Dir(configFile)
678+ base := filepath.Base(configFile)
679+ entries, err := os.ReadDir(dir)
680+ if err != nil {
681+ return fmt.Errorf("failed to read backup directory: %w", err)
682+ }
683+
684+ prefix := base + ".bak."
685+ type tsBackup struct {
686+ ts int64
687+ name string
688+ }
689+ var backups []tsBackup
690+ for _, entry := range entries {
691+ if entry.IsDir() {
692+ continue
693+ }
694+ name := entry.Name()
695+ if !strings.HasPrefix(name, prefix) {
696+ continue
697+ }
698+ suffix := strings.TrimPrefix(name, prefix)
699+ ts, err := strconv.ParseInt(suffix, 10, 64)
700+ if err != nil {
701+ continue
702+ }
703+ backups = append(backups, tsBackup{ts: ts, name: name})
704+ }
705+
706+ slices.SortFunc(backups, func(a, b tsBackup) int {
707+ return cmp.Compare(b.ts, a.ts)
708+ })
709+
605710 backupPath := configFile + ".bak"
711+ if len(backups) > 0 {
712+ backupPath = filepath.Join(dir, backups[0].name)
713+ }
714+
606715 src, err := os.ReadFile(backupPath)
607716 if err != nil {
608717 return fmt.Errorf("failed to read backup: %w", err)
@@ -2,10 +2,14 @@
2 package config2 package config
3 3
4 import (4 import (
5+ "cmp"
5 "context"6 "context"
6 "fmt"7 "fmt"
7 "os"8 "os"
9+ "path/filepath"
8 "reflect"10 "reflect"
11+ "slices"
12+ "strconv"
9 "strings"13 "strings"
10 "sync"14 "sync"
11 "sync/atomic"15 "sync/atomic"
@@ -237,25 +241,9 @@ func (s *Service) FindRoutesByImage(_ context.Context, imageName string) []domai
237 s.mu.RLock()241 s.mu.RLock()
238 defer s.mu.RUnlock()242 defer s.mu.RUnlock()
239 243
240- registryDomain := s.config.RegistryDomain
241- normalizedImage := NormalizeRegistryImage(imageName, registryDomain)
242- inputName, inputHasTag := splitImageNameTag(normalizedImage)
243-
244 var routes []domain.Route244 var routes []domain.Route
245 for domainName, image := range s.config.Routes {245 for domainName, image := range s.config.Routes {
246- normalizedRouteImage := NormalizeRegistryImage(image, registryDomain)246+ if matchesImageName(imageName, image, s.config.RegistryDomain) {
247-
248- match := false
249- if inputHasTag {
250- // Exact match when the caller provided a tag (e.g. event handler).
251- match = strings.EqualFold(normalizedRouteImage, normalizedImage)
252- } else {
253- // Name-only match when no tag was provided (e.g. CLI push).
254- routeName, _ := splitImageNameTag(normalizedRouteImage)
255- match = strings.EqualFold(routeName, inputName)
256- }
257-
258- if match {
259 route := domain.Route{247 route := domain.Route{
260 Image: image,248 Image: image,
261 HTTPS: true,249 HTTPS: true,
@@ -273,6 +261,37 @@ func (s *Service) FindRoutesByImage(_ context.Context, imageName string) []domai
273 return routes261 return routes
274 }262 }
275 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+
276 // 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.
277 func splitImageNameTag(image string) (name string, hasTag bool) {296 func splitImageNameTag(image string) (name string, hasTag bool) {
278 if idx := strings.LastIndex(image, ":"); idx != -1 {297 if idx := strings.LastIndex(image, ":"); idx != -1 {
@@ -593,16 +612,106 @@ func backupConfigFile(configFile string) error {
593 return fmt.Errorf("failed to read config for backup: %w", err)612 return fmt.Errorf("failed to read config for backup: %w", err)
594 }613 }
595 614
596- backupPath := configFile + ".bak"615+ backupPath := fmt.Sprintf("%s.bak.%d", configFile, time.Now().UnixNano())
597 if err := os.WriteFile(backupPath, src, 0600); err != nil {616 if err := os.WriteFile(backupPath, src, 0600); err != nil {
598 return fmt.Errorf("failed to write backup config: %w", err)617 return fmt.Errorf("failed to write backup config: %w", err)
599 }618 }
619+ if err := cleanupOldBackups(configFile, 5); err != nil {
620+ return fmt.Errorf("failed to clean up old backups: %w", err)
621+ }
600 622
601 return nil623 return nil
602 }624 }
603 625
626+// cleanupOldBackups removes old backup files, keeping only the most recent `keep` backups.
627+// It only removes files matching the pattern `<configFile>.bak.<numeric-timestamp>`.
628+func cleanupOldBackups(configFile string, keep int) error {
629+ dir := filepath.Dir(configFile)
630+ base := filepath.Base(configFile)
631+ entries, err := os.ReadDir(dir)
632+ if err != nil {
633+ return fmt.Errorf("failed to read backup directory: %w", err)
634+ }
635+
636+ prefix := base + ".bak."
637+ type tsBackup struct {
638+ ts int64
639+ name string
640+ }
641+ var backups []tsBackup
642+ for _, entry := range entries {
643+ if entry.IsDir() {
644+ continue
645+ }
646+ name := entry.Name()
647+ if !strings.HasPrefix(name, prefix) {
648+ continue
649+ }
650+ suffix := strings.TrimPrefix(name, prefix)
651+ ts, err := strconv.ParseInt(suffix, 10, 64)
652+ if err != nil {
653+ continue
654+ }
655+ backups = append(backups, tsBackup{ts: ts, name: name})
656+ }
657+
658+ slices.SortFunc(backups, func(a, b tsBackup) int {
659+ return cmp.Compare(b.ts, a.ts)
660+ })
661+ if len(backups) <= keep {
662+ return nil
663+ }
664+
665+ var firstErr error
666+ for _, backup := range backups[keep:] {
667+ backupPath := filepath.Join(dir, backup.name)
668+ if err := os.Remove(backupPath); err != nil && firstErr == nil {
669+ firstErr = fmt.Errorf("failed to remove old backup %s: %w", backupPath, err)
670+ }
671+ }
672+
673+ return firstErr
674+}
675+
604 func restoreConfigBackup(configFile string) error {676 func restoreConfigBackup(configFile string) error {
677+ dir := filepath.Dir(configFile)
678+ base := filepath.Base(configFile)
679+ entries, err := os.ReadDir(dir)
680+ if err != nil {
681+ return fmt.Errorf("failed to read backup directory: %w", err)
682+ }
683+
684+ prefix := base + ".bak."
685+ type tsBackup struct {
686+ ts int64
687+ name string
688+ }
689+ var backups []tsBackup
690+ for _, entry := range entries {
691+ if entry.IsDir() {
692+ continue
693+ }
694+ name := entry.Name()
695+ if !strings.HasPrefix(name, prefix) {
696+ continue
697+ }
698+ suffix := strings.TrimPrefix(name, prefix)
699+ ts, err := strconv.ParseInt(suffix, 10, 64)
700+ if err != nil {
701+ continue
702+ }
703+ backups = append(backups, tsBackup{ts: ts, name: name})
704+ }
705+
706+ slices.SortFunc(backups, func(a, b tsBackup) int {
707+ return cmp.Compare(b.ts, a.ts)
708+ })
709+
605 backupPath := configFile + ".bak"710 backupPath := configFile + ".bak"
711+ if len(backups) > 0 {
712+ backupPath = filepath.Join(dir, backups[0].name)
713+ }
714+
606 src, err := os.ReadFile(backupPath)715 src, err := os.ReadFile(backupPath)
607 if err != nil {716 if err != nil {
608 return fmt.Errorf("failed to read backup: %w", err)717 return fmt.Errorf("failed to read backup: %w", err)
M internal/usecase/config/service_test.go +127 -4
@@ -2,6 +2,7 @@ package config
22
33 import (
44 "context"
5+ "fmt"
56 "os"
67 "path/filepath"
78 "testing"
@@ -1154,6 +1155,86 @@ func TestService_FindRoutesByImage(t *testing.T) {
11541155 })
11551156 }
11561157
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+
11571238 func TestSavePreservesAllConfigFields(t *testing.T) {
11581239 t.Run("with defaults does not corrupt config", func(t *testing.T) {
11591240 tmpDir := t.TempDir()
@@ -1248,8 +1329,9 @@ backend = ["app.example.com"]
12481329 assert.Equal(t, "newapp:latest", savedRoutes["new.example.com"])
12491330 assert.Equal(t, "reg.example.com/myapp:latest", savedRoutes["app.example.com"])
12501331
1251- _, err = os.Stat(configFile + ".bak")
1252- assert.NoError(t, err)
1332+ matches, err := filepath.Glob(configFile + ".bak.*")
1333+ require.NoError(t, err)
1334+ assert.NotEmpty(t, matches)
12531335 })
12541336
12551337 t.Run("backup created before write", func(t *testing.T) {
@@ -1279,12 +1361,54 @@ port = 9999
12791361 err = svc.AddRoute(ctx, domain.Route{Domain: "new.example.com", Image: "newapp:latest"})
12801362 require.NoError(t, err)
12811363
1282- backupData, err := os.ReadFile(configFile + ".bak")
1364+ matches, err := filepath.Glob(configFile + ".bak.*")
1365+ require.NoError(t, err)
1366+ require.NotEmpty(t, matches)
1367+
1368+ backupData, err := os.ReadFile(matches[0])
12831369 require.NoError(t, err)
12841370 assert.Contains(t, string(backupData), "port = 9999")
12851371 assert.NotContains(t, string(backupData), "new.example.com")
12861372 })
12871373
1374+ t.Run("old backups are cleaned up keeping 5", func(t *testing.T) {
1375+ tmpDir := t.TempDir()
1376+ configFile := filepath.Join(tmpDir, "gordon.toml")
1377+ initialConfig := `
1378+[server]
1379+port = 9999
1380+
1381+[routes]
1382+"app.example.com" = "myapp:latest"
1383+`
1384+ err := os.WriteFile(configFile, []byte(initialConfig), 0600)
1385+ require.NoError(t, err)
1386+
1387+ for i := 1000000000000; i < 1000000000007; i++ {
1388+ backupPath := fmt.Sprintf("%s.bak.%d", configFile, i)
1389+ err := os.WriteFile(backupPath, []byte("old backup"), 0600)
1390+ require.NoError(t, err)
1391+ }
1392+
1393+ v := viper.New()
1394+ v.SetConfigFile(configFile)
1395+ err = v.ReadInConfig()
1396+ require.NoError(t, err)
1397+
1398+ eventBus := mocks.NewMockEventPublisher(t)
1399+ svc := NewService(v, eventBus)
1400+ ctx := testContext()
1401+ err = svc.Load(ctx)
1402+ require.NoError(t, err)
1403+
1404+ err = svc.AddRoute(ctx, domain.Route{Domain: "new.example.com", Image: "newapp:latest"})
1405+ require.NoError(t, err)
1406+
1407+ matches, err := filepath.Glob(configFile + ".bak.*")
1408+ require.NoError(t, err)
1409+ assert.Equal(t, 5, len(matches))
1410+ })
1411+
12881412 t.Run("AddRoute updates existing dotted domain route", func(t *testing.T) {
12891413 tmpDir := t.TempDir()
12901414 configFile := filepath.Join(tmpDir, "gordon.toml")
@@ -1378,7 +1502,6 @@ token_secret = "testsecret123"
13781502 assert.Equal(t, "testadmin", v2.GetString("auth.username"))
13791503 assert.Equal(t, "testsecret123", v2.GetString("auth.token_secret"))
13801504 })
1381-
13821505 t.Run("network_groups are persisted to disk", func(t *testing.T) {
13831506 // Setup: config file with auth, server, and network_groups
13841507 tmpDir := t.TempDir()
@@ -2,6 +2,7 @@ package config
2 2
3 import (3 import (
4 "context"4 "context"
5+ "fmt"
5 "os"6 "os"
6 "path/filepath"7 "path/filepath"
7 "testing"8 "testing"
@@ -1154,6 +1155,86 @@ func TestService_FindRoutesByImage(t *testing.T) {
1154 })1155 })
1155 }1156 }
1156 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+
1157 func TestSavePreservesAllConfigFields(t *testing.T) {1238 func TestSavePreservesAllConfigFields(t *testing.T) {
1158 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) {
1159 tmpDir := t.TempDir()1240 tmpDir := t.TempDir()
@@ -1248,8 +1329,9 @@ backend = ["app.example.com"]
1248 assert.Equal(t, "newapp:latest", savedRoutes["new.example.com"])1329 assert.Equal(t, "newapp:latest", savedRoutes["new.example.com"])
1249 assert.Equal(t, "reg.example.com/myapp:latest", savedRoutes["app.example.com"])1330 assert.Equal(t, "reg.example.com/myapp:latest", savedRoutes["app.example.com"])
1250 1331
1251- _, err = os.Stat(configFile + ".bak")1332+ matches, err := filepath.Glob(configFile + ".bak.*")
1252- assert.NoError(t, err)1333+ require.NoError(t, err)
1334+ assert.NotEmpty(t, matches)
1253 })1335 })
1254 1336
1255 t.Run("backup created before write", func(t *testing.T) {1337 t.Run("backup created before write", func(t *testing.T) {
@@ -1279,12 +1361,54 @@ port = 9999
1279 err = svc.AddRoute(ctx, domain.Route{Domain: "new.example.com", Image: "newapp:latest"})1361 err = svc.AddRoute(ctx, domain.Route{Domain: "new.example.com", Image: "newapp:latest"})
1280 require.NoError(t, err)1362 require.NoError(t, err)
1281 1363
1282- backupData, err := os.ReadFile(configFile + ".bak")1364+ matches, err := filepath.Glob(configFile + ".bak.*")
1365+ require.NoError(t, err)
1366+ require.NotEmpty(t, matches)
1367+
1368+ backupData, err := os.ReadFile(matches[0])
1283 require.NoError(t, err)1369 require.NoError(t, err)
1284 assert.Contains(t, string(backupData), "port = 9999")1370 assert.Contains(t, string(backupData), "port = 9999")
1285 assert.NotContains(t, string(backupData), "new.example.com")1371 assert.NotContains(t, string(backupData), "new.example.com")
1286 })1372 })
1287 1373
1374+ t.Run("old backups are cleaned up keeping 5", func(t *testing.T) {
1375+ tmpDir := t.TempDir()
1376+ configFile := filepath.Join(tmpDir, "gordon.toml")
1377+ initialConfig := `
1378+[server]
1379+port = 9999
1380+
1381+[routes]
1382+"app.example.com" = "myapp:latest"
1383+`
1384+ err := os.WriteFile(configFile, []byte(initialConfig), 0600)
1385+ require.NoError(t, err)
1386+
1387+ for i := 1000000000000; i < 1000000000007; i++ {
1388+ backupPath := fmt.Sprintf("%s.bak.%d", configFile, i)
1389+ err := os.WriteFile(backupPath, []byte("old backup"), 0600)
1390+ require.NoError(t, err)
1391+ }
1392+
1393+ v := viper.New()
1394+ v.SetConfigFile(configFile)
1395+ err = v.ReadInConfig()
1396+ require.NoError(t, err)
1397+
1398+ eventBus := mocks.NewMockEventPublisher(t)
1399+ svc := NewService(v, eventBus)
1400+ ctx := testContext()
1401+ err = svc.Load(ctx)
1402+ require.NoError(t, err)
1403+
1404+ err = svc.AddRoute(ctx, domain.Route{Domain: "new.example.com", Image: "newapp:latest"})
1405+ require.NoError(t, err)
1406+
1407+ matches, err := filepath.Glob(configFile + ".bak.*")
1408+ require.NoError(t, err)
1409+ assert.Equal(t, 5, len(matches))
1410+ })
1411+
1288 t.Run("AddRoute updates existing dotted domain route", func(t *testing.T) {1412 t.Run("AddRoute updates existing dotted domain route", func(t *testing.T) {
1289 tmpDir := t.TempDir()1413 tmpDir := t.TempDir()
1290 configFile := filepath.Join(tmpDir, "gordon.toml")1414 configFile := filepath.Join(tmpDir, "gordon.toml")
@@ -1378,7 +1502,6 @@ token_secret = "testsecret123"
1378 assert.Equal(t, "testadmin", v2.GetString("auth.username"))1502 assert.Equal(t, "testadmin", v2.GetString("auth.username"))
1379 assert.Equal(t, "testsecret123", v2.GetString("auth.token_secret"))1503 assert.Equal(t, "testsecret123", v2.GetString("auth.token_secret"))
1380 })1504 })
1381-
1382 t.Run("network_groups are persisted to disk", func(t *testing.T) {1505 t.Run("network_groups are persisted to disk", func(t *testing.T) {
1383 // Setup: config file with auth, server, and network_groups1506 // Setup: config file with auth, server, and network_groups
1384 tmpDir := t.TempDir()1507 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