detached ab55833

refactor(cli): rewrite push command to resolve domain from image

commit
ab55833f1e68a5570bafdec27d509015eeec9618
author
bnema · · 2026-02-09 20:22 UTC
parent
62502c9
signature
unsigned
2 files · +470 -22
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+}

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