detached c65c4e4

Merge pull request #70 from bnema/refactor/push-command-remove-domain-arg

refactor(cli): remove domain arg from push command
commit
c65c4e466fa680873f3aef534db6c3914e3a2ec1
author
bnema · · 2026-02-09 20:51 UTC
committer
GitHub · · 2026-02-09 20:51 UTC
merge of
aa8d1ec + d87f8ad · diff against the first parent
signature
unsigned
14 files · +906 -73
M internal/adapters/dto/admin_route.go +6 -0
@@ -42,6 +42,12 @@ type AttachmentsResponse struct {
4242 Attachments []Attachment `json:"attachments"`
4343 }
4444
45+// RoutesByImageResponse represents routes found for a given image.
46+type RoutesByImageResponse struct {
47+ Image string `json:"image"`
48+ Routes []Route `json:"routes"`
49+}
50+
4551 // RouteDeleteResponse represents a route removal response.
4652 type RouteDeleteResponse struct {
4753 Status string `json:"status"`
@@ -42,6 +42,12 @@ type AttachmentsResponse struct {
42 Attachments []Attachment `json:"attachments"`42 Attachments []Attachment `json:"attachments"`
43 }43 }
44 44
45+// RoutesByImageResponse represents routes found for a given image.
46+type RoutesByImageResponse struct {
47+ Image string `json:"image"`
48+ Routes []Route `json:"routes"`
49+}
50+
45 // RouteDeleteResponse represents a route removal response.51 // RouteDeleteResponse represents a route removal response.
46 type RouteDeleteResponse struct {52 type RouteDeleteResponse struct {
47 Status string `json:"status"`53 Status string `json:"status"`
M internal/adapters/in/cli/controlplane.go +1 -0
@@ -16,6 +16,7 @@ type ControlPlane interface {
1616 ListRoutesWithDetails(ctx context.Context) ([]remote.RouteInfo, error)
1717 GetHealth(ctx context.Context) (map[string]*remote.RouteHealth, error)
1818 GetRoute(ctx context.Context, routeDomain string) (*domain.Route, error)
19+ FindRoutesByImage(ctx context.Context, imageName string) ([]domain.Route, error)
1920 AddRoute(ctx context.Context, route domain.Route) error
2021 UpdateRoute(ctx context.Context, route domain.Route) error
2122 RemoveRoute(ctx context.Context, routeDomain string) error
@@ -16,6 +16,7 @@ type ControlPlane interface {
16 ListRoutesWithDetails(ctx context.Context) ([]remote.RouteInfo, error)16 ListRoutesWithDetails(ctx context.Context) ([]remote.RouteInfo, error)
17 GetHealth(ctx context.Context) (map[string]*remote.RouteHealth, error)17 GetHealth(ctx context.Context) (map[string]*remote.RouteHealth, error)
18 GetRoute(ctx context.Context, routeDomain string) (*domain.Route, error)18 GetRoute(ctx context.Context, routeDomain string) (*domain.Route, error)
19+ FindRoutesByImage(ctx context.Context, imageName string) ([]domain.Route, error)
19 AddRoute(ctx context.Context, route domain.Route) error20 AddRoute(ctx context.Context, route domain.Route) error
20 UpdateRoute(ctx context.Context, route domain.Route) error21 UpdateRoute(ctx context.Context, route domain.Route) error
21 RemoveRoute(ctx context.Context, routeDomain string) error22 RemoveRoute(ctx context.Context, routeDomain string) error
M internal/adapters/in/cli/controlplane_local.go +7 -0
@@ -84,6 +84,13 @@ func (l *localControlPlane) GetRoute(ctx context.Context, routeDomain string) (*
8484 return l.configSvc.GetRoute(ctx, routeDomain)
8585 }
8686
87+func (l *localControlPlane) FindRoutesByImage(ctx context.Context, imageName string) ([]domain.Route, error) {
88+ if l.configSvc == nil {
89+ return nil, fmt.Errorf("local config service unavailable")
90+ }
91+ return l.configSvc.FindRoutesByImage(ctx, imageName), nil
92+}
93+
8794 func (l *localControlPlane) AddRoute(ctx context.Context, route domain.Route) error {
8895 if l.configSvc == nil {
8996 return fmt.Errorf("local config service unavailable")
@@ -84,6 +84,13 @@ func (l *localControlPlane) GetRoute(ctx context.Context, routeDomain string) (*
84 return l.configSvc.GetRoute(ctx, routeDomain)84 return l.configSvc.GetRoute(ctx, routeDomain)
85 }85 }
86 86
87+func (l *localControlPlane) FindRoutesByImage(ctx context.Context, imageName string) ([]domain.Route, error) {
88+ if l.configSvc == nil {
89+ return nil, fmt.Errorf("local config service unavailable")
90+ }
91+ return l.configSvc.FindRoutesByImage(ctx, imageName), nil
92+}
93+
87 func (l *localControlPlane) AddRoute(ctx context.Context, route domain.Route) error {94 func (l *localControlPlane) AddRoute(ctx context.Context, route domain.Route) error {
88 if l.configSvc == nil {95 if l.configSvc == nil {
89 return fmt.Errorf("local config service unavailable")96 return fmt.Errorf("local config service unavailable")
M internal/adapters/in/cli/controlplane_remote.go +4 -0
@@ -28,6 +28,10 @@ func (r *remoteControlPlane) GetRoute(ctx context.Context, routeDomain string) (
2828 return r.client.GetRoute(ctx, routeDomain)
2929 }
3030
31+func (r *remoteControlPlane) FindRoutesByImage(ctx context.Context, imageName string) ([]domain.Route, error) {
32+ return r.client.FindRoutesByImage(ctx, imageName)
33+}
34+
3135 func (r *remoteControlPlane) AddRoute(ctx context.Context, route domain.Route) error {
3236 return r.client.AddRoute(ctx, route)
3337 }
@@ -28,6 +28,10 @@ func (r *remoteControlPlane) GetRoute(ctx context.Context, routeDomain string) (
28 return r.client.GetRoute(ctx, routeDomain)28 return r.client.GetRoute(ctx, routeDomain)
29 }29 }
30 30
31+func (r *remoteControlPlane) FindRoutesByImage(ctx context.Context, imageName string) ([]domain.Route, error) {
32+ return r.client.FindRoutesByImage(ctx, imageName)
33+}
34+
31 func (r *remoteControlPlane) AddRoute(ctx context.Context, route domain.Route) error {35 func (r *remoteControlPlane) AddRoute(ctx context.Context, route domain.Route) error {
32 return r.client.AddRoute(ctx, route)36 return r.client.AddRoute(ctx, route)
33 }37 }
M internal/adapters/in/cli/push.go +288 -22
@@ -1,10 +1,12 @@
11 package cli
22
33 import (
4+ "bufio"
45 "context"
56 "fmt"
67 "os"
78 "os/exec"
9+ "path/filepath"
810 "regexp"
911 "strings"
1012
@@ -14,6 +16,7 @@ import (
1416 "github.com/bnema/gordon/internal/adapters/in/cli/remote"
1517 "github.com/bnema/gordon/internal/adapters/in/cli/ui/components"
1618 "github.com/bnema/gordon/internal/adapters/in/cli/ui/styles"
19+ "github.com/bnema/gordon/internal/domain"
1720 "github.com/bnema/gordon/pkg/validation"
1821 )
1922
@@ -26,24 +29,35 @@ func newPushCmd() *cobra.Command {
2629 tag string
2730 dockerfile string
2831 buildArgs []string
32+ domainFlag string
2933 )
3034
3135 cmd := &cobra.Command{
32- Use: "push <domain>",
36+ Use: "push [image]",
3337 Short: "Tag, push, and optionally deploy an image",
3438 Long: `Tags a local image for the Gordon registry and pushes it.
3539 Uses git tags for versioning. Optionally triggers deployment after push.
3640
41+The image argument is optional. Resolution order:
42+ 1. If --domain is provided, uses the legacy domain-based lookup
43+ 2. If an image name is provided as argument, resolves domain(s) from the backend
44+ 3. If no argument, auto-detects from Dockerfile labels or current directory name
45+
3746 With --build, builds the image first using docker buildx.
3847
3948 Examples:
40- gordon push myapp.example.com --remote ...
41- gordon push myapp.example.com --build --remote ...
42- gordon push myapp.example.com --tag v1.2.0 --no-deploy --remote ...
43- gordon push myapp.example.com --build --build-arg CGO_ENABLED=0 --remote ...`,
44- Args: cobra.ExactArgs(1),
49+ gordon push --build --remote ...
50+ gordon push myapp --build --remote ...
51+ gordon push --domain myapp.example.com --build --remote ...
52+ gordon push myapp --tag v1.2.0 --no-deploy --remote ...
53+ gordon push --build --build-arg CGO_ENABLED=0 --remote ...`,
54+ Args: cobra.MaximumNArgs(1),
4555 RunE: func(cmd *cobra.Command, args []string) error {
46- return runPush(cmd.Context(), args[0], tag, build, platform, dockerfile, buildArgs, noDeploy, noConfirm)
56+ var imageArg string
57+ if len(args) > 0 {
58+ imageArg = args[0]
59+ }
60+ return runPush(cmd.Context(), imageArg, domainFlag, tag, build, platform, dockerfile, buildArgs, noDeploy, noConfirm)
4761 },
4862 }
4963
@@ -54,6 +68,7 @@ Examples:
5468 cmd.Flags().StringVarP(&dockerfile, "file", "f", "", "Path to Dockerfile (default: ./Dockerfile, used with --build)")
5569 cmd.Flags().StringVar(&tag, "tag", "", "Override version tag (default: git describe)")
5670 cmd.Flags().StringArrayVar(&buildArgs, "build-arg", nil, "Additional build args (used with --build)")
71+ cmd.Flags().StringVar(&domainFlag, "domain", "", "Explicit domain override (legacy mode)")
5772
5873 return cmd
5974 }
@@ -74,7 +89,35 @@ func resolveImageRefs(registry, imageName, version string) (versionRef, latestRe
7489 return versionRef, latestRef
7590 }
7691
77-func runPush(ctx context.Context, pushDomain, tag string, build bool, platform string, dockerfile string, buildArgs []string, noDeploy bool, noConfirm bool) error {
92+// resolveRoute determines the registry, image name, and domain from the input mode.
93+func resolveRoute(ctx context.Context, cp ControlPlane, imageArg, domainFlag, dockerfile string) (registry, imageName, pushDomain string, err error) {
94+ if domainFlag != "" {
95+ return resolveFromDomain(ctx, cp, domainFlag)
96+ }
97+
98+ if imageArg == "" {
99+ imageArg, err = detectImageName(dockerfile)
100+ if err != nil {
101+ return "", "", "", err
102+ }
103+ fmt.Printf("Detected image: %s\n", styles.Theme.Bold.Render(imageArg))
104+ }
105+
106+ return resolveFromImage(ctx, cp, imageArg, dockerfile)
107+}
108+
109+// resolveVersion determines and validates the version tag.
110+func resolveVersion(ctx context.Context, tag string) (string, error) {
111+ version := determineVersion(ctx, tag)
112+ if version != "latest" {
113+ if err := validation.ValidateReference(version); err != nil {
114+ return "", fmt.Errorf("invalid version tag %q: %w", version, err)
115+ }
116+ }
117+ return version, nil
118+}
119+
120+func runPush(ctx context.Context, imageArg, domainFlag, tag string, build bool, platform string, dockerfile string, buildArgs []string, noDeploy bool, noConfirm bool) error {
78121 handle, err := resolveControlPlane(configPath)
79122 if err != nil {
80123 return err
@@ -86,21 +129,14 @@ func runPush(ctx context.Context, pushDomain, tag string, build bool, platform s
86129 return err
87130 }
88131
89- route, err := handle.plane.GetRoute(ctx, pushDomain)
132+ registry, imageName, pushDomain, err := resolveRoute(ctx, handle.plane, imageArg, domainFlag, dockerfile)
90133 if err != nil {
91- return fmt.Errorf("failed to get route: %w", err)
92- }
93-
94- registry, imageName, _ := parseImageRef(route.Image)
95- if registry == "" || imageName == "" {
96- return fmt.Errorf("cannot parse registry/image from route image: %s", route.Image)
134+ return err
97135 }
98136
99- version := determineVersion(ctx, tag)
100- if version != "latest" {
101- if err := validation.ValidateReference(version); err != nil {
102- return fmt.Errorf("invalid version tag %q: %w", version, err)
103- }
137+ version, err := resolveVersion(ctx, tag)
138+ if err != nil {
139+ return err
104140 }
105141
106142 for _, ba := range buildArgs {
@@ -111,10 +147,11 @@ func runPush(ctx context.Context, pushDomain, tag string, build bool, platform s
111147
112148 versionRef, latestRef := resolveImageRefs(registry, imageName, version)
113149
114- fmt.Printf("Image: %s\n", styles.Theme.Bold.Render(versionRef))
150+ fmt.Printf("Image: %s\n", styles.Theme.Bold.Render(versionRef))
115151 if version != "latest" {
116- fmt.Printf("Also: %s\n", styles.Theme.Bold.Render(latestRef))
152+ fmt.Printf("Also: %s\n", styles.Theme.Bold.Render(latestRef))
117153 }
154+ fmt.Printf("Domain: %s\n", styles.Theme.Bold.Render(pushDomain))
118155
119156 if build {
120157 if err := buildAndPush(ctx, version, platform, dockerfile, buildArgs, versionRef, latestRef); err != nil {
@@ -135,6 +172,235 @@ func runPush(ctx context.Context, pushDomain, tag string, build bool, platform s
135172 return nil
136173 }
137174
175+// resolveFromDomain resolves image info from a domain name (legacy mode).
176+func resolveFromDomain(ctx context.Context, cp ControlPlane, pushDomain string) (registry, imageName, resolvedDomain string, err error) {
177+ route, err := cp.GetRoute(ctx, pushDomain)
178+ if err != nil {
179+ return "", "", "", fmt.Errorf("failed to get route for domain %q: %w", pushDomain, err)
180+ }
181+
182+ registry, imageName, _ = parseImageRef(route.Image)
183+ if registry == "" || imageName == "" {
184+ return "", "", "", fmt.Errorf("cannot parse registry/image from route image: %s", route.Image)
185+ }
186+
187+ return registry, imageName, pushDomain, nil
188+}
189+
190+// resolveFromImage resolves domain(s) from an image name using the backend.
191+func resolveFromImage(ctx context.Context, cp ControlPlane, imageArg, dockerfile string) (registry, imageName, resolvedDomain string, err error) {
192+ // First, query the backend to find routes for this image
193+ routes, err := cp.FindRoutesByImage(ctx, imageArg)
194+ if err != nil {
195+ return "", "", "", fmt.Errorf("failed to find routes for image %q: %w", imageArg, err)
196+ }
197+
198+ if len(routes) == 0 {
199+ return "", "", "", fmt.Errorf(
200+ "no route configured for image %q\n\nUse 'gordon routes add' to create a route first",
201+ imageArg,
202+ )
203+ }
204+
205+ // Pick the target domain
206+ var targetRoute domain.Route
207+ if len(routes) == 1 {
208+ targetRoute = routes[0]
209+ } else {
210+ // Multiple domains: check Dockerfile labels first, then prompt
211+ targetRoute, err = selectDomain(routes, dockerfile)
212+ if err != nil {
213+ return "", "", "", err
214+ }
215+ }
216+
217+ registry, imageName, _ = parseImageRef(targetRoute.Image)
218+ if registry == "" || imageName == "" {
219+ return "", "", "", fmt.Errorf("cannot parse registry/image from route image: %s", targetRoute.Image)
220+ }
221+
222+ return registry, imageName, targetRoute.Domain, nil
223+}
224+
225+// selectDomain picks the target domain from multiple routes.
226+// Checks Dockerfile labels first, then falls back to interactive selection.
227+func selectDomain(routes []domain.Route, dockerfile string) (domain.Route, error) {
228+ // Try to resolve from Dockerfile labels
229+ labels := parseDockerfileLabels(dockerfile)
230+ labelDomain := labels[domain.LabelDomain]
231+ labelDomains := labels[domain.LabelDomains]
232+
233+ // Collect domains from labels
234+ var labelDomainList []string
235+ if labelDomain != "" {
236+ labelDomainList = append(labelDomainList, labelDomain)
237+ }
238+ if labelDomains != "" {
239+ for _, d := range strings.Split(labelDomains, ",") {
240+ d = strings.TrimSpace(d)
241+ if d != "" {
242+ labelDomainList = append(labelDomainList, d)
243+ }
244+ }
245+ }
246+
247+ // Try to find a matching route from labels
248+ if len(labelDomainList) > 0 {
249+ routeMap := make(map[string]domain.Route, len(routes))
250+ for _, r := range routes {
251+ routeMap[r.Domain] = r
252+ }
253+ for _, ld := range labelDomainList {
254+ if r, ok := routeMap[ld]; ok {
255+ return r, nil
256+ }
257+ }
258+ }
259+
260+ // Fall back to interactive selection
261+ items := make([]string, 0, len(routes))
262+ for _, r := range routes {
263+ items = append(items, fmt.Sprintf("%s %s", r.Domain, styles.Theme.Muted.Render(r.Image)))
264+ }
265+
266+ selected, err := components.RunSelector(
267+ "Multiple domains found for this image. Select target:",
268+ items,
269+ "",
270+ )
271+ if err != nil {
272+ return domain.Route{}, fmt.Errorf("selection error: %w", err)
273+ }
274+ if selected == "" {
275+ return domain.Route{}, fmt.Errorf("no domain selected")
276+ }
277+
278+ // Extract domain from the selected display string
279+ for i, item := range items {
280+ if item == selected {
281+ return routes[i], nil
282+ }
283+ }
284+
285+ return domain.Route{}, fmt.Errorf("selected domain not found")
286+}
287+
288+// detectImageName auto-detects the image name from context.
289+// Resolution order:
290+// 1. Dockerfile label gordon.domain (if Dockerfile exists)
291+// 2. Current directory name
292+func detectImageName(dockerfile string) (string, error) {
293+ // Try Dockerfile labels
294+ labels := parseDockerfileLabels(dockerfile)
295+ if d := labels[domain.LabelDomain]; d != "" {
296+ // Strip the registry prefix if it looks like a full image ref
297+ _, name, _ := parseImageRef(d)
298+ if name != "" {
299+ return name, nil
300+ }
301+ return d, nil
302+ }
303+
304+ // Fall back to current directory name
305+ cwd, err := os.Getwd()
306+ if err != nil {
307+ return "", fmt.Errorf("cannot detect image name: %w", err)
308+ }
309+
310+ dirName := filepath.Base(cwd)
311+ if dirName == "." || dirName == "/" {
312+ return "", fmt.Errorf("cannot detect image name from current directory; provide an image name or use --domain")
313+ }
314+
315+ return dirName, nil
316+}
317+
318+// parseDockerfileLabels extracts LABEL instructions from a Dockerfile.
319+// Returns a map of label key -> value. Only parses gordon.* labels.
320+func parseDockerfileLabels(dockerfile string) map[string]string {
321+ labels := make(map[string]string)
322+
323+ f, err := os.Open(dockerfile)
324+ if err != nil {
325+ return labels
326+ }
327+ defer f.Close()
328+
329+ scanner := bufio.NewScanner(f)
330+ for scanner.Scan() {
331+ line := strings.TrimSpace(scanner.Text())
332+
333+ // Skip comments and empty lines
334+ if line == "" || strings.HasPrefix(line, "#") {
335+ continue
336+ }
337+
338+ // Match LABEL instructions
339+ if !strings.HasPrefix(strings.ToUpper(line), "LABEL ") {
340+ continue
341+ }
342+
343+ // Parse "LABEL key=value" or "LABEL key=\"value\""
344+ labelContent := strings.TrimSpace(line[6:])
345+ for _, pair := range splitLabelPairs(labelContent) {
346+ key, value, ok := parseLabelPair(pair)
347+ if ok && strings.HasPrefix(key, "gordon.") {
348+ labels[key] = value
349+ }
350+ }
351+ }
352+
353+ return labels
354+}
355+
356+// splitLabelPairs splits LABEL content into key=value pairs,
357+// handling quoted values that may contain spaces.
358+func splitLabelPairs(content string) []string {
359+ var pairs []string
360+ var current strings.Builder
361+ inQuote := false
362+ quoteChar := byte(0)
363+
364+ for i := 0; i < len(content); i++ {
365+ ch := content[i]
366+ if inQuote {
367+ current.WriteByte(ch)
368+ if ch == quoteChar {
369+ inQuote = false
370+ }
371+ } else if ch == '"' || ch == '\'' {
372+ inQuote = true
373+ quoteChar = ch
374+ current.WriteByte(ch)
375+ } else if ch == ' ' || ch == '\t' {
376+ if current.Len() > 0 {
377+ pairs = append(pairs, current.String())
378+ current.Reset()
379+ }
380+ } else {
381+ current.WriteByte(ch)
382+ }
383+ }
384+ if current.Len() > 0 {
385+ pairs = append(pairs, current.String())
386+ }
387+
388+ return pairs
389+}
390+
391+// parseLabelPair parses a single "key=value" or "key=\"value\"" pair.
392+func parseLabelPair(pair string) (key, value string, ok bool) {
393+ idx := strings.Index(pair, "=")
394+ if idx == -1 {
395+ return "", "", false
396+ }
397+ key = pair[:idx]
398+ value = pair[idx+1:]
399+ // Strip surrounding quotes
400+ value = strings.Trim(value, "\"'")
401+ return key, value, true
402+}
403+
138404 func determineVersion(ctx context.Context, tag string) string {
139405 version := tag
140406 if version == "" {
@@ -1,10 +1,12 @@
1 package cli1 package cli
2 2
3 import (3 import (
4+ "bufio"
4 "context"5 "context"
5 "fmt"6 "fmt"
6 "os"7 "os"
7 "os/exec"8 "os/exec"
9+ "path/filepath"
8 "regexp"10 "regexp"
9 "strings"11 "strings"
10 12
@@ -14,6 +16,7 @@ import (
14 "github.com/bnema/gordon/internal/adapters/in/cli/remote"16 "github.com/bnema/gordon/internal/adapters/in/cli/remote"
15 "github.com/bnema/gordon/internal/adapters/in/cli/ui/components"17 "github.com/bnema/gordon/internal/adapters/in/cli/ui/components"
16 "github.com/bnema/gordon/internal/adapters/in/cli/ui/styles"18 "github.com/bnema/gordon/internal/adapters/in/cli/ui/styles"
19+ "github.com/bnema/gordon/internal/domain"
17 "github.com/bnema/gordon/pkg/validation"20 "github.com/bnema/gordon/pkg/validation"
18 )21 )
19 22
@@ -26,24 +29,35 @@ func newPushCmd() *cobra.Command {
26 tag string29 tag string
27 dockerfile string30 dockerfile string
28 buildArgs []string31 buildArgs []string
32+ domainFlag string
29 )33 )
30 34
31 cmd := &cobra.Command{35 cmd := &cobra.Command{
32- Use: "push <domain>",36+ Use: "push [image]",
33 Short: "Tag, push, and optionally deploy an image",37 Short: "Tag, push, and optionally deploy an image",
34 Long: `Tags a local image for the Gordon registry and pushes it.38 Long: `Tags a local image for the Gordon registry and pushes it.
35 Uses git tags for versioning. Optionally triggers deployment after push.39 Uses git tags for versioning. Optionally triggers deployment after push.
36 40
41+The image argument is optional. Resolution order:
42+ 1. If --domain is provided, uses the legacy domain-based lookup
43+ 2. If an image name is provided as argument, resolves domain(s) from the backend
44+ 3. If no argument, auto-detects from Dockerfile labels or current directory name
45+
37 With --build, builds the image first using docker buildx.46 With --build, builds the image first using docker buildx.
38 47
39 Examples:48 Examples:
40- gordon push myapp.example.com --remote ...49+ gordon push --build --remote ...
41- gordon push myapp.example.com --build --remote ...50+ gordon push myapp --build --remote ...
42- gordon push myapp.example.com --tag v1.2.0 --no-deploy --remote ...51+ gordon push --domain myapp.example.com --build --remote ...
43- gordon push myapp.example.com --build --build-arg CGO_ENABLED=0 --remote ...`,52+ gordon push myapp --tag v1.2.0 --no-deploy --remote ...
44- Args: cobra.ExactArgs(1),53+ gordon push --build --build-arg CGO_ENABLED=0 --remote ...`,
54+ Args: cobra.MaximumNArgs(1),
45 RunE: func(cmd *cobra.Command, args []string) error {55 RunE: func(cmd *cobra.Command, args []string) error {
46- return runPush(cmd.Context(), args[0], tag, build, platform, dockerfile, buildArgs, noDeploy, noConfirm)56+ var imageArg string
57+ if len(args) > 0 {
58+ imageArg = args[0]
59+ }
60+ return runPush(cmd.Context(), imageArg, domainFlag, tag, build, platform, dockerfile, buildArgs, noDeploy, noConfirm)
47 },61 },
48 }62 }
49 63
@@ -54,6 +68,7 @@ Examples:
54 cmd.Flags().StringVarP(&dockerfile, "file", "f", "", "Path to Dockerfile (default: ./Dockerfile, used with --build)")68 cmd.Flags().StringVarP(&dockerfile, "file", "f", "", "Path to Dockerfile (default: ./Dockerfile, used with --build)")
55 cmd.Flags().StringVar(&tag, "tag", "", "Override version tag (default: git describe)")69 cmd.Flags().StringVar(&tag, "tag", "", "Override version tag (default: git describe)")
56 cmd.Flags().StringArrayVar(&buildArgs, "build-arg", nil, "Additional build args (used with --build)")70 cmd.Flags().StringArrayVar(&buildArgs, "build-arg", nil, "Additional build args (used with --build)")
71+ cmd.Flags().StringVar(&domainFlag, "domain", "", "Explicit domain override (legacy mode)")
57 72
58 return cmd73 return cmd
59 }74 }
@@ -74,7 +89,35 @@ func resolveImageRefs(registry, imageName, version string) (versionRef, latestRe
74 return versionRef, latestRef89 return versionRef, latestRef
75 }90 }
76 91
77-func runPush(ctx context.Context, pushDomain, tag string, build bool, platform string, dockerfile string, buildArgs []string, noDeploy bool, noConfirm bool) error {92+// resolveRoute determines the registry, image name, and domain from the input mode.
93+func resolveRoute(ctx context.Context, cp ControlPlane, imageArg, domainFlag, dockerfile string) (registry, imageName, pushDomain string, err error) {
94+ if domainFlag != "" {
95+ return resolveFromDomain(ctx, cp, domainFlag)
96+ }
97+
98+ if imageArg == "" {
99+ imageArg, err = detectImageName(dockerfile)
100+ if err != nil {
101+ return "", "", "", err
102+ }
103+ fmt.Printf("Detected image: %s\n", styles.Theme.Bold.Render(imageArg))
104+ }
105+
106+ return resolveFromImage(ctx, cp, imageArg, dockerfile)
107+}
108+
109+// resolveVersion determines and validates the version tag.
110+func resolveVersion(ctx context.Context, tag string) (string, error) {
111+ version := determineVersion(ctx, tag)
112+ if version != "latest" {
113+ if err := validation.ValidateReference(version); err != nil {
114+ return "", fmt.Errorf("invalid version tag %q: %w", version, err)
115+ }
116+ }
117+ return version, nil
118+}
119+
120+func runPush(ctx context.Context, imageArg, domainFlag, tag string, build bool, platform string, dockerfile string, buildArgs []string, noDeploy bool, noConfirm bool) error {
78 handle, err := resolveControlPlane(configPath)121 handle, err := resolveControlPlane(configPath)
79 if err != nil {122 if err != nil {
80 return err123 return err
@@ -86,21 +129,14 @@ func runPush(ctx context.Context, pushDomain, tag string, build bool, platform s
86 return err129 return err
87 }130 }
88 131
89- route, err := handle.plane.GetRoute(ctx, pushDomain)132+ registry, imageName, pushDomain, err := resolveRoute(ctx, handle.plane, imageArg, domainFlag, dockerfile)
90 if err != nil {133 if err != nil {
91- return fmt.Errorf("failed to get route: %w", err)134+ return err
92- }
93-
94- registry, imageName, _ := parseImageRef(route.Image)
95- if registry == "" || imageName == "" {
96- return fmt.Errorf("cannot parse registry/image from route image: %s", route.Image)
97 }135 }
98 136
99- version := determineVersion(ctx, tag)137+ version, err := resolveVersion(ctx, tag)
100- if version != "latest" {138+ if err != nil {
101- if err := validation.ValidateReference(version); err != nil {139+ return err
102- return fmt.Errorf("invalid version tag %q: %w", version, err)
103- }
104 }140 }
105 141
106 for _, ba := range buildArgs {142 for _, ba := range buildArgs {
@@ -111,10 +147,11 @@ func runPush(ctx context.Context, pushDomain, tag string, build bool, platform s
111 147
112 versionRef, latestRef := resolveImageRefs(registry, imageName, version)148 versionRef, latestRef := resolveImageRefs(registry, imageName, version)
113 149
114- fmt.Printf("Image: %s\n", styles.Theme.Bold.Render(versionRef))150+ fmt.Printf("Image: %s\n", styles.Theme.Bold.Render(versionRef))
115 if version != "latest" {151 if version != "latest" {
116- fmt.Printf("Also: %s\n", styles.Theme.Bold.Render(latestRef))152+ fmt.Printf("Also: %s\n", styles.Theme.Bold.Render(latestRef))
117 }153 }
154+ fmt.Printf("Domain: %s\n", styles.Theme.Bold.Render(pushDomain))
118 155
119 if build {156 if build {
120 if err := buildAndPush(ctx, version, platform, dockerfile, buildArgs, versionRef, latestRef); err != nil {157 if err := buildAndPush(ctx, version, platform, dockerfile, buildArgs, versionRef, latestRef); err != nil {
@@ -135,6 +172,235 @@ func runPush(ctx context.Context, pushDomain, tag string, build bool, platform s
135 return nil172 return nil
136 }173 }
137 174
175+// resolveFromDomain resolves image info from a domain name (legacy mode).
176+func resolveFromDomain(ctx context.Context, cp ControlPlane, pushDomain string) (registry, imageName, resolvedDomain string, err error) {
177+ route, err := cp.GetRoute(ctx, pushDomain)
178+ if err != nil {
179+ return "", "", "", fmt.Errorf("failed to get route for domain %q: %w", pushDomain, err)
180+ }
181+
182+ registry, imageName, _ = parseImageRef(route.Image)
183+ if registry == "" || imageName == "" {
184+ return "", "", "", fmt.Errorf("cannot parse registry/image from route image: %s", route.Image)
185+ }
186+
187+ return registry, imageName, pushDomain, nil
188+}
189+
190+// resolveFromImage resolves domain(s) from an image name using the backend.
191+func resolveFromImage(ctx context.Context, cp ControlPlane, imageArg, dockerfile string) (registry, imageName, resolvedDomain string, err error) {
192+ // First, query the backend to find routes for this image
193+ routes, err := cp.FindRoutesByImage(ctx, imageArg)
194+ if err != nil {
195+ return "", "", "", fmt.Errorf("failed to find routes for image %q: %w", imageArg, err)
196+ }
197+
198+ if len(routes) == 0 {
199+ return "", "", "", fmt.Errorf(
200+ "no route configured for image %q\n\nUse 'gordon routes add' to create a route first",
201+ imageArg,
202+ )
203+ }
204+
205+ // Pick the target domain
206+ var targetRoute domain.Route
207+ if len(routes) == 1 {
208+ targetRoute = routes[0]
209+ } else {
210+ // Multiple domains: check Dockerfile labels first, then prompt
211+ targetRoute, err = selectDomain(routes, dockerfile)
212+ if err != nil {
213+ return "", "", "", err
214+ }
215+ }
216+
217+ registry, imageName, _ = parseImageRef(targetRoute.Image)
218+ if registry == "" || imageName == "" {
219+ return "", "", "", fmt.Errorf("cannot parse registry/image from route image: %s", targetRoute.Image)
220+ }
221+
222+ return registry, imageName, targetRoute.Domain, nil
223+}
224+
225+// selectDomain picks the target domain from multiple routes.
226+// Checks Dockerfile labels first, then falls back to interactive selection.
227+func selectDomain(routes []domain.Route, dockerfile string) (domain.Route, error) {
228+ // Try to resolve from Dockerfile labels
229+ labels := parseDockerfileLabels(dockerfile)
230+ labelDomain := labels[domain.LabelDomain]
231+ labelDomains := labels[domain.LabelDomains]
232+
233+ // Collect domains from labels
234+ var labelDomainList []string
235+ if labelDomain != "" {
236+ labelDomainList = append(labelDomainList, labelDomain)
237+ }
238+ if labelDomains != "" {
239+ for _, d := range strings.Split(labelDomains, ",") {
240+ d = strings.TrimSpace(d)
241+ if d != "" {
242+ labelDomainList = append(labelDomainList, d)
243+ }
244+ }
245+ }
246+
247+ // Try to find a matching route from labels
248+ if len(labelDomainList) > 0 {
249+ routeMap := make(map[string]domain.Route, len(routes))
250+ for _, r := range routes {
251+ routeMap[r.Domain] = r
252+ }
253+ for _, ld := range labelDomainList {
254+ if r, ok := routeMap[ld]; ok {
255+ return r, nil
256+ }
257+ }
258+ }
259+
260+ // Fall back to interactive selection
261+ items := make([]string, 0, len(routes))
262+ for _, r := range routes {
263+ items = append(items, fmt.Sprintf("%s %s", r.Domain, styles.Theme.Muted.Render(r.Image)))
264+ }
265+
266+ selected, err := components.RunSelector(
267+ "Multiple domains found for this image. Select target:",
268+ items,
269+ "",
270+ )
271+ if err != nil {
272+ return domain.Route{}, fmt.Errorf("selection error: %w", err)
273+ }
274+ if selected == "" {
275+ return domain.Route{}, fmt.Errorf("no domain selected")
276+ }
277+
278+ // Extract domain from the selected display string
279+ for i, item := range items {
280+ if item == selected {
281+ return routes[i], nil
282+ }
283+ }
284+
285+ return domain.Route{}, fmt.Errorf("selected domain not found")
286+}
287+
288+// detectImageName auto-detects the image name from context.
289+// Resolution order:
290+// 1. Dockerfile label gordon.domain (if Dockerfile exists)
291+// 2. Current directory name
292+func detectImageName(dockerfile string) (string, error) {
293+ // Try Dockerfile labels
294+ labels := parseDockerfileLabels(dockerfile)
295+ if d := labels[domain.LabelDomain]; d != "" {
296+ // Strip the registry prefix if it looks like a full image ref
297+ _, name, _ := parseImageRef(d)
298+ if name != "" {
299+ return name, nil
300+ }
301+ return d, nil
302+ }
303+
304+ // Fall back to current directory name
305+ cwd, err := os.Getwd()
306+ if err != nil {
307+ return "", fmt.Errorf("cannot detect image name: %w", err)
308+ }
309+
310+ dirName := filepath.Base(cwd)
311+ if dirName == "." || dirName == "/" {
312+ return "", fmt.Errorf("cannot detect image name from current directory; provide an image name or use --domain")
313+ }
314+
315+ return dirName, nil
316+}
317+
318+// parseDockerfileLabels extracts LABEL instructions from a Dockerfile.
319+// Returns a map of label key -> value. Only parses gordon.* labels.
320+func parseDockerfileLabels(dockerfile string) map[string]string {
321+ labels := make(map[string]string)
322+
323+ f, err := os.Open(dockerfile)
324+ if err != nil {
325+ return labels
326+ }
327+ defer f.Close()
328+
329+ scanner := bufio.NewScanner(f)
330+ for scanner.Scan() {
331+ line := strings.TrimSpace(scanner.Text())
332+
333+ // Skip comments and empty lines
334+ if line == "" || strings.HasPrefix(line, "#") {
335+ continue
336+ }
337+
338+ // Match LABEL instructions
339+ if !strings.HasPrefix(strings.ToUpper(line), "LABEL ") {
340+ continue
341+ }
342+
343+ // Parse "LABEL key=value" or "LABEL key=\"value\""
344+ labelContent := strings.TrimSpace(line[6:])
345+ for _, pair := range splitLabelPairs(labelContent) {
346+ key, value, ok := parseLabelPair(pair)
347+ if ok && strings.HasPrefix(key, "gordon.") {
348+ labels[key] = value
349+ }
350+ }
351+ }
352+
353+ return labels
354+}
355+
356+// splitLabelPairs splits LABEL content into key=value pairs,
357+// handling quoted values that may contain spaces.
358+func splitLabelPairs(content string) []string {
359+ var pairs []string
360+ var current strings.Builder
361+ inQuote := false
362+ quoteChar := byte(0)
363+
364+ for i := 0; i < len(content); i++ {
365+ ch := content[i]
366+ if inQuote {
367+ current.WriteByte(ch)
368+ if ch == quoteChar {
369+ inQuote = false
370+ }
371+ } else if ch == '"' || ch == '\'' {
372+ inQuote = true
373+ quoteChar = ch
374+ current.WriteByte(ch)
375+ } else if ch == ' ' || ch == '\t' {
376+ if current.Len() > 0 {
377+ pairs = append(pairs, current.String())
378+ current.Reset()
379+ }
380+ } else {
381+ current.WriteByte(ch)
382+ }
383+ }
384+ if current.Len() > 0 {
385+ pairs = append(pairs, current.String())
386+ }
387+
388+ return pairs
389+}
390+
391+// parseLabelPair parses a single "key=value" or "key=\"value\"" pair.
392+func parseLabelPair(pair string) (key, value string, ok bool) {
393+ idx := strings.Index(pair, "=")
394+ if idx == -1 {
395+ return "", "", false
396+ }
397+ key = pair[:idx]
398+ value = pair[idx+1:]
399+ // Strip surrounding quotes
400+ value = strings.Trim(value, "\"'")
401+ return key, value, true
402+}
403+
138 func determineVersion(ctx context.Context, tag string) string {404 func determineVersion(ctx context.Context, tag string) string {
139 version := tag405 version := tag
140 if version == "" {406 if version == "" {
M internal/adapters/in/cli/push_test.go +182 -0
@@ -1,6 +1,8 @@
11 package cli
22
33 import (
4+ "os"
5+ "path/filepath"
46 "testing"
57
68 "github.com/stretchr/testify/assert"
@@ -78,3 +80,183 @@ func TestBuildImageArgs_CustomDockerfile(t *testing.T) {
7880 assert.Contains(t, args, "-f")
7981 assert.Contains(t, args, "docker/app/Dockerfile")
8082 }
83+
84+func TestParseDockerfileLabels(t *testing.T) {
85+ tests := []struct {
86+ name string
87+ content string
88+ expected map[string]string
89+ }{
90+ {
91+ name: "simple label",
92+ content: `LABEL gordon.domain="myapp.example.com"`,
93+ expected: map[string]string{
94+ "gordon.domain": "myapp.example.com",
95+ },
96+ },
97+ {
98+ name: "multiple labels",
99+ content: `LABEL gordon.domain="myapp.example.com"
100+LABEL gordon.port="8080"
101+LABEL gordon.health="/health"`,
102+ expected: map[string]string{
103+ "gordon.domain": "myapp.example.com",
104+ "gordon.port": "8080",
105+ "gordon.health": "/health",
106+ },
107+ },
108+ {
109+ name: "multi-label on one line",
110+ content: `LABEL gordon.domain="myapp.example.com" gordon.port="8080"`,
111+ expected: map[string]string{
112+ "gordon.domain": "myapp.example.com",
113+ "gordon.port": "8080",
114+ },
115+ },
116+ {
117+ name: "unquoted value",
118+ content: `LABEL gordon.domain=myapp.example.com`,
119+ expected: map[string]string{
120+ "gordon.domain": "myapp.example.com",
121+ },
122+ },
123+ {
124+ name: "single-quoted value",
125+ content: `LABEL gordon.domain='myapp.example.com'`,
126+ expected: map[string]string{
127+ "gordon.domain": "myapp.example.com",
128+ },
129+ },
130+ {
131+ name: "non-gordon labels ignored",
132+ content: `LABEL maintainer="John" gordon.domain="myapp.example.com"`,
133+ expected: map[string]string{
134+ "gordon.domain": "myapp.example.com",
135+ },
136+ },
137+ {
138+ name: "comma-separated domains",
139+ content: `LABEL gordon.domains="app1.example.com,app2.example.com"`,
140+ expected: map[string]string{
141+ "gordon.domains": "app1.example.com,app2.example.com",
142+ },
143+ },
144+ {
145+ name: "no labels",
146+ content: `FROM alpine:latest`,
147+ expected: map[string]string{},
148+ },
149+ {
150+ name: "comments and empty lines",
151+ content: "# This is a comment\n\nLABEL gordon.domain=\"test.com\"",
152+ expected: map[string]string{"gordon.domain": "test.com"},
153+ },
154+ }
155+
156+ for _, tt := range tests {
157+ t.Run(tt.name, func(t *testing.T) {
158+ // Write a temp Dockerfile
159+ dir := t.TempDir()
160+ dockerfile := filepath.Join(dir, "Dockerfile")
161+ err := os.WriteFile(dockerfile, []byte(tt.content), 0644)
162+ assert.NoError(t, err)
163+
164+ labels := parseDockerfileLabels(dockerfile)
165+ assert.Equal(t, tt.expected, labels)
166+ })
167+ }
168+}
169+
170+func TestParseDockerfileLabels_NonExistent(t *testing.T) {
171+ labels := parseDockerfileLabels("/nonexistent/Dockerfile")
172+ assert.Empty(t, labels)
173+}
174+
175+func TestDetectImageName_FromDockerfileLabels(t *testing.T) {
176+ dir := t.TempDir()
177+ dockerfile := filepath.Join(dir, "Dockerfile")
178+ err := os.WriteFile(dockerfile, []byte(`FROM alpine
179+LABEL gordon.domain="myapp.example.com"
180+`), 0644)
181+ assert.NoError(t, err)
182+
183+ name, err := detectImageName(dockerfile)
184+ assert.NoError(t, err)
185+ assert.Equal(t, "myapp.example.com", name)
186+}
187+
188+func TestDetectImageName_FallbackToDir(t *testing.T) {
189+ dir := t.TempDir()
190+ dockerfile := filepath.Join(dir, "Dockerfile")
191+ err := os.WriteFile(dockerfile, []byte(`FROM alpine
192+`), 0644)
193+ assert.NoError(t, err)
194+
195+ // When no gordon.domain label, falls back to cwd basename
196+ name, err := detectImageName(dockerfile)
197+ assert.NoError(t, err)
198+ assert.NotEmpty(t, name)
199+}
200+
201+func TestSplitLabelPairs(t *testing.T) {
202+ tests := []struct {
203+ name string
204+ content string
205+ expected []string
206+ }{
207+ {
208+ name: "single pair",
209+ content: `gordon.domain="test.com"`,
210+ expected: []string{`gordon.domain="test.com"`},
211+ },
212+ {
213+ name: "two pairs",
214+ content: `gordon.domain="test.com" gordon.port="8080"`,
215+ expected: []string{`gordon.domain="test.com"`, `gordon.port="8080"`},
216+ },
217+ {
218+ name: "unquoted",
219+ content: `gordon.domain=test.com gordon.port=8080`,
220+ expected: []string{`gordon.domain=test.com`, `gordon.port=8080`},
221+ },
222+ {
223+ name: "quoted with spaces in value",
224+ content: `gordon.domain="my app.com"`,
225+ expected: []string{`gordon.domain="my app.com"`},
226+ },
227+ }
228+
229+ for _, tt := range tests {
230+ t.Run(tt.name, func(t *testing.T) {
231+ result := splitLabelPairs(tt.content)
232+ assert.Equal(t, tt.expected, result)
233+ })
234+ }
235+}
236+
237+func TestParseLabelPair(t *testing.T) {
238+ tests := []struct {
239+ name string
240+ pair string
241+ wantKey string
242+ wantValue string
243+ wantOk bool
244+ }{
245+ {"quoted", `gordon.domain="test.com"`, "gordon.domain", "test.com", true},
246+ {"unquoted", `gordon.domain=test.com`, "gordon.domain", "test.com", true},
247+ {"single quoted", `gordon.domain='test.com'`, "gordon.domain", "test.com", true},
248+ {"empty value", `gordon.domain=`, "gordon.domain", "", true},
249+ {"no equals", `gordon.domain`, "", "", false},
250+ }
251+
252+ for _, tt := range tests {
253+ t.Run(tt.name, func(t *testing.T) {
254+ key, value, ok := parseLabelPair(tt.pair)
255+ assert.Equal(t, tt.wantOk, ok)
256+ if ok {
257+ assert.Equal(t, tt.wantKey, key)
258+ assert.Equal(t, tt.wantValue, value)
259+ }
260+ })
261+ }
262+}
@@ -1,6 +1,8 @@
1 package cli1 package cli
2 2
3 import (3 import (
4+ "os"
5+ "path/filepath"
4 "testing"6 "testing"
5 7
6 "github.com/stretchr/testify/assert"8 "github.com/stretchr/testify/assert"
@@ -78,3 +80,183 @@ func TestBuildImageArgs_CustomDockerfile(t *testing.T) {
78 assert.Contains(t, args, "-f")80 assert.Contains(t, args, "-f")
79 assert.Contains(t, args, "docker/app/Dockerfile")81 assert.Contains(t, args, "docker/app/Dockerfile")
80 }82 }
83+
84+func TestParseDockerfileLabels(t *testing.T) {
85+ tests := []struct {
86+ name string
87+ content string
88+ expected map[string]string
89+ }{
90+ {
91+ name: "simple label",
92+ content: `LABEL gordon.domain="myapp.example.com"`,
93+ expected: map[string]string{
94+ "gordon.domain": "myapp.example.com",
95+ },
96+ },
97+ {
98+ name: "multiple labels",
99+ content: `LABEL gordon.domain="myapp.example.com"
100+LABEL gordon.port="8080"
101+LABEL gordon.health="/health"`,
102+ expected: map[string]string{
103+ "gordon.domain": "myapp.example.com",
104+ "gordon.port": "8080",
105+ "gordon.health": "/health",
106+ },
107+ },
108+ {
109+ name: "multi-label on one line",
110+ content: `LABEL gordon.domain="myapp.example.com" gordon.port="8080"`,
111+ expected: map[string]string{
112+ "gordon.domain": "myapp.example.com",
113+ "gordon.port": "8080",
114+ },
115+ },
116+ {
117+ name: "unquoted value",
118+ content: `LABEL gordon.domain=myapp.example.com`,
119+ expected: map[string]string{
120+ "gordon.domain": "myapp.example.com",
121+ },
122+ },
123+ {
124+ name: "single-quoted value",
125+ content: `LABEL gordon.domain='myapp.example.com'`,
126+ expected: map[string]string{
127+ "gordon.domain": "myapp.example.com",
128+ },
129+ },
130+ {
131+ name: "non-gordon labels ignored",
132+ content: `LABEL maintainer="John" gordon.domain="myapp.example.com"`,
133+ expected: map[string]string{
134+ "gordon.domain": "myapp.example.com",
135+ },
136+ },
137+ {
138+ name: "comma-separated domains",
139+ content: `LABEL gordon.domains="app1.example.com,app2.example.com"`,
140+ expected: map[string]string{
141+ "gordon.domains": "app1.example.com,app2.example.com",
142+ },
143+ },
144+ {
145+ name: "no labels",
146+ content: `FROM alpine:latest`,
147+ expected: map[string]string{},
148+ },
149+ {
150+ name: "comments and empty lines",
151+ content: "# This is a comment\n\nLABEL gordon.domain=\"test.com\"",
152+ expected: map[string]string{"gordon.domain": "test.com"},
153+ },
154+ }
155+
156+ for _, tt := range tests {
157+ t.Run(tt.name, func(t *testing.T) {
158+ // Write a temp Dockerfile
159+ dir := t.TempDir()
160+ dockerfile := filepath.Join(dir, "Dockerfile")
161+ err := os.WriteFile(dockerfile, []byte(tt.content), 0644)
162+ assert.NoError(t, err)
163+
164+ labels := parseDockerfileLabels(dockerfile)
165+ assert.Equal(t, tt.expected, labels)
166+ })
167+ }
168+}
169+
170+func TestParseDockerfileLabels_NonExistent(t *testing.T) {
171+ labels := parseDockerfileLabels("/nonexistent/Dockerfile")
172+ assert.Empty(t, labels)
173+}
174+
175+func TestDetectImageName_FromDockerfileLabels(t *testing.T) {
176+ dir := t.TempDir()
177+ dockerfile := filepath.Join(dir, "Dockerfile")
178+ err := os.WriteFile(dockerfile, []byte(`FROM alpine
179+LABEL gordon.domain="myapp.example.com"
180+`), 0644)
181+ assert.NoError(t, err)
182+
183+ name, err := detectImageName(dockerfile)
184+ assert.NoError(t, err)
185+ assert.Equal(t, "myapp.example.com", name)
186+}
187+
188+func TestDetectImageName_FallbackToDir(t *testing.T) {
189+ dir := t.TempDir()
190+ dockerfile := filepath.Join(dir, "Dockerfile")
191+ err := os.WriteFile(dockerfile, []byte(`FROM alpine
192+`), 0644)
193+ assert.NoError(t, err)
194+
195+ // When no gordon.domain label, falls back to cwd basename
196+ name, err := detectImageName(dockerfile)
197+ assert.NoError(t, err)
198+ assert.NotEmpty(t, name)
199+}
200+
201+func TestSplitLabelPairs(t *testing.T) {
202+ tests := []struct {
203+ name string
204+ content string
205+ expected []string
206+ }{
207+ {
208+ name: "single pair",
209+ content: `gordon.domain="test.com"`,
210+ expected: []string{`gordon.domain="test.com"`},
211+ },
212+ {
213+ name: "two pairs",
214+ content: `gordon.domain="test.com" gordon.port="8080"`,
215+ expected: []string{`gordon.domain="test.com"`, `gordon.port="8080"`},
216+ },
217+ {
218+ name: "unquoted",
219+ content: `gordon.domain=test.com gordon.port=8080`,
220+ expected: []string{`gordon.domain=test.com`, `gordon.port=8080`},
221+ },
222+ {
223+ name: "quoted with spaces in value",
224+ content: `gordon.domain="my app.com"`,
225+ expected: []string{`gordon.domain="my app.com"`},
226+ },
227+ }
228+
229+ for _, tt := range tests {
230+ t.Run(tt.name, func(t *testing.T) {
231+ result := splitLabelPairs(tt.content)
232+ assert.Equal(t, tt.expected, result)
233+ })
234+ }
235+}
236+
237+func TestParseLabelPair(t *testing.T) {
238+ tests := []struct {
239+ name string
240+ pair string
241+ wantKey string
242+ wantValue string
243+ wantOk bool
244+ }{
245+ {"quoted", `gordon.domain="test.com"`, "gordon.domain", "test.com", true},
246+ {"unquoted", `gordon.domain=test.com`, "gordon.domain", "test.com", true},
247+ {"single quoted", `gordon.domain='test.com'`, "gordon.domain", "test.com", true},
248+ {"empty value", `gordon.domain=`, "gordon.domain", "", true},
249+ {"no equals", `gordon.domain`, "", "", false},
250+ }
251+
252+ for _, tt := range tests {
253+ t.Run(tt.name, func(t *testing.T) {
254+ key, value, ok := parseLabelPair(tt.pair)
255+ assert.Equal(t, tt.wantOk, ok)
256+ if ok {
257+ assert.Equal(t, tt.wantKey, key)
258+ assert.Equal(t, tt.wantValue, value)
259+ }
260+ })
261+ }
262+}
M internal/adapters/in/cli/remote/client.go +18 -0
@@ -322,6 +322,24 @@ func (c *Client) GetRoute(ctx context.Context, routeDomain string) (*domain.Rout
322322 return &route, nil
323323 }
324324
325+// FindRoutesByImage returns all routes associated with the given image name.
326+func (c *Client) FindRoutesByImage(ctx context.Context, imageName string) ([]domain.Route, error) {
327+ resp, err := c.request(ctx, http.MethodGet, "/routes/by-image/"+url.PathEscape(imageName), nil)
328+ if err != nil {
329+ return nil, err
330+ }
331+
332+ var result struct {
333+ Image string `json:"image"`
334+ Routes []domain.Route `json:"routes"`
335+ }
336+ if err := parseResponse(resp, &result); err != nil {
337+ return nil, err
338+ }
339+
340+ return result.Routes, nil
341+}
342+
325343 // AddRoute adds a new route.
326344 func (c *Client) AddRoute(ctx context.Context, route domain.Route) error {
327345 resp, err := c.request(ctx, http.MethodPost, "/routes", route)
@@ -322,6 +322,24 @@ func (c *Client) GetRoute(ctx context.Context, routeDomain string) (*domain.Rout
322 return &route, nil322 return &route, nil
323 }323 }
324 324
325+// FindRoutesByImage returns all routes associated with the given image name.
326+func (c *Client) FindRoutesByImage(ctx context.Context, imageName string) ([]domain.Route, error) {
327+ resp, err := c.request(ctx, http.MethodGet, "/routes/by-image/"+url.PathEscape(imageName), nil)
328+ if err != nil {
329+ return nil, err
330+ }
331+
332+ var result struct {
333+ Image string `json:"image"`
334+ Routes []domain.Route `json:"routes"`
335+ }
336+ if err := parseResponse(resp, &result); err != nil {
337+ return nil, err
338+ }
339+
340+ return result.Routes, nil
341+}
342+
325 // AddRoute adds a new route.343 // AddRoute adds a new route.
326 func (c *Client) AddRoute(ctx context.Context, route domain.Route) error {344 func (c *Client) AddRoute(ctx context.Context, route domain.Route) error {
327 resp, err := c.request(ctx, http.MethodPost, "/routes", route)345 resp, err := c.request(ctx, http.MethodPost, "/routes", route)
M internal/adapters/in/http/admin/handler.go +35 -0
@@ -210,6 +210,7 @@ func (h *Handler) matchRoute(path string) (routeHandler, bool) {
210210 }{
211211 {"/backups", h.handleBackups},
212212 {"/attachments", h.handleAttachmentsConfig},
213+ {"/routes/by-image", h.handleRoutesByImage},
213214 {"/routes", h.handleRoutes},
214215 {"/secrets", h.handleSecrets},
215216 {"/deploy", h.handleDeploy},
@@ -453,6 +454,40 @@ func (h *Handler) handleRoutesDelete(w http.ResponseWriter, r *http.Request, rou
453454 h.sendJSON(w, http.StatusOK, dto.RouteDeleteResponse{Status: "removed"})
454455 }
455456
457+// handleRoutesByImage handles GET /admin/routes/by-image/{image} endpoint.
458+// Returns all routes associated with the given image name.
459+func (h *Handler) handleRoutesByImage(w http.ResponseWriter, r *http.Request, path string) {
460+ if r.Method != http.MethodGet {
461+ h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
462+ return
463+ }
464+
465+ ctx := r.Context()
466+
467+ if !HasAccess(ctx, domain.AdminResourceRoutes, domain.AdminActionRead) {
468+ h.sendError(w, http.StatusForbidden, "insufficient permissions for routes:read")
469+ return
470+ }
471+
472+ imageName := strings.TrimPrefix(path, "/routes/by-image/")
473+ if imageName == "" || imageName == "/routes/by-image" {
474+ h.sendError(w, http.StatusBadRequest, "image name required in path")
475+ return
476+ }
477+
478+ routes := h.configSvc.FindRoutesByImage(ctx, imageName)
479+
480+ response := make([]routeResponse, 0, len(routes))
481+ for _, route := range routes {
482+ response = append(response, toRouteResponse(route))
483+ }
484+
485+ h.sendJSON(w, http.StatusOK, dto.RoutesByImageResponse{
486+ Image: imageName,
487+ Routes: response,
488+ })
489+}
490+
456491 func (h *Handler) handleNetworks(w http.ResponseWriter, r *http.Request) {
457492 if r.Method != http.MethodGet {
458493 h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
@@ -210,6 +210,7 @@ func (h *Handler) matchRoute(path string) (routeHandler, bool) {
210 }{210 }{
211 {"/backups", h.handleBackups},211 {"/backups", h.handleBackups},
212 {"/attachments", h.handleAttachmentsConfig},212 {"/attachments", h.handleAttachmentsConfig},
213+ {"/routes/by-image", h.handleRoutesByImage},
213 {"/routes", h.handleRoutes},214 {"/routes", h.handleRoutes},
214 {"/secrets", h.handleSecrets},215 {"/secrets", h.handleSecrets},
215 {"/deploy", h.handleDeploy},216 {"/deploy", h.handleDeploy},
@@ -453,6 +454,40 @@ func (h *Handler) handleRoutesDelete(w http.ResponseWriter, r *http.Request, rou
453 h.sendJSON(w, http.StatusOK, dto.RouteDeleteResponse{Status: "removed"})454 h.sendJSON(w, http.StatusOK, dto.RouteDeleteResponse{Status: "removed"})
454 }455 }
455 456
457+// handleRoutesByImage handles GET /admin/routes/by-image/{image} endpoint.
458+// Returns all routes associated with the given image name.
459+func (h *Handler) handleRoutesByImage(w http.ResponseWriter, r *http.Request, path string) {
460+ if r.Method != http.MethodGet {
461+ h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
462+ return
463+ }
464+
465+ ctx := r.Context()
466+
467+ if !HasAccess(ctx, domain.AdminResourceRoutes, domain.AdminActionRead) {
468+ h.sendError(w, http.StatusForbidden, "insufficient permissions for routes:read")
469+ return
470+ }
471+
472+ imageName := strings.TrimPrefix(path, "/routes/by-image/")
473+ if imageName == "" || imageName == "/routes/by-image" {
474+ h.sendError(w, http.StatusBadRequest, "image name required in path")
475+ return
476+ }
477+
478+ routes := h.configSvc.FindRoutesByImage(ctx, imageName)
479+
480+ response := make([]routeResponse, 0, len(routes))
481+ for _, route := range routes {
482+ response = append(response, toRouteResponse(route))
483+ }
484+
485+ h.sendJSON(w, http.StatusOK, dto.RoutesByImageResponse{
486+ Image: imageName,
487+ Routes: response,
488+ })
489+}
490+
456 func (h *Handler) handleNetworks(w http.ResponseWriter, r *http.Request) {491 func (h *Handler) handleNetworks(w http.ResponseWriter, r *http.Request) {
457 if r.Method != http.MethodGet {492 if r.Method != http.MethodGet {
458 h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")493 h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
M internal/boundaries/in/config.go +4 -0
@@ -21,6 +21,10 @@ type ConfigService interface {
2121 // GetRoute returns a single route by domain.
2222 GetRoute(ctx context.Context, domain string) (*domain.Route, error)
2323
24+ // FindRoutesByImage returns all routes that match the given image name.
25+ // Image names are normalized by stripping the registry domain prefix before comparison.
26+ FindRoutesByImage(ctx context.Context, imageName string) []domain.Route
27+
2428 // AddRoute adds a new route to the configuration.
2529 AddRoute(ctx context.Context, route domain.Route) error
2630
@@ -21,6 +21,10 @@ type ConfigService interface {
21 // GetRoute returns a single route by domain.21 // GetRoute returns a single route by domain.
22 GetRoute(ctx context.Context, domain string) (*domain.Route, error)22 GetRoute(ctx context.Context, domain string) (*domain.Route, error)
23 23
24+ // FindRoutesByImage returns all routes that match the given image name.
25+ // Image names are normalized by stripping the registry domain prefix before comparison.
26+ FindRoutesByImage(ctx context.Context, imageName string) []domain.Route
27+
24 // AddRoute adds a new route to the configuration.28 // AddRoute adds a new route to the configuration.
25 AddRoute(ctx context.Context, route domain.Route) error29 AddRoute(ctx context.Context, route domain.Route) error
26 30
M internal/boundaries/in/mocks/mock_config_service.go +59 -0
@@ -158,6 +158,65 @@ func (_c *MockConfigService_AddRoute_Call) RunAndReturn(run func(ctx context.Con
158158 return _c
159159 }
160160
161+// FindRoutesByImage provides a mock function for the type MockConfigService
162+func (_mock *MockConfigService) FindRoutesByImage(ctx context.Context, imageName string) []domain.Route {
163+ ret := _mock.Called(ctx, imageName)
164+
165+ if len(ret) == 0 {
166+ panic("no return value specified for FindRoutesByImage")
167+ }
168+
169+ var r0 []domain.Route
170+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) []domain.Route); ok {
171+ r0 = returnFunc(ctx, imageName)
172+ } else {
173+ if ret.Get(0) != nil {
174+ r0 = ret.Get(0).([]domain.Route)
175+ }
176+ }
177+ return r0
178+}
179+
180+// MockConfigService_FindRoutesByImage_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'FindRoutesByImage'
181+type MockConfigService_FindRoutesByImage_Call struct {
182+ *mock.Call
183+}
184+
185+// FindRoutesByImage is a helper method to define mock.On call
186+// - ctx context.Context
187+// - imageName string
188+func (_e *MockConfigService_Expecter) FindRoutesByImage(ctx interface{}, imageName interface{}) *MockConfigService_FindRoutesByImage_Call {
189+ return &MockConfigService_FindRoutesByImage_Call{Call: _e.mock.On("FindRoutesByImage", ctx, imageName)}
190+}
191+
192+func (_c *MockConfigService_FindRoutesByImage_Call) Run(run func(ctx context.Context, imageName string)) *MockConfigService_FindRoutesByImage_Call {
193+ _c.Call.Run(func(args mock.Arguments) {
194+ var arg0 context.Context
195+ if args[0] != nil {
196+ arg0 = args[0].(context.Context)
197+ }
198+ var arg1 string
199+ if args[1] != nil {
200+ arg1 = args[1].(string)
201+ }
202+ run(
203+ arg0,
204+ arg1,
205+ )
206+ })
207+ return _c
208+}
209+
210+func (_c *MockConfigService_FindRoutesByImage_Call) Return(routes []domain.Route) *MockConfigService_FindRoutesByImage_Call {
211+ _c.Call.Return(routes)
212+ return _c
213+}
214+
215+func (_c *MockConfigService_FindRoutesByImage_Call) RunAndReturn(run func(ctx context.Context, imageName string) []domain.Route) *MockConfigService_FindRoutesByImage_Call {
216+ _c.Call.Return(run)
217+ return _c
218+}
219+
161220 // GetAllAttachments provides a mock function for the type MockConfigService
162221 func (_mock *MockConfigService) GetAllAttachments(ctx context.Context) map[string][]string {
163222 ret := _mock.Called(ctx)
@@ -158,6 +158,65 @@ func (_c *MockConfigService_AddRoute_Call) RunAndReturn(run func(ctx context.Con
158 return _c158 return _c
159 }159 }
160 160
161+// FindRoutesByImage provides a mock function for the type MockConfigService
162+func (_mock *MockConfigService) FindRoutesByImage(ctx context.Context, imageName string) []domain.Route {
163+ ret := _mock.Called(ctx, imageName)
164+
165+ if len(ret) == 0 {
166+ panic("no return value specified for FindRoutesByImage")
167+ }
168+
169+ var r0 []domain.Route
170+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) []domain.Route); ok {
171+ r0 = returnFunc(ctx, imageName)
172+ } else {
173+ if ret.Get(0) != nil {
174+ r0 = ret.Get(0).([]domain.Route)
175+ }
176+ }
177+ return r0
178+}
179+
180+// MockConfigService_FindRoutesByImage_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'FindRoutesByImage'
181+type MockConfigService_FindRoutesByImage_Call struct {
182+ *mock.Call
183+}
184+
185+// FindRoutesByImage is a helper method to define mock.On call
186+// - ctx context.Context
187+// - imageName string
188+func (_e *MockConfigService_Expecter) FindRoutesByImage(ctx interface{}, imageName interface{}) *MockConfigService_FindRoutesByImage_Call {
189+ return &MockConfigService_FindRoutesByImage_Call{Call: _e.mock.On("FindRoutesByImage", ctx, imageName)}
190+}
191+
192+func (_c *MockConfigService_FindRoutesByImage_Call) Run(run func(ctx context.Context, imageName string)) *MockConfigService_FindRoutesByImage_Call {
193+ _c.Call.Run(func(args mock.Arguments) {
194+ var arg0 context.Context
195+ if args[0] != nil {
196+ arg0 = args[0].(context.Context)
197+ }
198+ var arg1 string
199+ if args[1] != nil {
200+ arg1 = args[1].(string)
201+ }
202+ run(
203+ arg0,
204+ arg1,
205+ )
206+ })
207+ return _c
208+}
209+
210+func (_c *MockConfigService_FindRoutesByImage_Call) Return(routes []domain.Route) *MockConfigService_FindRoutesByImage_Call {
211+ _c.Call.Return(routes)
212+ return _c
213+}
214+
215+func (_c *MockConfigService_FindRoutesByImage_Call) RunAndReturn(run func(ctx context.Context, imageName string) []domain.Route) *MockConfigService_FindRoutesByImage_Call {
216+ _c.Call.Return(run)
217+ return _c
218+}
219+
161 // GetAllAttachments provides a mock function for the type MockConfigService220 // GetAllAttachments provides a mock function for the type MockConfigService
162 func (_mock *MockConfigService) GetAllAttachments(ctx context.Context) map[string][]string {221 func (_mock *MockConfigService) GetAllAttachments(ctx context.Context) map[string][]string {
163 ret := _mock.Called(ctx)222 ret := _mock.Called(ctx)
M internal/usecase/config/service.go +66 -0
@@ -226,6 +226,72 @@ func (s *Service) GetRoute(_ context.Context, domainName string) (*domain.Route,
226226 return route, nil
227227 }
228228
229+// FindRoutesByImage returns all routes whose image matches the given image name.
230+// Image names are normalized by stripping the registry domain prefix before comparison.
231+// When the input has no tag, only the name portion is compared (i.e. "myapp" matches "myapp:latest").
232+func (s *Service) FindRoutesByImage(_ context.Context, imageName string) []domain.Route {
233+ s.mu.RLock()
234+ defer s.mu.RUnlock()
235+
236+ registryDomain := s.config.RegistryDomain
237+ normalizedImage := NormalizeRegistryImage(imageName, registryDomain)
238+ inputName, inputHasTag := splitImageNameTag(normalizedImage)
239+
240+ var routes []domain.Route
241+ for domainName, image := range s.config.Routes {
242+ normalizedRouteImage := NormalizeRegistryImage(image, registryDomain)
243+
244+ match := false
245+ if inputHasTag {
246+ // Exact match when the caller provided a tag (e.g. event handler).
247+ match = strings.EqualFold(normalizedRouteImage, normalizedImage)
248+ } else {
249+ // Name-only match when no tag was provided (e.g. CLI push).
250+ routeName, _ := splitImageNameTag(normalizedRouteImage)
251+ match = strings.EqualFold(routeName, inputName)
252+ }
253+
254+ if match {
255+ route := domain.Route{
256+ Image: image,
257+ HTTPS: true,
258+ }
259+ if strings.HasPrefix(domainName, "http://") {
260+ route.Domain = strings.TrimPrefix(domainName, "http://")
261+ route.HTTPS = false
262+ } else {
263+ route.Domain = domainName
264+ }
265+ routes = append(routes, route)
266+ }
267+ }
268+
269+ return routes
270+}
271+
272+// splitImageNameTag splits "name:tag" into ("name", true) or ("name", false) when no tag is present.
273+func splitImageNameTag(image string) (name string, hasTag bool) {
274+ if idx := strings.LastIndex(image, ":"); idx != -1 {
275+ return image[:idx], true
276+ }
277+ return image, false
278+}
279+
280+// NormalizeRegistryImage strips the registry domain prefix from an image name for comparison.
281+func NormalizeRegistryImage(imageName, registryDomain string) string {
282+ registryDomain = strings.TrimSuffix(registryDomain, "/")
283+ if registryDomain == "" {
284+ return imageName
285+ }
286+
287+ prefix := registryDomain + "/"
288+ if strings.HasPrefix(imageName, prefix) {
289+ return strings.TrimPrefix(imageName, prefix)
290+ }
291+
292+ return imageName
293+}
294+
229295 // AddRoute adds a new route to the configuration and persists it.
230296 func (s *Service) AddRoute(ctx context.Context, route domain.Route) error {
231297 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
@@ -226,6 +226,72 @@ func (s *Service) GetRoute(_ context.Context, domainName string) (*domain.Route,
226 return route, nil226 return route, nil
227 }227 }
228 228
229+// FindRoutesByImage returns all routes whose image matches the given image name.
230+// Image names are normalized by stripping the registry domain prefix before comparison.
231+// When the input has no tag, only the name portion is compared (i.e. "myapp" matches "myapp:latest").
232+func (s *Service) FindRoutesByImage(_ context.Context, imageName string) []domain.Route {
233+ s.mu.RLock()
234+ defer s.mu.RUnlock()
235+
236+ registryDomain := s.config.RegistryDomain
237+ normalizedImage := NormalizeRegistryImage(imageName, registryDomain)
238+ inputName, inputHasTag := splitImageNameTag(normalizedImage)
239+
240+ var routes []domain.Route
241+ for domainName, image := range s.config.Routes {
242+ normalizedRouteImage := NormalizeRegistryImage(image, registryDomain)
243+
244+ match := false
245+ if inputHasTag {
246+ // Exact match when the caller provided a tag (e.g. event handler).
247+ match = strings.EqualFold(normalizedRouteImage, normalizedImage)
248+ } else {
249+ // Name-only match when no tag was provided (e.g. CLI push).
250+ routeName, _ := splitImageNameTag(normalizedRouteImage)
251+ match = strings.EqualFold(routeName, inputName)
252+ }
253+
254+ if match {
255+ route := domain.Route{
256+ Image: image,
257+ HTTPS: true,
258+ }
259+ if strings.HasPrefix(domainName, "http://") {
260+ route.Domain = strings.TrimPrefix(domainName, "http://")
261+ route.HTTPS = false
262+ } else {
263+ route.Domain = domainName
264+ }
265+ routes = append(routes, route)
266+ }
267+ }
268+
269+ return routes
270+}
271+
272+// splitImageNameTag splits "name:tag" into ("name", true) or ("name", false) when no tag is present.
273+func splitImageNameTag(image string) (name string, hasTag bool) {
274+ if idx := strings.LastIndex(image, ":"); idx != -1 {
275+ return image[:idx], true
276+ }
277+ return image, false
278+}
279+
280+// NormalizeRegistryImage strips the registry domain prefix from an image name for comparison.
281+func NormalizeRegistryImage(imageName, registryDomain string) string {
282+ registryDomain = strings.TrimSuffix(registryDomain, "/")
283+ if registryDomain == "" {
284+ return imageName
285+ }
286+
287+ prefix := registryDomain + "/"
288+ if strings.HasPrefix(imageName, prefix) {
289+ return strings.TrimPrefix(imageName, prefix)
290+ }
291+
292+ return imageName
293+}
294+
229 // AddRoute adds a new route to the configuration and persists it.295 // AddRoute adds a new route to the configuration and persists it.
230 func (s *Service) AddRoute(ctx context.Context, route domain.Route) error {296 func (s *Service) AddRoute(ctx context.Context, route domain.Route) error {
231 ctx = zerowrap.CtxWithFields(ctx, map[string]any{297 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
M internal/usecase/config/service_test.go +227 -0
@@ -864,6 +864,233 @@ func TestService_GetExternalRoutes_Empty(t *testing.T) {
864864 assert.Empty(t, routes)
865865 }
866866
867+func TestSplitImageNameTag(t *testing.T) {
868+ tests := []struct {
869+ name string
870+ image string
871+ wantName string
872+ wantHasTag bool
873+ }{
874+ {"with tag", "myapp:latest", "myapp", true},
875+ {"with version tag", "myapp:v1.2.0", "myapp", true},
876+ {"no tag", "myapp", "myapp", false},
877+ {"empty string", "", "", false},
878+ {"tag only colon", "myapp:", "myapp", true},
879+ {"registry prefixed with tag", "reg.example.com/myapp:latest", "reg.example.com/myapp", true},
880+ {"registry prefixed no tag", "reg.example.com/myapp", "reg.example.com/myapp", false},
881+ }
882+
883+ for _, tt := range tests {
884+ t.Run(tt.name, func(t *testing.T) {
885+ name, hasTag := splitImageNameTag(tt.image)
886+ assert.Equal(t, tt.wantName, name)
887+ assert.Equal(t, tt.wantHasTag, hasTag)
888+ })
889+ }
890+}
891+
892+func TestNormalizeRegistryImage(t *testing.T) {
893+ tests := []struct {
894+ name string
895+ imageName string
896+ registryDomain string
897+ expected string
898+ }{
899+ {"strips registry prefix", "reg.example.com/myapp:latest", "reg.example.com", "myapp:latest"},
900+ {"no prefix to strip", "myapp:latest", "reg.example.com", "myapp:latest"},
901+ {"empty registry domain", "myapp:latest", "", "myapp:latest"},
902+ {"registry with trailing slash", "reg.example.com/myapp:latest", "reg.example.com/", "myapp:latest"},
903+ {"partial match is not stripped", "reg.example.com.evil/myapp:latest", "reg.example.com", "reg.example.com.evil/myapp:latest"},
904+ {"bare name no registry", "myapp", "reg.example.com", "myapp"},
905+ {"exact registry without image", "reg.example.com/", "reg.example.com", ""},
906+ }
907+
908+ for _, tt := range tests {
909+ t.Run(tt.name, func(t *testing.T) {
910+ result := NormalizeRegistryImage(tt.imageName, tt.registryDomain)
911+ assert.Equal(t, tt.expected, result)
912+ })
913+ }
914+}
915+
916+func TestService_FindRoutesByImage(t *testing.T) {
917+ t.Run("exact match with tag", func(t *testing.T) {
918+ v := viper.New()
919+ v.Set("routes", map[string]interface{}{
920+ "app.example.com": "myapp:latest",
921+ })
922+
923+ eventBus := mocks.NewMockEventPublisher(t)
924+ svc := NewService(v, eventBus)
925+ ctx := testContext()
926+ _ = svc.Load(ctx)
927+
928+ routes := svc.FindRoutesByImage(ctx, "myapp:latest")
929+ assert.Len(t, routes, 1)
930+ assert.Equal(t, "app.example.com", routes[0].Domain)
931+ assert.Equal(t, "myapp:latest", routes[0].Image)
932+ })
933+
934+ t.Run("bare name matches route with tag", func(t *testing.T) {
935+ v := viper.New()
936+ v.Set("routes", map[string]interface{}{
937+ "app.example.com": "myapp:latest",
938+ })
939+
940+ eventBus := mocks.NewMockEventPublisher(t)
941+ svc := NewService(v, eventBus)
942+ ctx := testContext()
943+ _ = svc.Load(ctx)
944+
945+ routes := svc.FindRoutesByImage(ctx, "myapp")
946+ assert.Len(t, routes, 1)
947+ assert.Equal(t, "app.example.com", routes[0].Domain)
948+ })
949+
950+ t.Run("bare name matches route with version tag", func(t *testing.T) {
951+ v := viper.New()
952+ v.Set("routes", map[string]interface{}{
953+ "app.example.com": "myapp:v2.0.0",
954+ })
955+
956+ eventBus := mocks.NewMockEventPublisher(t)
957+ svc := NewService(v, eventBus)
958+ ctx := testContext()
959+ _ = svc.Load(ctx)
960+
961+ routes := svc.FindRoutesByImage(ctx, "myapp")
962+ assert.Len(t, routes, 1)
963+ assert.Equal(t, "app.example.com", routes[0].Domain)
964+ })
965+
966+ t.Run("tag mismatch does not match", func(t *testing.T) {
967+ v := viper.New()
968+ v.Set("routes", map[string]interface{}{
969+ "app.example.com": "myapp:v1",
970+ })
971+
972+ eventBus := mocks.NewMockEventPublisher(t)
973+ svc := NewService(v, eventBus)
974+ ctx := testContext()
975+ _ = svc.Load(ctx)
976+
977+ routes := svc.FindRoutesByImage(ctx, "myapp:v2")
978+ assert.Empty(t, routes)
979+ })
980+
981+ t.Run("no match", func(t *testing.T) {
982+ v := viper.New()
983+ v.Set("routes", map[string]interface{}{
984+ "app.example.com": "myapp:latest",
985+ })
986+
987+ eventBus := mocks.NewMockEventPublisher(t)
988+ svc := NewService(v, eventBus)
989+ ctx := testContext()
990+ _ = svc.Load(ctx)
991+
992+ routes := svc.FindRoutesByImage(ctx, "otherapp")
993+ assert.Empty(t, routes)
994+ })
995+
996+ t.Run("strips registry prefix before matching", func(t *testing.T) {
997+ v := viper.New()
998+ v.Set("server.registry_domain", "reg.example.com")
999+ v.Set("routes", map[string]interface{}{
1000+ "app.example.com": "reg.example.com/myapp:latest",
1001+ })
1002+
1003+ eventBus := mocks.NewMockEventPublisher(t)
1004+ svc := NewService(v, eventBus)
1005+ ctx := testContext()
1006+ _ = svc.Load(ctx)
1007+
1008+ routes := svc.FindRoutesByImage(ctx, "myapp")
1009+ assert.Len(t, routes, 1)
1010+ assert.Equal(t, "app.example.com", routes[0].Domain)
1011+ })
1012+
1013+ t.Run("strips registry prefix from input too", func(t *testing.T) {
1014+ v := viper.New()
1015+ v.Set("server.registry_domain", "reg.example.com")
1016+ v.Set("routes", map[string]interface{}{
1017+ "app.example.com": "reg.example.com/myapp:latest",
1018+ })
1019+
1020+ eventBus := mocks.NewMockEventPublisher(t)
1021+ svc := NewService(v, eventBus)
1022+ ctx := testContext()
1023+ _ = svc.Load(ctx)
1024+
1025+ routes := svc.FindRoutesByImage(ctx, "reg.example.com/myapp:latest")
1026+ assert.Len(t, routes, 1)
1027+ assert.Equal(t, "app.example.com", routes[0].Domain)
1028+ })
1029+
1030+ t.Run("multiple routes for same image", func(t *testing.T) {
1031+ v := viper.New()
1032+ v.Set("routes", map[string]interface{}{
1033+ "app1.example.com": "myapp:latest",
1034+ "app2.example.com": "myapp:latest",
1035+ "other.example.com": "otherapp:latest",
1036+ })
1037+
1038+ eventBus := mocks.NewMockEventPublisher(t)
1039+ svc := NewService(v, eventBus)
1040+ ctx := testContext()
1041+ _ = svc.Load(ctx)
1042+
1043+ routes := svc.FindRoutesByImage(ctx, "myapp")
1044+ assert.Len(t, routes, 2)
1045+
1046+ domains := []string{routes[0].Domain, routes[1].Domain}
1047+ assert.ElementsMatch(t, []string{"app1.example.com", "app2.example.com"}, domains)
1048+ })
1049+
1050+ t.Run("case insensitive match", func(t *testing.T) {
1051+ v := viper.New()
1052+ v.Set("routes", map[string]interface{}{
1053+ "app.example.com": "MyApp:latest",
1054+ })
1055+
1056+ eventBus := mocks.NewMockEventPublisher(t)
1057+ svc := NewService(v, eventBus)
1058+ ctx := testContext()
1059+ _ = svc.Load(ctx)
1060+
1061+ routes := svc.FindRoutesByImage(ctx, "myapp")
1062+ assert.Len(t, routes, 1)
1063+ })
1064+
1065+ t.Run("http prefix route sets HTTPS false", func(t *testing.T) {
1066+ v := viper.New()
1067+ v.Set("routes", map[string]interface{}{
1068+ "http://insecure.example.com": "myapp:latest",
1069+ })
1070+
1071+ eventBus := mocks.NewMockEventPublisher(t)
1072+ svc := NewService(v, eventBus)
1073+ ctx := testContext()
1074+ _ = svc.Load(ctx)
1075+
1076+ routes := svc.FindRoutesByImage(ctx, "myapp")
1077+ assert.Len(t, routes, 1)
1078+ assert.Equal(t, "insecure.example.com", routes[0].Domain)
1079+ assert.False(t, routes[0].HTTPS)
1080+ })
1081+
1082+ t.Run("empty routes", func(t *testing.T) {
1083+ v := viper.New()
1084+ eventBus := mocks.NewMockEventPublisher(t)
1085+ svc := NewService(v, eventBus)
1086+ ctx := testContext()
1087+ _ = svc.Load(ctx)
1088+
1089+ routes := svc.FindRoutesByImage(ctx, "myapp")
1090+ assert.Empty(t, routes)
1091+ })
1092+}
1093+
8671094 func TestExtractDomainFromImageName(t *testing.T) {
8681095 tests := []struct {
8691096 name string
@@ -864,6 +864,233 @@ func TestService_GetExternalRoutes_Empty(t *testing.T) {
864 assert.Empty(t, routes)864 assert.Empty(t, routes)
865 }865 }
866 866
867+func TestSplitImageNameTag(t *testing.T) {
868+ tests := []struct {
869+ name string
870+ image string
871+ wantName string
872+ wantHasTag bool
873+ }{
874+ {"with tag", "myapp:latest", "myapp", true},
875+ {"with version tag", "myapp:v1.2.0", "myapp", true},
876+ {"no tag", "myapp", "myapp", false},
877+ {"empty string", "", "", false},
878+ {"tag only colon", "myapp:", "myapp", true},
879+ {"registry prefixed with tag", "reg.example.com/myapp:latest", "reg.example.com/myapp", true},
880+ {"registry prefixed no tag", "reg.example.com/myapp", "reg.example.com/myapp", false},
881+ }
882+
883+ for _, tt := range tests {
884+ t.Run(tt.name, func(t *testing.T) {
885+ name, hasTag := splitImageNameTag(tt.image)
886+ assert.Equal(t, tt.wantName, name)
887+ assert.Equal(t, tt.wantHasTag, hasTag)
888+ })
889+ }
890+}
891+
892+func TestNormalizeRegistryImage(t *testing.T) {
893+ tests := []struct {
894+ name string
895+ imageName string
896+ registryDomain string
897+ expected string
898+ }{
899+ {"strips registry prefix", "reg.example.com/myapp:latest", "reg.example.com", "myapp:latest"},
900+ {"no prefix to strip", "myapp:latest", "reg.example.com", "myapp:latest"},
901+ {"empty registry domain", "myapp:latest", "", "myapp:latest"},
902+ {"registry with trailing slash", "reg.example.com/myapp:latest", "reg.example.com/", "myapp:latest"},
903+ {"partial match is not stripped", "reg.example.com.evil/myapp:latest", "reg.example.com", "reg.example.com.evil/myapp:latest"},
904+ {"bare name no registry", "myapp", "reg.example.com", "myapp"},
905+ {"exact registry without image", "reg.example.com/", "reg.example.com", ""},
906+ }
907+
908+ for _, tt := range tests {
909+ t.Run(tt.name, func(t *testing.T) {
910+ result := NormalizeRegistryImage(tt.imageName, tt.registryDomain)
911+ assert.Equal(t, tt.expected, result)
912+ })
913+ }
914+}
915+
916+func TestService_FindRoutesByImage(t *testing.T) {
917+ t.Run("exact match with tag", func(t *testing.T) {
918+ v := viper.New()
919+ v.Set("routes", map[string]interface{}{
920+ "app.example.com": "myapp:latest",
921+ })
922+
923+ eventBus := mocks.NewMockEventPublisher(t)
924+ svc := NewService(v, eventBus)
925+ ctx := testContext()
926+ _ = svc.Load(ctx)
927+
928+ routes := svc.FindRoutesByImage(ctx, "myapp:latest")
929+ assert.Len(t, routes, 1)
930+ assert.Equal(t, "app.example.com", routes[0].Domain)
931+ assert.Equal(t, "myapp:latest", routes[0].Image)
932+ })
933+
934+ t.Run("bare name matches route with tag", func(t *testing.T) {
935+ v := viper.New()
936+ v.Set("routes", map[string]interface{}{
937+ "app.example.com": "myapp:latest",
938+ })
939+
940+ eventBus := mocks.NewMockEventPublisher(t)
941+ svc := NewService(v, eventBus)
942+ ctx := testContext()
943+ _ = svc.Load(ctx)
944+
945+ routes := svc.FindRoutesByImage(ctx, "myapp")
946+ assert.Len(t, routes, 1)
947+ assert.Equal(t, "app.example.com", routes[0].Domain)
948+ })
949+
950+ t.Run("bare name matches route with version tag", func(t *testing.T) {
951+ v := viper.New()
952+ v.Set("routes", map[string]interface{}{
953+ "app.example.com": "myapp:v2.0.0",
954+ })
955+
956+ eventBus := mocks.NewMockEventPublisher(t)
957+ svc := NewService(v, eventBus)
958+ ctx := testContext()
959+ _ = svc.Load(ctx)
960+
961+ routes := svc.FindRoutesByImage(ctx, "myapp")
962+ assert.Len(t, routes, 1)
963+ assert.Equal(t, "app.example.com", routes[0].Domain)
964+ })
965+
966+ t.Run("tag mismatch does not match", func(t *testing.T) {
967+ v := viper.New()
968+ v.Set("routes", map[string]interface{}{
969+ "app.example.com": "myapp:v1",
970+ })
971+
972+ eventBus := mocks.NewMockEventPublisher(t)
973+ svc := NewService(v, eventBus)
974+ ctx := testContext()
975+ _ = svc.Load(ctx)
976+
977+ routes := svc.FindRoutesByImage(ctx, "myapp:v2")
978+ assert.Empty(t, routes)
979+ })
980+
981+ t.Run("no match", func(t *testing.T) {
982+ v := viper.New()
983+ v.Set("routes", map[string]interface{}{
984+ "app.example.com": "myapp:latest",
985+ })
986+
987+ eventBus := mocks.NewMockEventPublisher(t)
988+ svc := NewService(v, eventBus)
989+ ctx := testContext()
990+ _ = svc.Load(ctx)
991+
992+ routes := svc.FindRoutesByImage(ctx, "otherapp")
993+ assert.Empty(t, routes)
994+ })
995+
996+ t.Run("strips registry prefix before matching", func(t *testing.T) {
997+ v := viper.New()
998+ v.Set("server.registry_domain", "reg.example.com")
999+ v.Set("routes", map[string]interface{}{
1000+ "app.example.com": "reg.example.com/myapp:latest",
1001+ })
1002+
1003+ eventBus := mocks.NewMockEventPublisher(t)
1004+ svc := NewService(v, eventBus)
1005+ ctx := testContext()
1006+ _ = svc.Load(ctx)
1007+
1008+ routes := svc.FindRoutesByImage(ctx, "myapp")
1009+ assert.Len(t, routes, 1)
1010+ assert.Equal(t, "app.example.com", routes[0].Domain)
1011+ })
1012+
1013+ t.Run("strips registry prefix from input too", func(t *testing.T) {
1014+ v := viper.New()
1015+ v.Set("server.registry_domain", "reg.example.com")
1016+ v.Set("routes", map[string]interface{}{
1017+ "app.example.com": "reg.example.com/myapp:latest",
1018+ })
1019+
1020+ eventBus := mocks.NewMockEventPublisher(t)
1021+ svc := NewService(v, eventBus)
1022+ ctx := testContext()
1023+ _ = svc.Load(ctx)
1024+
1025+ routes := svc.FindRoutesByImage(ctx, "reg.example.com/myapp:latest")
1026+ assert.Len(t, routes, 1)
1027+ assert.Equal(t, "app.example.com", routes[0].Domain)
1028+ })
1029+
1030+ t.Run("multiple routes for same image", func(t *testing.T) {
1031+ v := viper.New()
1032+ v.Set("routes", map[string]interface{}{
1033+ "app1.example.com": "myapp:latest",
1034+ "app2.example.com": "myapp:latest",
1035+ "other.example.com": "otherapp:latest",
1036+ })
1037+
1038+ eventBus := mocks.NewMockEventPublisher(t)
1039+ svc := NewService(v, eventBus)
1040+ ctx := testContext()
1041+ _ = svc.Load(ctx)
1042+
1043+ routes := svc.FindRoutesByImage(ctx, "myapp")
1044+ assert.Len(t, routes, 2)
1045+
1046+ domains := []string{routes[0].Domain, routes[1].Domain}
1047+ assert.ElementsMatch(t, []string{"app1.example.com", "app2.example.com"}, domains)
1048+ })
1049+
1050+ t.Run("case insensitive match", func(t *testing.T) {
1051+ v := viper.New()
1052+ v.Set("routes", map[string]interface{}{
1053+ "app.example.com": "MyApp:latest",
1054+ })
1055+
1056+ eventBus := mocks.NewMockEventPublisher(t)
1057+ svc := NewService(v, eventBus)
1058+ ctx := testContext()
1059+ _ = svc.Load(ctx)
1060+
1061+ routes := svc.FindRoutesByImage(ctx, "myapp")
1062+ assert.Len(t, routes, 1)
1063+ })
1064+
1065+ t.Run("http prefix route sets HTTPS false", func(t *testing.T) {
1066+ v := viper.New()
1067+ v.Set("routes", map[string]interface{}{
1068+ "http://insecure.example.com": "myapp:latest",
1069+ })
1070+
1071+ eventBus := mocks.NewMockEventPublisher(t)
1072+ svc := NewService(v, eventBus)
1073+ ctx := testContext()
1074+ _ = svc.Load(ctx)
1075+
1076+ routes := svc.FindRoutesByImage(ctx, "myapp")
1077+ assert.Len(t, routes, 1)
1078+ assert.Equal(t, "insecure.example.com", routes[0].Domain)
1079+ assert.False(t, routes[0].HTTPS)
1080+ })
1081+
1082+ t.Run("empty routes", func(t *testing.T) {
1083+ v := viper.New()
1084+ eventBus := mocks.NewMockEventPublisher(t)
1085+ svc := NewService(v, eventBus)
1086+ ctx := testContext()
1087+ _ = svc.Load(ctx)
1088+
1089+ routes := svc.FindRoutesByImage(ctx, "myapp")
1090+ assert.Empty(t, routes)
1091+ })
1092+}
1093+
867 func TestExtractDomainFromImageName(t *testing.T) {1094 func TestExtractDomainFromImageName(t *testing.T) {
868 tests := []struct {1095 tests := []struct {
869 name string1096 name string
M internal/usecase/container/events.go +1 -33
@@ -3,7 +3,6 @@ package container
33 import (
44 "context"
55 "fmt"
6- "strings"
76
87 "github.com/bnema/zerowrap"
98
@@ -50,7 +49,7 @@ func (h *ImagePushedHandler) Handle(ctx context.Context, event domain.Event) err
5049
5150 log.Info().Str("image", fullImageName).Msg("processing image push event")
5251
53- routes := h.findRoutesForImage(fullImageName)
52+ routes := h.configSvc.FindRoutesByImage(ctx, fullImageName)
5453 if len(routes) == 0 {
5554 log.Debug().Str("image", fullImageName).Msg("no routes configured for pushed image")
5655 return nil
@@ -74,37 +73,6 @@ func (h *ImagePushedHandler) CanHandle(eventType domain.EventType) bool {
7473 return eventType == domain.EventImagePushed
7574 }
7675
77-func (h *ImagePushedHandler) findRoutesForImage(imageName string) []domain.Route {
78- var routes []domain.Route
79-
80- registryDomain := h.configSvc.GetRegistryDomain()
81- normalizedImage := normalizeRegistryImage(imageName, registryDomain)
82-
83- configuredRoutes := h.configSvc.GetRoutes(h.ctx)
84- for _, route := range configuredRoutes {
85- normalizedRouteImage := normalizeRegistryImage(route.Image, registryDomain)
86- if strings.EqualFold(normalizedRouteImage, normalizedImage) {
87- routes = append(routes, route)
88- }
89- }
90-
91- return routes
92-}
93-
94-func normalizeRegistryImage(imageName, registryDomain string) string {
95- registryDomain = strings.TrimSuffix(registryDomain, "/")
96- if registryDomain == "" {
97- return imageName
98- }
99-
100- prefix := registryDomain + "/"
101- if strings.HasPrefix(imageName, prefix) {
102- return strings.TrimPrefix(imageName, prefix)
103- }
104-
105- return imageName
106-}
107-
10876 // ConfigReloadHandler handles config.reload events.
10977 type ConfigReloadHandler struct {
11078 containerSvc in.ContainerService
@@ -3,7 +3,6 @@ package container
3 import (3 import (
4 "context"4 "context"
5 "fmt"5 "fmt"
6- "strings"
7 6
8 "github.com/bnema/zerowrap"7 "github.com/bnema/zerowrap"
9 8
@@ -50,7 +49,7 @@ func (h *ImagePushedHandler) Handle(ctx context.Context, event domain.Event) err
50 49
51 log.Info().Str("image", fullImageName).Msg("processing image push event")50 log.Info().Str("image", fullImageName).Msg("processing image push event")
52 51
53- routes := h.findRoutesForImage(fullImageName)52+ routes := h.configSvc.FindRoutesByImage(ctx, fullImageName)
54 if len(routes) == 0 {53 if len(routes) == 0 {
55 log.Debug().Str("image", fullImageName).Msg("no routes configured for pushed image")54 log.Debug().Str("image", fullImageName).Msg("no routes configured for pushed image")
56 return nil55 return nil
@@ -74,37 +73,6 @@ func (h *ImagePushedHandler) CanHandle(eventType domain.EventType) bool {
74 return eventType == domain.EventImagePushed73 return eventType == domain.EventImagePushed
75 }74 }
76 75
77-func (h *ImagePushedHandler) findRoutesForImage(imageName string) []domain.Route {
78- var routes []domain.Route
79-
80- registryDomain := h.configSvc.GetRegistryDomain()
81- normalizedImage := normalizeRegistryImage(imageName, registryDomain)
82-
83- configuredRoutes := h.configSvc.GetRoutes(h.ctx)
84- for _, route := range configuredRoutes {
85- normalizedRouteImage := normalizeRegistryImage(route.Image, registryDomain)
86- if strings.EqualFold(normalizedRouteImage, normalizedImage) {
87- routes = append(routes, route)
88- }
89- }
90-
91- return routes
92-}
93-
94-func normalizeRegistryImage(imageName, registryDomain string) string {
95- registryDomain = strings.TrimSuffix(registryDomain, "/")
96- if registryDomain == "" {
97- return imageName
98- }
99-
100- prefix := registryDomain + "/"
101- if strings.HasPrefix(imageName, prefix) {
102- return strings.TrimPrefix(imageName, prefix)
103- }
104-
105- return imageName
106-}
107-
108 // ConfigReloadHandler handles config.reload events.76 // ConfigReloadHandler handles config.reload events.
109 type ConfigReloadHandler struct {77 type ConfigReloadHandler struct {
110 containerSvc in.ContainerService78 containerSvc in.ContainerService
M internal/usecase/container/events_test.go +8 -18
@@ -36,13 +36,10 @@ func TestImagePushedHandler_Handle_DeploysMatchingRoutes(t *testing.T) {
3636
3737 handler := NewImagePushedHandler(testCtx(), containerSvc, configSvc)
3838
39- configSvc.EXPECT().GetRegistryDomain().Return("")
40-
41- // Configure routes that match the pushed image
42- configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{
39+ // FindRoutesByImage returns matching routes directly
40+ configSvc.EXPECT().FindRoutesByImage(mock.Anything, "myapp:latest").Return([]domain.Route{
4341 {Domain: "app1.example.com", Image: "myapp:latest"},
4442 {Domain: "app2.example.com", Image: "myapp:latest"},
45- {Domain: "other.example.com", Image: "otherapp:latest"},
4643 })
4744
4845 // Expect Deploy to be called for both matching routes
@@ -74,11 +71,8 @@ func TestImagePushedHandler_Handle_NoMatchingRoutes(t *testing.T) {
7471
7572 handler := NewImagePushedHandler(testCtx(), containerSvc, configSvc)
7673
77- configSvc.EXPECT().GetRegistryDomain().Return("")
78-
79- configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{
80- {Domain: "other.example.com", Image: "otherapp:latest"},
81- })
74+ // FindRoutesByImage returns no matching routes
75+ configSvc.EXPECT().FindRoutesByImage(mock.Anything, "myapp:latest").Return([]domain.Route{})
8276
8377 // No Deploy calls expected
8478
@@ -118,10 +112,8 @@ func TestImagePushedHandler_Handle_DefaultTag(t *testing.T) {
118112
119113 handler := NewImagePushedHandler(testCtx(), containerSvc, configSvc)
120114
121- configSvc.EXPECT().GetRegistryDomain().Return("")
122-
123115 // Image name "myapp" with empty tag should become "myapp:latest"
124- configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{
116+ configSvc.EXPECT().FindRoutesByImage(mock.Anything, "myapp:latest").Return([]domain.Route{
125117 {Domain: "app.example.com", Image: "myapp:latest"},
126118 })
127119
@@ -148,9 +140,7 @@ func TestImagePushedHandler_Handle_DeployError(t *testing.T) {
148140
149141 handler := NewImagePushedHandler(testCtx(), containerSvc, configSvc)
150142
151- configSvc.EXPECT().GetRegistryDomain().Return("")
152-
153- configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{
143+ configSvc.EXPECT().FindRoutesByImage(mock.Anything, "myapp:latest").Return([]domain.Route{
154144 {Domain: "app.example.com", Image: "myapp:latest"},
155145 })
156146
@@ -175,8 +165,8 @@ func TestImagePushedHandler_Handle_StripsRegistryDomain(t *testing.T) {
175165
176166 handler := NewImagePushedHandler(testCtx(), containerSvc, configSvc)
177167
178- configSvc.EXPECT().GetRegistryDomain().Return("registry.example.com")
179- configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{
168+ // FindRoutesByImage handles registry domain stripping internally
169+ configSvc.EXPECT().FindRoutesByImage(mock.Anything, "docker.io/library/nginx:latest").Return([]domain.Route{
180170 {Domain: "app.example.com", Image: "registry.example.com/docker.io/library/nginx:latest"},
181171 })
182172
@@ -36,13 +36,10 @@ func TestImagePushedHandler_Handle_DeploysMatchingRoutes(t *testing.T) {
36 36
37 handler := NewImagePushedHandler(testCtx(), containerSvc, configSvc)37 handler := NewImagePushedHandler(testCtx(), containerSvc, configSvc)
38 38
39- configSvc.EXPECT().GetRegistryDomain().Return("")39+ // FindRoutesByImage returns matching routes directly
40-40+ configSvc.EXPECT().FindRoutesByImage(mock.Anything, "myapp:latest").Return([]domain.Route{
41- // Configure routes that match the pushed image
42- configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{
43 {Domain: "app1.example.com", Image: "myapp:latest"},41 {Domain: "app1.example.com", Image: "myapp:latest"},
44 {Domain: "app2.example.com", Image: "myapp:latest"},42 {Domain: "app2.example.com", Image: "myapp:latest"},
45- {Domain: "other.example.com", Image: "otherapp:latest"},
46 })43 })
47 44
48 // Expect Deploy to be called for both matching routes45 // Expect Deploy to be called for both matching routes
@@ -74,11 +71,8 @@ func TestImagePushedHandler_Handle_NoMatchingRoutes(t *testing.T) {
74 71
75 handler := NewImagePushedHandler(testCtx(), containerSvc, configSvc)72 handler := NewImagePushedHandler(testCtx(), containerSvc, configSvc)
76 73
77- configSvc.EXPECT().GetRegistryDomain().Return("")74+ // FindRoutesByImage returns no matching routes
78-75+ configSvc.EXPECT().FindRoutesByImage(mock.Anything, "myapp:latest").Return([]domain.Route{})
79- configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{
80- {Domain: "other.example.com", Image: "otherapp:latest"},
81- })
82 76
83 // No Deploy calls expected77 // No Deploy calls expected
84 78
@@ -118,10 +112,8 @@ func TestImagePushedHandler_Handle_DefaultTag(t *testing.T) {
118 112
119 handler := NewImagePushedHandler(testCtx(), containerSvc, configSvc)113 handler := NewImagePushedHandler(testCtx(), containerSvc, configSvc)
120 114
121- configSvc.EXPECT().GetRegistryDomain().Return("")
122-
123 // Image name "myapp" with empty tag should become "myapp:latest"115 // Image name "myapp" with empty tag should become "myapp:latest"
124- configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{116+ configSvc.EXPECT().FindRoutesByImage(mock.Anything, "myapp:latest").Return([]domain.Route{
125 {Domain: "app.example.com", Image: "myapp:latest"},117 {Domain: "app.example.com", Image: "myapp:latest"},
126 })118 })
127 119
@@ -148,9 +140,7 @@ func TestImagePushedHandler_Handle_DeployError(t *testing.T) {
148 140
149 handler := NewImagePushedHandler(testCtx(), containerSvc, configSvc)141 handler := NewImagePushedHandler(testCtx(), containerSvc, configSvc)
150 142
151- configSvc.EXPECT().GetRegistryDomain().Return("")143+ configSvc.EXPECT().FindRoutesByImage(mock.Anything, "myapp:latest").Return([]domain.Route{
152-
153- configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{
154 {Domain: "app.example.com", Image: "myapp:latest"},144 {Domain: "app.example.com", Image: "myapp:latest"},
155 })145 })
156 146
@@ -175,8 +165,8 @@ func TestImagePushedHandler_Handle_StripsRegistryDomain(t *testing.T) {
175 165
176 handler := NewImagePushedHandler(testCtx(), containerSvc, configSvc)166 handler := NewImagePushedHandler(testCtx(), containerSvc, configSvc)
177 167
178- configSvc.EXPECT().GetRegistryDomain().Return("registry.example.com")168+ // FindRoutesByImage handles registry domain stripping internally
179- configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{169+ configSvc.EXPECT().FindRoutesByImage(mock.Anything, "docker.io/library/nginx:latest").Return([]domain.Route{
180 {Domain: "app.example.com", Image: "registry.example.com/docker.io/library/nginx:latest"},170 {Domain: "app.example.com", Image: "registry.example.com/docker.io/library/nginx:latest"},
181 })171 })
182 172

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 8

Tags 106

↑↓ moveenter openesc close