detached 9287455

refactor(cli): make push image-first (#173)

* test: cover push image-first parsing

* refactor(cli): make push image-first

* docs: update push command examples

* fix(cli): prefer image lookup for dotted push args

* fix(cli): harden push route lookup fallback
commit
9287455feec187ec4001f8ecfec152651b5009ce
author
bnema · · 2026-04-12 14:59 UTC
committer
GitHub · · 2026-04-12 14:59 UTC
parent
5569831
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
9 files · +272 -43
M README.md +3 -3
@@ -32,8 +32,8 @@ Config is created at `~/.config/gordon/gordon.toml`. See the [Getting Started gu
3232 Build locally, push directly to your Gordon server:
3333
3434 ```bash
35-# Push an image and deploy it to a domain
36-gordon push app.example.com --image myapp:latest
35+# Push using an explicit domain override
36+gordon push --domain app.example.com
3737
3838 # Or add a route manually, then deploy
3939 gordon routes add app.example.com myapp:latest
@@ -90,7 +90,7 @@ See the [Deploy Action README](.github/actions/deploy/README.md) for multi-platf
9090
9191 | Command | Description |
9292 |---------|-------------|
93-| `gordon push <domain>` | Tag and push an image to deploy |
93+| `gordon push [image]` | Tag, push, and optionally deploy an image |
9494 | `gordon routes list` | List all routes |
9595 | `gordon routes add <domain> <image>` | Create or update a route |
9696 | `gordon routes remove <domain>` | Remove a route |
@@ -32,8 +32,8 @@ Config is created at `~/.config/gordon/gordon.toml`. See the [Getting Started gu
32 Build locally, push directly to your Gordon server:32 Build locally, push directly to your Gordon server:
33 33
34 ```bash34 ```bash
35-# Push an image and deploy it to a domain35+# Push using an explicit domain override
36-gordon push app.example.com --image myapp:latest36+gordon push --domain app.example.com
37 37
38 # Or add a route manually, then deploy38 # Or add a route manually, then deploy
39 gordon routes add app.example.com myapp:latest39 gordon routes add app.example.com myapp:latest
@@ -90,7 +90,7 @@ See the [Deploy Action README](.github/actions/deploy/README.md) for multi-platf
90 90
91 | Command | Description |91 | Command | Description |
92 |---------|-------------|92 |---------|-------------|
93-| `gordon push <domain>` | Tag and push an image to deploy |93+| `gordon push [image]` | Tag, push, and optionally deploy an image |
94 | `gordon routes list` | List all routes |94 | `gordon routes list` | List all routes |
95 | `gordon routes add <domain> <image>` | Create or update a route |95 | `gordon routes add <domain> <image>` | Create or update a route |
96 | `gordon routes remove <domain>` | Remove a route |96 | `gordon routes remove <domain>` | Remove a route |
M docs/cli/attachments.md +1 -1
@@ -255,7 +255,7 @@ gordon bootstrap app.example.com myapp:latest --attachment pitlane-pgsql
255255 gordon attachments push pitlane-pgsql --build
256256
257257 # Then push and deploy the route image
258-gordon push app.example.com --build --no-confirm
258+gordon push --domain app.example.com --build --no-confirm
259259 ```
260260
261261 ### CI/CD Integration
@@ -255,7 +255,7 @@ gordon bootstrap app.example.com myapp:latest --attachment pitlane-pgsql
255 gordon attachments push pitlane-pgsql --build255 gordon attachments push pitlane-pgsql --build
256 256
257 # Then push and deploy the route image257 # Then push and deploy the route image
258-gordon push app.example.com --build --no-confirm258+gordon push --domain app.example.com --build --no-confirm
259 ```259 ```
260 260
261 ### CI/CD Integration261 ### CI/CD Integration
M docs/cli/bootstrap.md +2 -2
@@ -44,7 +44,7 @@ Unlike `gordon push`, `gordon bootstrap` does not require the route to exist fir
4444 gordon bootstrap app.example.com myapp:latest
4545
4646 # Then push and deploy the image
47-gordon push app.example.com --build --no-confirm
47+gordon push --domain app.example.com --build --no-confirm
4848
4949 # First-time route setup with a database attachment and environment variable
5050 gordon bootstrap app.example.com myapp:latest --attachment postgres:18 --env APP_ENV=production
@@ -63,7 +63,7 @@ gordon bootstrap app.example.com myapp:latest --remote https://gordon.mydomain.c
6363 gordon attachments push pitlane-pgsql --build
6464
6565 # Then push and deploy the route image
66-gordon push app.example.com --build --no-confirm
66+gordon push myapp --build --no-confirm
6767 ```
6868
6969 ### Notes
@@ -44,7 +44,7 @@ Unlike `gordon push`, `gordon bootstrap` does not require the route to exist fir
44 gordon bootstrap app.example.com myapp:latest44 gordon bootstrap app.example.com myapp:latest
45 45
46 # Then push and deploy the image46 # Then push and deploy the image
47-gordon push app.example.com --build --no-confirm47+gordon push --domain app.example.com --build --no-confirm
48 48
49 # First-time route setup with a database attachment and environment variable49 # First-time route setup with a database attachment and environment variable
50 gordon bootstrap app.example.com myapp:latest --attachment postgres:18 --env APP_ENV=production50 gordon bootstrap app.example.com myapp:latest --attachment postgres:18 --env APP_ENV=production
@@ -63,7 +63,7 @@ gordon bootstrap app.example.com myapp:latest --remote https://gordon.mydomain.c
63 gordon attachments push pitlane-pgsql --build63 gordon attachments push pitlane-pgsql --build
64 64
65 # Then push and deploy the route image65 # Then push and deploy the route image
66-gordon push app.example.com --build --no-confirm66+gordon push myapp --build --no-confirm
67 ```67 ```
68 68
69 ### Notes69 ### Notes
M docs/cli/index.md +3 -3
@@ -72,13 +72,13 @@ gordon restart myapp.example.com
7272 gordon bootstrap app.example.com myapp:latest --attachment postgres:18 --env APP_ENV=production
7373
7474 # Then push and deploy
75-gordon push app.example.com --build --no-confirm
75+gordon push --domain app.example.com --build --no-confirm
7676
7777 # Push an image and deploy
78-gordon push myapp.example.com --build
78+gordon push myapp --build
7979
8080 # Push and deploy without confirmation
81-gordon push myapp.example.com --no-confirm
81+gordon push myapp --no-confirm
8282
8383 # Roll back to a previous tag
8484 gordon rollback myapp.example.com
@@ -72,13 +72,13 @@ gordon restart myapp.example.com
72 gordon bootstrap app.example.com myapp:latest --attachment postgres:18 --env APP_ENV=production72 gordon bootstrap app.example.com myapp:latest --attachment postgres:18 --env APP_ENV=production
73 73
74 # Then push and deploy74 # Then push and deploy
75-gordon push app.example.com --build --no-confirm75+gordon push --domain app.example.com --build --no-confirm
76 76
77 # Push an image and deploy77 # Push an image and deploy
78-gordon push myapp.example.com --build78+gordon push myapp --build
79 79
80 # Push and deploy without confirmation80 # Push and deploy without confirmation
81-gordon push myapp.example.com --no-confirm81+gordon push myapp --no-confirm
82 82
83 # Roll back to a previous tag83 # Roll back to a previous tag
84 gordon rollback myapp.example.com84 gordon rollback myapp.example.com
M docs/cli/push.md +23 -14
@@ -24,22 +24,25 @@ gordon push [image] [options]
2424 | `-f, --file` | Path to Dockerfile (default: `./Dockerfile`, used with `--build`) |
2525 | `--platform` | Target platform for buildx (default: `linux/amd64`) |
2626 | `--build-arg` | Additional build args (repeatable, `KEY=VALUE`) |
27-| `--tag` | Override version tag (default: tag ref from CI, then `git describe --tags --dirty`) |
27+| `--tag` | Override pushed version tag (default: tag ref from CI, then `git describe --tags --dirty`) |
2828 | `--no-confirm` | Skip deploy confirmation prompt |
2929 | `--no-deploy` | Push only; skip deployment prompt |
30-| `--domain` | Explicit domain override (legacy mode) |
30+| `--domain` | Explicit deploy target override |
3131 | `--remote, -r` | Remote name or URL (e.g., prod, https://gordon.mydomain.com) |
3232 | `--token` | Authentication token for remote |
3333
3434 ### Description
3535
36-`gordon push` resolves the target image from the backend, tags it for the
37-Gordon registry, pushes it, and optionally deploys it.
36+`gordon push` tags the selected image for the Gordon registry, pushes it, and
37+optionally deploys matching routes.
3838
3939 Image resolution order:
40-1. If `--domain` is provided, uses domain-based route lookup
41-2. If an image name is provided as argument, resolves domain(s) from the backend
42-3. If no argument, auto-detects from Dockerfile `gordon.domain` label or current directory name
40+1. `--domain` is the explicit deploy target override for legacy workflows
41+2. Positional refs resolve routes by image name; tagged refs still use the image name for lookup
42+3. Dotted positional refs probe image routes first, then fall back to legacy domain lookup
43+4. No-arg mode auto-detects from the Dockerfile label or current directory
44+
45+The pushed version still comes from `--tag`, CI tag refs, or `git describe --tags --dirty`.
4346
4447 To push attachment images (databases, caches, etc.), use `gordon attachments push`.
4548
@@ -93,30 +96,36 @@ Gordon reads version tags from CI environment variables (in priority order):
9396 # Build, push, and deploy (auto-detect image name)
9497 gordon push --build --remote https://gordon.example.com --no-confirm
9598
96-# Specify image name explicitly
97-gordon push myapp --build --remote https://gordon.example.com --no-confirm
99+# Push an image and deploy it
100+gordon push myapp --remote https://gordon.example.com --no-confirm
101+
102+# Push and deploy to an explicit domain
103+gordon push --domain app.example.com --remote https://gordon.example.com --no-confirm
104+
105+# Tagged refs still resolve routes by image name
106+gordon push myapp:v1.2.3 --tag v1.2.3 --no-deploy
98107
99108 # Push existing local image, skip deploy
100109 gordon push myapp --tag v1.2.0 --no-deploy
101110
102-# Push and deploy without confirmation
103-gordon push myapp --no-confirm
104-
105111 # Build for ARM and pass build args
106112 gordon push myapp --build --platform linux/arm64 --build-arg CGO_ENABLED=0
107113
108114 # Build from a custom Dockerfile path
109115 gordon push myapp --build -f docker/app/Dockerfile
110116
117+# Legacy compatibility: domain-looking positional target
118+gordon push app.example.com --no-confirm
119+
111120 # CI/CD usage (single env var, no docker login needed)
112121 export GORDON_TOKEN="your-token"
113-gordon push --build --remote https://gordon.example.com --no-confirm
122+gordon push myapp --build --remote https://gordon.example.com --no-confirm
114123 ```
115124
116125 ### Notes
117126
118127 - Remote mode required. See [CLI Overview](./index.md) for targeting options.
119-- `gordon push` requires the route to already exist so it can resolve the target image.
128+- `gordon push` requires the target route to already exist so it can resolve the deploy target.
120129 Use `gordon bootstrap` for first deploys.
121130 - `--build` requires Docker with Buildx. Docker Desktop includes it; on Linux,
122131 install the `docker-buildx-plugin` package.
@@ -24,22 +24,25 @@ gordon push [image] [options]
24 | `-f, --file` | Path to Dockerfile (default: `./Dockerfile`, used with `--build`) |24 | `-f, --file` | Path to Dockerfile (default: `./Dockerfile`, used with `--build`) |
25 | `--platform` | Target platform for buildx (default: `linux/amd64`) |25 | `--platform` | Target platform for buildx (default: `linux/amd64`) |
26 | `--build-arg` | Additional build args (repeatable, `KEY=VALUE`) |26 | `--build-arg` | Additional build args (repeatable, `KEY=VALUE`) |
27-| `--tag` | Override version tag (default: tag ref from CI, then `git describe --tags --dirty`) |27+| `--tag` | Override pushed version tag (default: tag ref from CI, then `git describe --tags --dirty`) |
28 | `--no-confirm` | Skip deploy confirmation prompt |28 | `--no-confirm` | Skip deploy confirmation prompt |
29 | `--no-deploy` | Push only; skip deployment prompt |29 | `--no-deploy` | Push only; skip deployment prompt |
30-| `--domain` | Explicit domain override (legacy mode) |30+| `--domain` | Explicit deploy target override |
31 | `--remote, -r` | Remote name or URL (e.g., prod, https://gordon.mydomain.com) |31 | `--remote, -r` | Remote name or URL (e.g., prod, https://gordon.mydomain.com) |
32 | `--token` | Authentication token for remote |32 | `--token` | Authentication token for remote |
33 33
34 ### Description34 ### Description
35 35
36-`gordon push` resolves the target image from the backend, tags it for the36+`gordon push` tags the selected image for the Gordon registry, pushes it, and
37-Gordon registry, pushes it, and optionally deploys it.37+optionally deploys matching routes.
38 38
39 Image resolution order:39 Image resolution order:
40-1. If `--domain` is provided, uses domain-based route lookup40+1. `--domain` is the explicit deploy target override for legacy workflows
41-2. If an image name is provided as argument, resolves domain(s) from the backend41+2. Positional refs resolve routes by image name; tagged refs still use the image name for lookup
42-3. If no argument, auto-detects from Dockerfile `gordon.domain` label or current directory name42+3. Dotted positional refs probe image routes first, then fall back to legacy domain lookup
43+4. No-arg mode auto-detects from the Dockerfile label or current directory
44+
45+The pushed version still comes from `--tag`, CI tag refs, or `git describe --tags --dirty`.
43 46
44 To push attachment images (databases, caches, etc.), use `gordon attachments push`.47 To push attachment images (databases, caches, etc.), use `gordon attachments push`.
45 48
@@ -93,30 +96,36 @@ Gordon reads version tags from CI environment variables (in priority order):
93 # Build, push, and deploy (auto-detect image name)96 # Build, push, and deploy (auto-detect image name)
94 gordon push --build --remote https://gordon.example.com --no-confirm97 gordon push --build --remote https://gordon.example.com --no-confirm
95 98
96-# Specify image name explicitly99+# Push an image and deploy it
97-gordon push myapp --build --remote https://gordon.example.com --no-confirm100+gordon push myapp --remote https://gordon.example.com --no-confirm
101+
102+# Push and deploy to an explicit domain
103+gordon push --domain app.example.com --remote https://gordon.example.com --no-confirm
104+
105+# Tagged refs still resolve routes by image name
106+gordon push myapp:v1.2.3 --tag v1.2.3 --no-deploy
98 107
99 # Push existing local image, skip deploy108 # Push existing local image, skip deploy
100 gordon push myapp --tag v1.2.0 --no-deploy109 gordon push myapp --tag v1.2.0 --no-deploy
101 110
102-# Push and deploy without confirmation
103-gordon push myapp --no-confirm
104-
105 # Build for ARM and pass build args111 # Build for ARM and pass build args
106 gordon push myapp --build --platform linux/arm64 --build-arg CGO_ENABLED=0112 gordon push myapp --build --platform linux/arm64 --build-arg CGO_ENABLED=0
107 113
108 # Build from a custom Dockerfile path114 # Build from a custom Dockerfile path
109 gordon push myapp --build -f docker/app/Dockerfile115 gordon push myapp --build -f docker/app/Dockerfile
110 116
117+# Legacy compatibility: domain-looking positional target
118+gordon push app.example.com --no-confirm
119+
111 # CI/CD usage (single env var, no docker login needed)120 # CI/CD usage (single env var, no docker login needed)
112 export GORDON_TOKEN="your-token"121 export GORDON_TOKEN="your-token"
113-gordon push --build --remote https://gordon.example.com --no-confirm122+gordon push myapp --build --remote https://gordon.example.com --no-confirm
114 ```123 ```
115 124
116 ### Notes125 ### Notes
117 126
118 - Remote mode required. See [CLI Overview](./index.md) for targeting options.127 - Remote mode required. See [CLI Overview](./index.md) for targeting options.
119-- `gordon push` requires the route to already exist so it can resolve the target image.128+- `gordon push` requires the target route to already exist so it can resolve the deploy target.
120 Use `gordon bootstrap` for first deploys.129 Use `gordon bootstrap` for first deploys.
121 - `--build` requires Docker with Buildx. Docker Desktop includes it; on Linux,130 - `--build` requires Docker with Buildx. Docker Desktop includes it; on Linux,
122 install the `docker-buildx-plugin` package.131 install the `docker-buildx-plugin` package.
M docs/getting-started.md +3 -3
@@ -147,7 +147,7 @@ gordon remotes use prod
147147 gordon bootstrap app.example.com myapp:latest --attachment postgres:18 --env APP_ENV=production
148148
149149 # Then build, push, and deploy
150-gordon push app.example.com --build --no-confirm
150+gordon push --domain app.example.com --build --no-confirm
151151 ```
152152
153153 What this command does:
@@ -161,11 +161,11 @@ What this command does:
161161 If the route already exists and you only need to push a new image version, use:
162162
163163 ```bash
164-gordon push app.example.com --build --no-confirm
164+gordon push myapp --build --no-confirm
165165 ```
166166
167167 `gordon push` still requires the route to already exist so it can resolve the
168-target image.
168+deploy target.
169169
170170 Your app is now live at `https://app.mydomain.com`!
171171
@@ -147,7 +147,7 @@ gordon remotes use prod
147 gordon bootstrap app.example.com myapp:latest --attachment postgres:18 --env APP_ENV=production147 gordon bootstrap app.example.com myapp:latest --attachment postgres:18 --env APP_ENV=production
148 148
149 # Then build, push, and deploy149 # Then build, push, and deploy
150-gordon push app.example.com --build --no-confirm150+gordon push --domain app.example.com --build --no-confirm
151 ```151 ```
152 152
153 What this command does:153 What this command does:
@@ -161,11 +161,11 @@ What this command does:
161 If the route already exists and you only need to push a new image version, use:161 If the route already exists and you only need to push a new image version, use:
162 162
163 ```bash163 ```bash
164-gordon push app.example.com --build --no-confirm164+gordon push myapp --build --no-confirm
165 ```165 ```
166 166
167 `gordon push` still requires the route to already exist so it can resolve the167 `gordon push` still requires the route to already exist so it can resolve the
168-target image.168+deploy target.
169 169
170 Your app is now live at `https://app.mydomain.com`!170 Your app is now live at `https://app.mydomain.com`!
171 171
M internal/adapters/in/cli/push.go +105 -16
@@ -70,18 +70,21 @@ func newPushCmd() *cobra.Command {
7070 Long: `Tags a local image for the Gordon registry and pushes it.
7171 Uses git tags for versioning. Optionally triggers deployment after push.
7272
73-The image argument is optional. Resolution order:
74- 1. If --domain is provided, uses the legacy domain-based lookup
75- 2. If an image name is provided as argument, resolves domain(s) from the backend
76- 3. If no argument, auto-detects from Dockerfile labels or current directory name
73+Image-first syntax is primary:
74+ - Positional args resolve routes by image name first
75+ - Tagged positional refs still resolve routes by image name
76+ - The pushed version comes from --tag, CI tag refs, or git describe
77+ - Dotted positional refs fall back to legacy domain lookup if no image route exists
78+ - --domain is a deploy target override for legacy workflows
79+ - No-arg mode still auto-detects from Dockerfile labels or current directory name
7780
7881 With --build, builds the image first using docker buildx.
7982
8083 Examples:
81- gordon push --build --remote ...
8284 gordon push myapp --build --remote ...
85+ gordon push myapp:v1.2.3 --tag v1.2.3 --no-deploy --remote ...
86+ gordon push registry.example.com/myapp:v1.2.3 --build --remote ...
8387 gordon push --domain myapp.example.com --build --remote ...
84- gordon push myapp --tag v1.2.0 --no-deploy --remote ...
8588 gordon push --build --build-arg CGO_ENABLED=0 --remote ...`,
8689 Args: cobra.MaximumNArgs(1),
8790 RunE: func(cmd *cobra.Command, args []string) error {
@@ -105,7 +108,7 @@ Examples:
105108 cmd.Flags().BoolVar(&build, "build", false, "Build the image first using docker buildx")
106109 cmd.Flags().StringVar(&platform, "platform", "linux/amd64", "Target platform (used with --build)")
107110 cmd.Flags().StringVarP(&dockerfile, "file", "f", "", "Path to Dockerfile (default: ./Dockerfile, used with --build)")
108- cmd.Flags().StringVar(&tag, "tag", "", "Override version tag (default: CI tag ref or git describe)")
111+ cmd.Flags().StringVar(&tag, "tag", "", "Override pushed version tag (default: CI tag ref or git describe)")
109112 cmd.Flags().StringArrayVar(&buildArgs, "build-arg", nil, "Additional build args (used with --build)")
110113 cmd.Flags().StringVar(&domainFlag, "domain", "", "Explicit domain override (legacy mode)")
111114
@@ -128,6 +131,42 @@ func resolveImageRefs(registry, imageName, version string) (versionRef, latestRe
128131 return versionRef, latestRef
129132 }
130133
134+type pushArgKind string
135+
136+const (
137+ pushArgKindImage pushArgKind = "image"
138+ pushArgKindLegacyDomain pushArgKind = "legacy-domain"
139+)
140+
141+type classifiedPushArg struct {
142+ kind pushArgKind
143+ lookupImage string
144+ legacyDomain string
145+}
146+
147+func classifyPushArgument(arg string) classifiedPushArg {
148+ if looksLikeLegacyDomain(arg) {
149+ return classifiedPushArg{kind: pushArgKindLegacyDomain, legacyDomain: arg}
150+ }
151+
152+ classified := classifiedPushArg{kind: pushArgKindImage, lookupImage: arg}
153+ if parsedImage, ref := validation.ParseImageReference(arg); parsedImage != arg && !strings.HasPrefix(ref, "sha256:") {
154+ classified.lookupImage = parsedImage
155+ }
156+ return classified
157+}
158+
159+func looksLikeLegacyDomain(arg string) bool {
160+ if arg == "" || strings.Contains(arg, "/") {
161+ return false
162+ }
163+ host := arg
164+ if idx := strings.Index(host, ":"); idx != -1 {
165+ host = host[:idx]
166+ }
167+ return strings.Contains(host, ".")
168+}
169+
131170 // resolveRoute determines the registry, image name, and domain from the input mode.
132171 func resolveRoute(ctx context.Context, cp ControlPlane, imageArg, domainFlag, dockerfile string) (registry, imageName, pushDomain string, err error) {
133172 if domainFlag != "" {
@@ -142,7 +181,37 @@ func resolveRoute(ctx context.Context, cp ControlPlane, imageArg, domainFlag, do
142181 fmt.Printf("Detected image: %s\n", styles.Theme.Bold.Render(imageArg))
143182 }
144183
145- return resolveFromImage(ctx, cp, imageArg, dockerfile)
184+ if looksLikeLegacyDomain(imageArg) {
185+ lookupImage := imageArg
186+ domainLookupArg := imageArg
187+ if parsedImage, ref := validation.ParseImageReference(imageArg); parsedImage != imageArg {
188+ domainLookupArg = parsedImage
189+ if !strings.HasPrefix(ref, "sha256:") {
190+ lookupImage = parsedImage
191+ }
192+ }
193+
194+ registry, imageName, pushDomain, err = resolveFromImage(ctx, cp, lookupImage, dockerfile)
195+ if err == nil {
196+ return registry, imageName, pushDomain, nil
197+ }
198+ if !isImageBootstrapError(err) {
199+ return "", "", "", err
200+ }
201+
202+ registry, imageName, pushDomain, err = resolveFromDomain(ctx, cp, domainLookupArg)
203+ if err == nil {
204+ return registry, imageName, pushDomain, nil
205+ }
206+ if errors.Is(err, domain.ErrRouteNotFound) {
207+ return "", "", "", noRouteForImageError(domainLookupArg)
208+ }
209+ return "", "", "", err
210+ }
211+
212+ classified := classifyPushArgument(imageArg)
213+
214+ return resolveFromImage(ctx, cp, classified.lookupImage, dockerfile)
146215 }
147216
148217 // resolveVersion determines and validates the version tag.
@@ -287,14 +356,7 @@ func resolveFromImage(ctx context.Context, cp ControlPlane, imageArg, dockerfile
287356 routes = filtered
288357
289358 if len(routes) == 0 {
290- // bootstrap command is registered in bootstrap.go (see issue #98)
291- return "", "", "", fmt.Errorf(
292- "no route configured for image %q\n\nFor a first deploy, use 'gordon bootstrap <domain> %s'\nOr configure the route directly with 'gordon routes add <domain> %s'\nIf this is an attachment image, use 'gordon attachments push %s'",
293- imageArg,
294- imageArg,
295- imageArg,
296- imageArg,
297- )
359+ return "", "", "", noRouteForImageError(imageArg)
298360 }
299361
300362 // Pick the target domain
@@ -317,6 +379,33 @@ func resolveFromImage(ctx context.Context, cp ControlPlane, imageArg, dockerfile
317379 return registry, imageName, targetRoute.Domain, nil
318380 }
319381
382+func isImageBootstrapError(err error) bool {
383+ return errors.Is(err, domain.ErrNoRouteForImage)
384+}
385+
386+func noRouteForImageError(imageArg string) error {
387+ // bootstrap command is registered in bootstrap.go (see issue #98)
388+ return noRouteForImageBootstrapError{imageArg: imageArg}
389+}
390+
391+type noRouteForImageBootstrapError struct {
392+ imageArg string
393+}
394+
395+func (e noRouteForImageBootstrapError) Error() string {
396+ return fmt.Sprintf(
397+ "no route configured for image %q\n\nFor a first deploy, use 'gordon bootstrap <domain> %s'\nOr configure the route directly with 'gordon routes add <domain> %s'\nIf this is an attachment image, use 'gordon attachments push %s'",
398+ e.imageArg,
399+ e.imageArg,
400+ e.imageArg,
401+ e.imageArg,
402+ )
403+}
404+
405+func (e noRouteForImageBootstrapError) Unwrap() error {
406+ return domain.ErrNoRouteForImage
407+}
408+
320409 // selectDomain picks the target domain from multiple routes.
321410 // Checks Dockerfile labels first, then falls back to interactive selection.
322411 func selectDomain(routes []domain.Route, dockerfile string) (domain.Route, error) {
@@ -70,18 +70,21 @@ func newPushCmd() *cobra.Command {
70 Long: `Tags a local image for the Gordon registry and pushes it.70 Long: `Tags a local image for the Gordon registry and pushes it.
71 Uses git tags for versioning. Optionally triggers deployment after push.71 Uses git tags for versioning. Optionally triggers deployment after push.
72 72
73-The image argument is optional. Resolution order:73+Image-first syntax is primary:
74- 1. If --domain is provided, uses the legacy domain-based lookup74+ - Positional args resolve routes by image name first
75- 2. If an image name is provided as argument, resolves domain(s) from the backend75+ - Tagged positional refs still resolve routes by image name
76- 3. If no argument, auto-detects from Dockerfile labels or current directory name76+ - The pushed version comes from --tag, CI tag refs, or git describe
77+ - Dotted positional refs fall back to legacy domain lookup if no image route exists
78+ - --domain is a deploy target override for legacy workflows
79+ - No-arg mode still auto-detects from Dockerfile labels or current directory name
77 80
78 With --build, builds the image first using docker buildx.81 With --build, builds the image first using docker buildx.
79 82
80 Examples:83 Examples:
81- gordon push --build --remote ...
82 gordon push myapp --build --remote ...84 gordon push myapp --build --remote ...
85+ gordon push myapp:v1.2.3 --tag v1.2.3 --no-deploy --remote ...
86+ gordon push registry.example.com/myapp:v1.2.3 --build --remote ...
83 gordon push --domain myapp.example.com --build --remote ...87 gordon push --domain myapp.example.com --build --remote ...
84- gordon push myapp --tag v1.2.0 --no-deploy --remote ...
85 gordon push --build --build-arg CGO_ENABLED=0 --remote ...`,88 gordon push --build --build-arg CGO_ENABLED=0 --remote ...`,
86 Args: cobra.MaximumNArgs(1),89 Args: cobra.MaximumNArgs(1),
87 RunE: func(cmd *cobra.Command, args []string) error {90 RunE: func(cmd *cobra.Command, args []string) error {
@@ -105,7 +108,7 @@ Examples:
105 cmd.Flags().BoolVar(&build, "build", false, "Build the image first using docker buildx")108 cmd.Flags().BoolVar(&build, "build", false, "Build the image first using docker buildx")
106 cmd.Flags().StringVar(&platform, "platform", "linux/amd64", "Target platform (used with --build)")109 cmd.Flags().StringVar(&platform, "platform", "linux/amd64", "Target platform (used with --build)")
107 cmd.Flags().StringVarP(&dockerfile, "file", "f", "", "Path to Dockerfile (default: ./Dockerfile, used with --build)")110 cmd.Flags().StringVarP(&dockerfile, "file", "f", "", "Path to Dockerfile (default: ./Dockerfile, used with --build)")
108- cmd.Flags().StringVar(&tag, "tag", "", "Override version tag (default: CI tag ref or git describe)")111+ cmd.Flags().StringVar(&tag, "tag", "", "Override pushed version tag (default: CI tag ref or git describe)")
109 cmd.Flags().StringArrayVar(&buildArgs, "build-arg", nil, "Additional build args (used with --build)")112 cmd.Flags().StringArrayVar(&buildArgs, "build-arg", nil, "Additional build args (used with --build)")
110 cmd.Flags().StringVar(&domainFlag, "domain", "", "Explicit domain override (legacy mode)")113 cmd.Flags().StringVar(&domainFlag, "domain", "", "Explicit domain override (legacy mode)")
111 114
@@ -128,6 +131,42 @@ func resolveImageRefs(registry, imageName, version string) (versionRef, latestRe
128 return versionRef, latestRef131 return versionRef, latestRef
129 }132 }
130 133
134+type pushArgKind string
135+
136+const (
137+ pushArgKindImage pushArgKind = "image"
138+ pushArgKindLegacyDomain pushArgKind = "legacy-domain"
139+)
140+
141+type classifiedPushArg struct {
142+ kind pushArgKind
143+ lookupImage string
144+ legacyDomain string
145+}
146+
147+func classifyPushArgument(arg string) classifiedPushArg {
148+ if looksLikeLegacyDomain(arg) {
149+ return classifiedPushArg{kind: pushArgKindLegacyDomain, legacyDomain: arg}
150+ }
151+
152+ classified := classifiedPushArg{kind: pushArgKindImage, lookupImage: arg}
153+ if parsedImage, ref := validation.ParseImageReference(arg); parsedImage != arg && !strings.HasPrefix(ref, "sha256:") {
154+ classified.lookupImage = parsedImage
155+ }
156+ return classified
157+}
158+
159+func looksLikeLegacyDomain(arg string) bool {
160+ if arg == "" || strings.Contains(arg, "/") {
161+ return false
162+ }
163+ host := arg
164+ if idx := strings.Index(host, ":"); idx != -1 {
165+ host = host[:idx]
166+ }
167+ return strings.Contains(host, ".")
168+}
169+
131 // resolveRoute determines the registry, image name, and domain from the input mode.170 // resolveRoute determines the registry, image name, and domain from the input mode.
132 func resolveRoute(ctx context.Context, cp ControlPlane, imageArg, domainFlag, dockerfile string) (registry, imageName, pushDomain string, err error) {171 func resolveRoute(ctx context.Context, cp ControlPlane, imageArg, domainFlag, dockerfile string) (registry, imageName, pushDomain string, err error) {
133 if domainFlag != "" {172 if domainFlag != "" {
@@ -142,7 +181,37 @@ func resolveRoute(ctx context.Context, cp ControlPlane, imageArg, domainFlag, do
142 fmt.Printf("Detected image: %s\n", styles.Theme.Bold.Render(imageArg))181 fmt.Printf("Detected image: %s\n", styles.Theme.Bold.Render(imageArg))
143 }182 }
144 183
145- return resolveFromImage(ctx, cp, imageArg, dockerfile)184+ if looksLikeLegacyDomain(imageArg) {
185+ lookupImage := imageArg
186+ domainLookupArg := imageArg
187+ if parsedImage, ref := validation.ParseImageReference(imageArg); parsedImage != imageArg {
188+ domainLookupArg = parsedImage
189+ if !strings.HasPrefix(ref, "sha256:") {
190+ lookupImage = parsedImage
191+ }
192+ }
193+
194+ registry, imageName, pushDomain, err = resolveFromImage(ctx, cp, lookupImage, dockerfile)
195+ if err == nil {
196+ return registry, imageName, pushDomain, nil
197+ }
198+ if !isImageBootstrapError(err) {
199+ return "", "", "", err
200+ }
201+
202+ registry, imageName, pushDomain, err = resolveFromDomain(ctx, cp, domainLookupArg)
203+ if err == nil {
204+ return registry, imageName, pushDomain, nil
205+ }
206+ if errors.Is(err, domain.ErrRouteNotFound) {
207+ return "", "", "", noRouteForImageError(domainLookupArg)
208+ }
209+ return "", "", "", err
210+ }
211+
212+ classified := classifyPushArgument(imageArg)
213+
214+ return resolveFromImage(ctx, cp, classified.lookupImage, dockerfile)
146 }215 }
147 216
148 // resolveVersion determines and validates the version tag.217 // resolveVersion determines and validates the version tag.
@@ -287,14 +356,7 @@ func resolveFromImage(ctx context.Context, cp ControlPlane, imageArg, dockerfile
287 routes = filtered356 routes = filtered
288 357
289 if len(routes) == 0 {358 if len(routes) == 0 {
290- // bootstrap command is registered in bootstrap.go (see issue #98)359+ return "", "", "", noRouteForImageError(imageArg)
291- return "", "", "", fmt.Errorf(
292- "no route configured for image %q\n\nFor a first deploy, use 'gordon bootstrap <domain> %s'\nOr configure the route directly with 'gordon routes add <domain> %s'\nIf this is an attachment image, use 'gordon attachments push %s'",
293- imageArg,
294- imageArg,
295- imageArg,
296- imageArg,
297- )
298 }360 }
299 361
300 // Pick the target domain362 // Pick the target domain
@@ -317,6 +379,33 @@ func resolveFromImage(ctx context.Context, cp ControlPlane, imageArg, dockerfile
317 return registry, imageName, targetRoute.Domain, nil379 return registry, imageName, targetRoute.Domain, nil
318 }380 }
319 381
382+func isImageBootstrapError(err error) bool {
383+ return errors.Is(err, domain.ErrNoRouteForImage)
384+}
385+
386+func noRouteForImageError(imageArg string) error {
387+ // bootstrap command is registered in bootstrap.go (see issue #98)
388+ return noRouteForImageBootstrapError{imageArg: imageArg}
389+}
390+
391+type noRouteForImageBootstrapError struct {
392+ imageArg string
393+}
394+
395+func (e noRouteForImageBootstrapError) Error() string {
396+ return fmt.Sprintf(
397+ "no route configured for image %q\n\nFor a first deploy, use 'gordon bootstrap <domain> %s'\nOr configure the route directly with 'gordon routes add <domain> %s'\nIf this is an attachment image, use 'gordon attachments push %s'",
398+ e.imageArg,
399+ e.imageArg,
400+ e.imageArg,
401+ e.imageArg,
402+ )
403+}
404+
405+func (e noRouteForImageBootstrapError) Unwrap() error {
406+ return domain.ErrNoRouteForImage
407+}
408+
320 // selectDomain picks the target domain from multiple routes.409 // selectDomain picks the target domain from multiple routes.
321 // Checks Dockerfile labels first, then falls back to interactive selection.410 // Checks Dockerfile labels first, then falls back to interactive selection.
322 func selectDomain(routes []domain.Route, dockerfile string) (domain.Route, error) {411 func selectDomain(routes []domain.Route, dockerfile string) (domain.Route, error) {
M internal/adapters/in/cli/push_test.go +131 -1
@@ -2,6 +2,7 @@ package cli
22
33 import (
44 "context"
5+ "errors"
56 "fmt"
67 "io"
78 "os"
@@ -17,6 +18,7 @@ import (
1718 )
1819
1920 type resolveFromImageTestControlPlane struct {
21+ getRoute func(context.Context, string) (*domain.Route, error)
2022 findRoutesByImage func(context.Context, string) ([]domain.Route, error)
2123 }
2224
@@ -30,7 +32,10 @@ func (c *resolveFromImageTestControlPlane) GetHealth(context.Context) (map[strin
3032 panic("unexpected call")
3133 }
3234
33-func (c *resolveFromImageTestControlPlane) GetRoute(context.Context, string) (*domain.Route, error) {
35+func (c *resolveFromImageTestControlPlane) GetRoute(ctx context.Context, domainName string) (*domain.Route, error) {
36+ if c.getRoute != nil {
37+ return c.getRoute(ctx, domainName)
38+ }
3439 panic("unexpected call")
3540 }
3641
@@ -236,6 +241,28 @@ func TestParseImageRef(t *testing.T) {
236241 }
237242 }
238243
244+func TestClassifyPushArgument(t *testing.T) {
245+ tests := []struct {
246+ name string
247+ arg string
248+ want classifiedPushArg
249+ }{
250+ {name: "tagged image latest", arg: "myapp:latest", want: classifiedPushArg{kind: pushArgKindImage, lookupImage: "myapp"}},
251+ {name: "tagged image semver", arg: "myapp:v1.2.3", want: classifiedPushArg{kind: pushArgKindImage, lookupImage: "myapp"}},
252+ {name: "registry qualified image", arg: "registry.example.com/myapp:v1.2.3", want: classifiedPushArg{kind: pushArgKindImage, lookupImage: "registry.example.com/myapp"}},
253+ {name: "registry qualified digest", arg: "registry.example.com/myapp@sha256:deadbeef", want: classifiedPushArg{kind: pushArgKindImage, lookupImage: "registry.example.com/myapp@sha256:deadbeef"}},
254+ {name: "legacy domain", arg: "app.example.com", want: classifiedPushArg{kind: pushArgKindLegacyDomain, legacyDomain: "app.example.com"}},
255+ {name: "bare image name", arg: "myapp", want: classifiedPushArg{kind: pushArgKindImage, lookupImage: "myapp"}},
256+ }
257+
258+ for _, tt := range tests {
259+ t.Run(tt.name, func(t *testing.T) {
260+ got := classifyPushArgument(tt.arg)
261+ assert.Equal(t, tt.want, got)
262+ })
263+ }
264+}
265+
239266 func TestBuildAndPush_BuildArgs(t *testing.T) {
240267 // Verify buildImageArgs produces --load instead of --push
241268 args := buildImageArgs(context.Background(), "v1.0.0", "linux/amd64", "Dockerfile", []string{"CGO_ENABLED=0"}, "reg.example.com/app:v1.0.0", "reg.example.com/app:latest")
@@ -605,6 +632,109 @@ func TestResolveFromImage_NoRouteSuggestsBootstrap(t *testing.T) {
605632 assert.Contains(t, err.Error(), "gordon bootstrap")
606633 }
607634
635+func TestResolveRoute_DottedBareImageUsesImageLookup(t *testing.T) {
636+ cp := &resolveFromImageTestControlPlane{
637+ findRoutesByImage: func(_ context.Context, imageName string) ([]domain.Route, error) {
638+ assert.Equal(t, "my.app", imageName)
639+ return []domain.Route{{Domain: "app.example.com", Image: "registry.example.com/my.app:latest"}}, nil
640+ },
641+ getRoute: func(context.Context, string) (*domain.Route, error) {
642+ t.Fatalf("unexpected GetRoute call")
643+ return nil, nil
644+ },
645+ }
646+
647+ registry, imageName, pushDomain, err := resolveRoute(context.Background(), cp, "my.app", "", "Dockerfile")
648+
649+ assert.NoError(t, err)
650+ assert.Equal(t, "registry.example.com", registry)
651+ assert.Equal(t, "my.app", imageName)
652+ assert.Equal(t, "app.example.com", pushDomain)
653+}
654+
655+func TestResolveRoute_DottedBareImageNoRoutesKeepsBootstrapError(t *testing.T) {
656+ sawImageLookup := false
657+ cp := &resolveFromImageTestControlPlane{
658+ findRoutesByImage: func(_ context.Context, imageName string) ([]domain.Route, error) {
659+ sawImageLookup = true
660+ assert.Equal(t, "my.app", imageName)
661+ return nil, nil
662+ },
663+ getRoute: func(_ context.Context, domainName string) (*domain.Route, error) {
664+ assert.True(t, sawImageLookup, "expected image lookup before legacy domain lookup")
665+ assert.Equal(t, "my.app", domainName)
666+ return nil, domain.ErrRouteNotFound
667+ },
668+ }
669+
670+ _, _, _, err := resolveRoute(context.Background(), cp, "my.app", "", "Dockerfile")
671+
672+ assert.Error(t, err)
673+ assert.True(t, errors.Is(err, domain.ErrNoRouteForImage))
674+ assert.Contains(t, err.Error(), `no route configured for image "my.app"`)
675+ assert.Contains(t, err.Error(), "gordon bootstrap")
676+ assert.NotContains(t, err.Error(), "failed to get route for domain")
677+}
678+
679+func TestNoRouteForImageErrorWrapsSentinel(t *testing.T) {
680+ err := noRouteForImageError("myapp")
681+
682+ assert.True(t, errors.Is(err, domain.ErrNoRouteForImage))
683+ assert.Contains(t, err.Error(), `no route configured for image "myapp"`)
684+}
685+
686+func TestResolveRoute_DottedBareDomainFallsBackToLegacyLookup(t *testing.T) {
687+ sawImageLookup := false
688+ cp := &resolveFromImageTestControlPlane{
689+ findRoutesByImage: func(_ context.Context, imageName string) ([]domain.Route, error) {
690+ sawImageLookup = true
691+ assert.Equal(t, "app.example.com", imageName)
692+ return nil, nil
693+ },
694+ getRoute: func(_ context.Context, domainName string) (*domain.Route, error) {
695+ assert.True(t, sawImageLookup, "expected image lookup before legacy domain lookup")
696+ assert.Equal(t, "app.example.com", domainName)
697+ return &domain.Route{Domain: domainName, Image: "registry.example.com/myapp:latest"}, nil
698+ },
699+ }
700+
701+ registry, imageName, pushDomain, err := resolveRoute(context.Background(), cp, "app.example.com", "", "Dockerfile")
702+
703+ assert.NoError(t, err)
704+ assert.Equal(t, "registry.example.com", registry)
705+ assert.Equal(t, "myapp", imageName)
706+ assert.Equal(t, "app.example.com", pushDomain)
707+}
708+
709+func TestResolveRoute_TaggedImageStripsTagBeforeLookup(t *testing.T) {
710+ cp := &resolveFromImageTestControlPlane{
711+ findRoutesByImage: func(_ context.Context, imageName string) ([]domain.Route, error) {
712+ assert.Equal(t, "myapp", imageName)
713+ return []domain.Route{{Domain: "app.example.com", Image: "registry.example.com/myapp:latest"}}, nil
714+ },
715+ }
716+
717+ registry, imageName, pushDomain, err := resolveRoute(context.Background(), cp, "myapp:v1.2.3", "", "Dockerfile")
718+
719+ assert.NoError(t, err)
720+ assert.Equal(t, "registry.example.com", registry)
721+ assert.Equal(t, "myapp", imageName)
722+ assert.Equal(t, "app.example.com", pushDomain)
723+}
724+
725+func TestResolveRoute_RegistryQualifiedTaggedImageUsesImageLookup(t *testing.T) {
726+ cp := &resolveFromImageTestControlPlane{
727+ findRoutesByImage: func(_ context.Context, imageName string) ([]domain.Route, error) {
728+ assert.Equal(t, "registry.example.com/myapp", imageName)
729+ return []domain.Route{{Domain: "app.example.com", Image: "registry.example.com/myapp:latest"}}, nil
730+ },
731+ }
732+
733+ _, _, _, err := resolveRoute(context.Background(), cp, "registry.example.com/myapp:v1.2.3", "", "Dockerfile")
734+
735+ assert.NoError(t, err)
736+}
737+
608738 func TestIsInsufficientScope(t *testing.T) {
609739 tests := []struct {
610740 name string
@@ -2,6 +2,7 @@ package cli
2 2
3 import (3 import (
4 "context"4 "context"
5+ "errors"
5 "fmt"6 "fmt"
6 "io"7 "io"
7 "os"8 "os"
@@ -17,6 +18,7 @@ import (
17 )18 )
18 19
19 type resolveFromImageTestControlPlane struct {20 type resolveFromImageTestControlPlane struct {
21+ getRoute func(context.Context, string) (*domain.Route, error)
20 findRoutesByImage func(context.Context, string) ([]domain.Route, error)22 findRoutesByImage func(context.Context, string) ([]domain.Route, error)
21 }23 }
22 24
@@ -30,7 +32,10 @@ func (c *resolveFromImageTestControlPlane) GetHealth(context.Context) (map[strin
30 panic("unexpected call")32 panic("unexpected call")
31 }33 }
32 34
33-func (c *resolveFromImageTestControlPlane) GetRoute(context.Context, string) (*domain.Route, error) {35+func (c *resolveFromImageTestControlPlane) GetRoute(ctx context.Context, domainName string) (*domain.Route, error) {
36+ if c.getRoute != nil {
37+ return c.getRoute(ctx, domainName)
38+ }
34 panic("unexpected call")39 panic("unexpected call")
35 }40 }
36 41
@@ -236,6 +241,28 @@ func TestParseImageRef(t *testing.T) {
236 }241 }
237 }242 }
238 243
244+func TestClassifyPushArgument(t *testing.T) {
245+ tests := []struct {
246+ name string
247+ arg string
248+ want classifiedPushArg
249+ }{
250+ {name: "tagged image latest", arg: "myapp:latest", want: classifiedPushArg{kind: pushArgKindImage, lookupImage: "myapp"}},
251+ {name: "tagged image semver", arg: "myapp:v1.2.3", want: classifiedPushArg{kind: pushArgKindImage, lookupImage: "myapp"}},
252+ {name: "registry qualified image", arg: "registry.example.com/myapp:v1.2.3", want: classifiedPushArg{kind: pushArgKindImage, lookupImage: "registry.example.com/myapp"}},
253+ {name: "registry qualified digest", arg: "registry.example.com/myapp@sha256:deadbeef", want: classifiedPushArg{kind: pushArgKindImage, lookupImage: "registry.example.com/myapp@sha256:deadbeef"}},
254+ {name: "legacy domain", arg: "app.example.com", want: classifiedPushArg{kind: pushArgKindLegacyDomain, legacyDomain: "app.example.com"}},
255+ {name: "bare image name", arg: "myapp", want: classifiedPushArg{kind: pushArgKindImage, lookupImage: "myapp"}},
256+ }
257+
258+ for _, tt := range tests {
259+ t.Run(tt.name, func(t *testing.T) {
260+ got := classifyPushArgument(tt.arg)
261+ assert.Equal(t, tt.want, got)
262+ })
263+ }
264+}
265+
239 func TestBuildAndPush_BuildArgs(t *testing.T) {266 func TestBuildAndPush_BuildArgs(t *testing.T) {
240 // Verify buildImageArgs produces --load instead of --push267 // Verify buildImageArgs produces --load instead of --push
241 args := buildImageArgs(context.Background(), "v1.0.0", "linux/amd64", "Dockerfile", []string{"CGO_ENABLED=0"}, "reg.example.com/app:v1.0.0", "reg.example.com/app:latest")268 args := buildImageArgs(context.Background(), "v1.0.0", "linux/amd64", "Dockerfile", []string{"CGO_ENABLED=0"}, "reg.example.com/app:v1.0.0", "reg.example.com/app:latest")
@@ -605,6 +632,109 @@ func TestResolveFromImage_NoRouteSuggestsBootstrap(t *testing.T) {
605 assert.Contains(t, err.Error(), "gordon bootstrap")632 assert.Contains(t, err.Error(), "gordon bootstrap")
606 }633 }
607 634
635+func TestResolveRoute_DottedBareImageUsesImageLookup(t *testing.T) {
636+ cp := &resolveFromImageTestControlPlane{
637+ findRoutesByImage: func(_ context.Context, imageName string) ([]domain.Route, error) {
638+ assert.Equal(t, "my.app", imageName)
639+ return []domain.Route{{Domain: "app.example.com", Image: "registry.example.com/my.app:latest"}}, nil
640+ },
641+ getRoute: func(context.Context, string) (*domain.Route, error) {
642+ t.Fatalf("unexpected GetRoute call")
643+ return nil, nil
644+ },
645+ }
646+
647+ registry, imageName, pushDomain, err := resolveRoute(context.Background(), cp, "my.app", "", "Dockerfile")
648+
649+ assert.NoError(t, err)
650+ assert.Equal(t, "registry.example.com", registry)
651+ assert.Equal(t, "my.app", imageName)
652+ assert.Equal(t, "app.example.com", pushDomain)
653+}
654+
655+func TestResolveRoute_DottedBareImageNoRoutesKeepsBootstrapError(t *testing.T) {
656+ sawImageLookup := false
657+ cp := &resolveFromImageTestControlPlane{
658+ findRoutesByImage: func(_ context.Context, imageName string) ([]domain.Route, error) {
659+ sawImageLookup = true
660+ assert.Equal(t, "my.app", imageName)
661+ return nil, nil
662+ },
663+ getRoute: func(_ context.Context, domainName string) (*domain.Route, error) {
664+ assert.True(t, sawImageLookup, "expected image lookup before legacy domain lookup")
665+ assert.Equal(t, "my.app", domainName)
666+ return nil, domain.ErrRouteNotFound
667+ },
668+ }
669+
670+ _, _, _, err := resolveRoute(context.Background(), cp, "my.app", "", "Dockerfile")
671+
672+ assert.Error(t, err)
673+ assert.True(t, errors.Is(err, domain.ErrNoRouteForImage))
674+ assert.Contains(t, err.Error(), `no route configured for image "my.app"`)
675+ assert.Contains(t, err.Error(), "gordon bootstrap")
676+ assert.NotContains(t, err.Error(), "failed to get route for domain")
677+}
678+
679+func TestNoRouteForImageErrorWrapsSentinel(t *testing.T) {
680+ err := noRouteForImageError("myapp")
681+
682+ assert.True(t, errors.Is(err, domain.ErrNoRouteForImage))
683+ assert.Contains(t, err.Error(), `no route configured for image "myapp"`)
684+}
685+
686+func TestResolveRoute_DottedBareDomainFallsBackToLegacyLookup(t *testing.T) {
687+ sawImageLookup := false
688+ cp := &resolveFromImageTestControlPlane{
689+ findRoutesByImage: func(_ context.Context, imageName string) ([]domain.Route, error) {
690+ sawImageLookup = true
691+ assert.Equal(t, "app.example.com", imageName)
692+ return nil, nil
693+ },
694+ getRoute: func(_ context.Context, domainName string) (*domain.Route, error) {
695+ assert.True(t, sawImageLookup, "expected image lookup before legacy domain lookup")
696+ assert.Equal(t, "app.example.com", domainName)
697+ return &domain.Route{Domain: domainName, Image: "registry.example.com/myapp:latest"}, nil
698+ },
699+ }
700+
701+ registry, imageName, pushDomain, err := resolveRoute(context.Background(), cp, "app.example.com", "", "Dockerfile")
702+
703+ assert.NoError(t, err)
704+ assert.Equal(t, "registry.example.com", registry)
705+ assert.Equal(t, "myapp", imageName)
706+ assert.Equal(t, "app.example.com", pushDomain)
707+}
708+
709+func TestResolveRoute_TaggedImageStripsTagBeforeLookup(t *testing.T) {
710+ cp := &resolveFromImageTestControlPlane{
711+ findRoutesByImage: func(_ context.Context, imageName string) ([]domain.Route, error) {
712+ assert.Equal(t, "myapp", imageName)
713+ return []domain.Route{{Domain: "app.example.com", Image: "registry.example.com/myapp:latest"}}, nil
714+ },
715+ }
716+
717+ registry, imageName, pushDomain, err := resolveRoute(context.Background(), cp, "myapp:v1.2.3", "", "Dockerfile")
718+
719+ assert.NoError(t, err)
720+ assert.Equal(t, "registry.example.com", registry)
721+ assert.Equal(t, "myapp", imageName)
722+ assert.Equal(t, "app.example.com", pushDomain)
723+}
724+
725+func TestResolveRoute_RegistryQualifiedTaggedImageUsesImageLookup(t *testing.T) {
726+ cp := &resolveFromImageTestControlPlane{
727+ findRoutesByImage: func(_ context.Context, imageName string) ([]domain.Route, error) {
728+ assert.Equal(t, "registry.example.com/myapp", imageName)
729+ return []domain.Route{{Domain: "app.example.com", Image: "registry.example.com/myapp:latest"}}, nil
730+ },
731+ }
732+
733+ _, _, _, err := resolveRoute(context.Background(), cp, "registry.example.com/myapp:v1.2.3", "", "Dockerfile")
734+
735+ assert.NoError(t, err)
736+}
737+
608 func TestIsInsufficientScope(t *testing.T) {738 func TestIsInsufficientScope(t *testing.T) {
609 tests := []struct {739 tests := []struct {
610 name string740 name string
M internal/domain/errors.go +1 -0
@@ -21,6 +21,7 @@ var (
2121 ErrRouteNotFound = errors.New("route not found")
2222 ErrRouteExists = errors.New("route already exists")
2323 ErrInvalidRoute = errors.New("invalid route configuration")
24+ ErrNoRouteForImage = errors.New("no route configured for image")
2425 ErrRouteDomainEmpty = errors.New("route domain cannot be empty")
2526 ErrRouteImageEmpty = errors.New("route image cannot be empty")
2627 ErrRouteDomainInvalid = errors.New("route domain is not a valid public hostname")
@@ -21,6 +21,7 @@ var (
21 ErrRouteNotFound = errors.New("route not found")21 ErrRouteNotFound = errors.New("route not found")
22 ErrRouteExists = errors.New("route already exists")22 ErrRouteExists = errors.New("route already exists")
23 ErrInvalidRoute = errors.New("invalid route configuration")23 ErrInvalidRoute = errors.New("invalid route configuration")
24+ ErrNoRouteForImage = errors.New("no route configured for image")
24 ErrRouteDomainEmpty = errors.New("route domain cannot be empty")25 ErrRouteDomainEmpty = errors.New("route domain cannot be empty")
25 ErrRouteImageEmpty = errors.New("route image cannot be empty")26 ErrRouteImageEmpty = errors.New("route image cannot be empty")
26 ErrRouteDomainInvalid = errors.New("route domain is not a valid public hostname")27 ErrRouteDomainInvalid = errors.New("route domain is not a valid public hostname")

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