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 Unverified
B5690EEEBB952194The 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 | |||
| 32 | 32 | Build locally, push directly to your Gordon server: | |
| 33 | 33 | ||
| 34 | 34 | ```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 | |
| 37 | 37 | ||
| 38 | 38 | # Or add a route manually, then deploy | |
| 39 | 39 | 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 | 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 | 94 | | `gordon routes list` | List all routes | | |
| 95 | 95 | | `gordon routes add <domain> <image>` | Create or update a route | | |
| 96 | 96 | | `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 | ```bash | 34 | ```bash | ||
| 35 | - | # Push an image and deploy it to a domain | 35 | + | # Push using an explicit domain override |
| 36 | - | gordon push app.example.com --image myapp:latest | 36 | + | gordon push --domain app.example.com |
| 37 | 37 | ||||
| 38 | # Or add a route manually, then deploy | 38 | # Or add a route manually, then deploy | ||
| 39 | gordon routes add app.example.com myapp:latest | 39 | 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 | |||
| 255 | 255 | gordon attachments push pitlane-pgsql --build | |
| 256 | 256 | ||
| 257 | 257 | # 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 | |
| 259 | 259 | ``` | |
| 260 | 260 | ||
| 261 | 261 | ### CI/CD Integration | |
| @@ -255,7 +255,7 @@ gordon bootstrap app.example.com myapp:latest --attachment pitlane-pgsql | |||||
| 255 | gordon attachments push pitlane-pgsql --build | 255 | gordon attachments push pitlane-pgsql --build | ||
| 256 | 256 | ||||
| 257 | # Then push and deploy the route image | 257 | # 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 |
| 259 | ``` | 259 | ``` | ||
| 260 | 260 | ||||
| 261 | ### CI/CD Integration | 261 | ### 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 | |||
| 44 | 44 | gordon bootstrap app.example.com myapp:latest | |
| 45 | 45 | ||
| 46 | 46 | # 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 | |
| 48 | 48 | ||
| 49 | 49 | # First-time route setup with a database attachment and environment variable | |
| 50 | 50 | 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 | 63 | gordon attachments push pitlane-pgsql --build | |
| 64 | 64 | ||
| 65 | 65 | # Then push and deploy the route image | |
| 66 | - | gordon push app.example.com --build --no-confirm | |
| 66 | + | gordon push myapp --build --no-confirm | |
| 67 | 67 | ``` | |
| 68 | 68 | ||
| 69 | 69 | ### 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:latest | 44 | gordon bootstrap app.example.com myapp:latest | ||
| 45 | 45 | ||||
| 46 | # Then push and deploy the image | 46 | # 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 |
| 48 | 48 | ||||
| 49 | # First-time route setup with a database attachment and environment variable | 49 | # 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=production | 50 | 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 --build | 63 | gordon attachments push pitlane-pgsql --build | ||
| 64 | 64 | ||||
| 65 | # Then push and deploy the route image | 65 | # Then push and deploy the route image | ||
| 66 | - | gordon push app.example.com --build --no-confirm | 66 | + | gordon push myapp --build --no-confirm |
| 67 | ``` | 67 | ``` | ||
| 68 | 68 | ||||
| 69 | ### Notes | 69 | ### Notes | ||
M docs/cli/index.md +3 -3
| @@ -72,13 +72,13 @@ gordon restart myapp.example.com | |||
| 72 | 72 | gordon bootstrap app.example.com myapp:latest --attachment postgres:18 --env APP_ENV=production | |
| 73 | 73 | ||
| 74 | 74 | # Then push and deploy | |
| 75 | - | gordon push app.example.com --build --no-confirm | |
| 75 | + | gordon push --domain app.example.com --build --no-confirm | |
| 76 | 76 | ||
| 77 | 77 | # Push an image and deploy | |
| 78 | - | gordon push myapp.example.com --build | |
| 78 | + | gordon push myapp --build | |
| 79 | 79 | ||
| 80 | 80 | # Push and deploy without confirmation | |
| 81 | - | gordon push myapp.example.com --no-confirm | |
| 81 | + | gordon push myapp --no-confirm | |
| 82 | 82 | ||
| 83 | 83 | # Roll back to a previous tag | |
| 84 | 84 | 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=production | 72 | gordon bootstrap app.example.com myapp:latest --attachment postgres:18 --env APP_ENV=production | ||
| 73 | 73 | ||||
| 74 | # Then push and deploy | 74 | # Then push and deploy | ||
| 75 | - | gordon push app.example.com --build --no-confirm | 75 | + | gordon push --domain app.example.com --build --no-confirm |
| 76 | 76 | ||||
| 77 | # Push an image and deploy | 77 | # Push an image and deploy | ||
| 78 | - | gordon push myapp.example.com --build | 78 | + | gordon push myapp --build |
| 79 | 79 | ||||
| 80 | # Push and deploy without confirmation | 80 | # Push and deploy without confirmation | ||
| 81 | - | gordon push myapp.example.com --no-confirm | 81 | + | gordon push myapp --no-confirm |
| 82 | 82 | ||||
| 83 | # Roll back to a previous tag | 83 | # Roll back to a previous tag | ||
| 84 | gordon rollback myapp.example.com | 84 | gordon rollback myapp.example.com | ||
M docs/cli/push.md +23 -14
| @@ -24,22 +24,25 @@ gordon push [image] [options] | |||
| 24 | 24 | | `-f, --file` | Path to Dockerfile (default: `./Dockerfile`, used with `--build`) | | |
| 25 | 25 | | `--platform` | Target platform for buildx (default: `linux/amd64`) | | |
| 26 | 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 | 28 | | `--no-confirm` | Skip deploy confirmation prompt | | |
| 29 | 29 | | `--no-deploy` | Push only; skip deployment prompt | | |
| 30 | - | | `--domain` | Explicit domain override (legacy mode) | | |
| 30 | + | | `--domain` | Explicit deploy target override | | |
| 31 | 31 | | `--remote, -r` | Remote name or URL (e.g., prod, https://gordon.mydomain.com) | | |
| 32 | 32 | | `--token` | Authentication token for remote | | |
| 33 | 33 | ||
| 34 | 34 | ### Description | |
| 35 | 35 | ||
| 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. | |
| 38 | 38 | ||
| 39 | 39 | 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`. | |
| 43 | 46 | ||
| 44 | 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 | 96 | # Build, push, and deploy (auto-detect image name) | |
| 94 | 97 | gordon push --build --remote https://gordon.example.com --no-confirm | |
| 95 | 98 | ||
| 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 | |
| 98 | 107 | ||
| 99 | 108 | # Push existing local image, skip deploy | |
| 100 | 109 | 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 | 111 | # Build for ARM and pass build args | |
| 106 | 112 | gordon push myapp --build --platform linux/arm64 --build-arg CGO_ENABLED=0 | |
| 107 | 113 | ||
| 108 | 114 | # Build from a custom Dockerfile path | |
| 109 | 115 | 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 | 120 | # CI/CD usage (single env var, no docker login needed) | |
| 112 | 121 | 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 | |
| 114 | 123 | ``` | |
| 115 | 124 | ||
| 116 | 125 | ### Notes | |
| 117 | 126 | ||
| 118 | 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 | 129 | Use `gordon bootstrap` for first deploys. | |
| 121 | 130 | - `--build` requires Docker with Buildx. Docker Desktop includes it; on Linux, | |
| 122 | 131 | 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 | ### Description | 34 | ### Description | ||
| 35 | 35 | ||||
| 36 | - | `gordon push` resolves the target image from the backend, tags it for the | 36 | + | `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 lookup | 40 | + | 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 backend | 41 | + | 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 name | 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`. | |||
| 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-confirm | 97 | gordon push --build --remote https://gordon.example.com --no-confirm | ||
| 95 | 98 | ||||
| 96 | - | # Specify image name explicitly | 99 | + | # Push an image and deploy it |
| 97 | - | gordon push myapp --build --remote https://gordon.example.com --no-confirm | 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 | |||
| 98 | 107 | ||||
| 99 | # Push existing local image, skip deploy | 108 | # Push existing local image, skip deploy | ||
| 100 | gordon push myapp --tag v1.2.0 --no-deploy | 109 | 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 args | 111 | # Build for ARM and pass build args | ||
| 106 | gordon push myapp --build --platform linux/arm64 --build-arg CGO_ENABLED=0 | 112 | gordon push myapp --build --platform linux/arm64 --build-arg CGO_ENABLED=0 | ||
| 107 | 113 | ||||
| 108 | # Build from a custom Dockerfile path | 114 | # Build from a custom Dockerfile path | ||
| 109 | gordon push myapp --build -f docker/app/Dockerfile | 115 | 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-confirm | 122 | + | gordon push myapp --build --remote https://gordon.example.com --no-confirm |
| 114 | ``` | 123 | ``` | ||
| 115 | 124 | ||||
| 116 | ### Notes | 125 | ### 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 | |||
| 147 | 147 | gordon bootstrap app.example.com myapp:latest --attachment postgres:18 --env APP_ENV=production | |
| 148 | 148 | ||
| 149 | 149 | # Then build, push, and deploy | |
| 150 | - | gordon push app.example.com --build --no-confirm | |
| 150 | + | gordon push --domain app.example.com --build --no-confirm | |
| 151 | 151 | ``` | |
| 152 | 152 | ||
| 153 | 153 | What this command does: | |
| @@ -161,11 +161,11 @@ What this command does: | |||
| 161 | 161 | If the route already exists and you only need to push a new image version, use: | |
| 162 | 162 | ||
| 163 | 163 | ```bash | |
| 164 | - | gordon push app.example.com --build --no-confirm | |
| 164 | + | gordon push myapp --build --no-confirm | |
| 165 | 165 | ``` | |
| 166 | 166 | ||
| 167 | 167 | `gordon push` still requires the route to already exist so it can resolve the | |
| 168 | - | target image. | |
| 168 | + | deploy target. | |
| 169 | 169 | ||
| 170 | 170 | Your app is now live at `https://app.mydomain.com`! | |
| 171 | 171 | ||
| @@ -147,7 +147,7 @@ gordon remotes use prod | |||||
| 147 | gordon bootstrap app.example.com myapp:latest --attachment postgres:18 --env APP_ENV=production | 147 | gordon bootstrap app.example.com myapp:latest --attachment postgres:18 --env APP_ENV=production | ||
| 148 | 148 | ||||
| 149 | # Then build, push, and deploy | 149 | # Then build, push, and deploy | ||
| 150 | - | gordon push app.example.com --build --no-confirm | 150 | + | 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 | ```bash | 163 | ```bash | ||
| 164 | - | gordon push app.example.com --build --no-confirm | 164 | + | gordon push myapp --build --no-confirm |
| 165 | ``` | 165 | ``` | ||
| 166 | 166 | ||||
| 167 | `gordon push` still requires the route to already exist so it can resolve the | 167 | `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 { | |||
| 70 | 70 | Long: `Tags a local image for the Gordon registry and pushes it. | |
| 71 | 71 | Uses git tags for versioning. Optionally triggers deployment after push. | |
| 72 | 72 | ||
| 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 | |
| 77 | 80 | ||
| 78 | 81 | With --build, builds the image first using docker buildx. | |
| 79 | 82 | ||
| 80 | 83 | Examples: | |
| 81 | - | gordon push --build --remote ... | |
| 82 | 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 | 87 | gordon push --domain myapp.example.com --build --remote ... | |
| 84 | - | gordon push myapp --tag v1.2.0 --no-deploy --remote ... | |
| 85 | 88 | gordon push --build --build-arg CGO_ENABLED=0 --remote ...`, | |
| 86 | 89 | Args: cobra.MaximumNArgs(1), | |
| 87 | 90 | RunE: func(cmd *cobra.Command, args []string) error { | |
| @@ -105,7 +108,7 @@ Examples: | |||
| 105 | 108 | cmd.Flags().BoolVar(&build, "build", false, "Build the image first using docker buildx") | |
| 106 | 109 | cmd.Flags().StringVar(&platform, "platform", "linux/amd64", "Target platform (used with --build)") | |
| 107 | 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 | 112 | cmd.Flags().StringArrayVar(&buildArgs, "build-arg", nil, "Additional build args (used with --build)") | |
| 110 | 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 | 131 | 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 | 170 | // resolveRoute determines the registry, image name, and domain from the input mode. | |
| 132 | 171 | func resolveRoute(ctx context.Context, cp ControlPlane, imageArg, domainFlag, dockerfile string) (registry, imageName, pushDomain string, err error) { | |
| 133 | 172 | if domainFlag != "" { | |
| @@ -142,7 +181,37 @@ func resolveRoute(ctx context.Context, cp ControlPlane, imageArg, domainFlag, do | |||
| 142 | 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 | 217 | // resolveVersion determines and validates the version tag. | |
| @@ -287,14 +356,7 @@ func resolveFromImage(ctx context.Context, cp ControlPlane, imageArg, dockerfile | |||
| 287 | 356 | routes = filtered | |
| 288 | 357 | ||
| 289 | 358 | 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) | |
| 298 | 360 | } | |
| 299 | 361 | ||
| 300 | 362 | // Pick the target domain | |
| @@ -317,6 +379,33 @@ func resolveFromImage(ctx context.Context, cp ControlPlane, imageArg, dockerfile | |||
| 317 | 379 | 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 | 409 | // selectDomain picks the target domain from multiple routes. | |
| 321 | 410 | // Checks Dockerfile labels first, then falls back to interactive selection. | |
| 322 | 411 | 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 lookup | 74 | + | - Positional args resolve routes by image name first |
| 75 | - | 2. If an image name is provided as argument, resolves domain(s) from the backend | 75 | + | - Tagged positional refs still resolve routes by image name |
| 76 | - | 3. If no argument, auto-detects from Dockerfile labels or current directory 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 | |||
| 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, latestRef | 131 | 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 = filtered | 356 | 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 domain | 362 | // Pick the target domain | ||
| @@ -317,6 +379,33 @@ func resolveFromImage(ctx context.Context, cp ControlPlane, imageArg, dockerfile | |||||
| 317 | return registry, imageName, targetRoute.Domain, nil | 379 | 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 | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | + | "errors" | |
| 5 | 6 | "fmt" | |
| 6 | 7 | "io" | |
| 7 | 8 | "os" | |
| @@ -17,6 +18,7 @@ import ( | |||
| 17 | 18 | ) | |
| 18 | 19 | ||
| 19 | 20 | type resolveFromImageTestControlPlane struct { | |
| 21 | + | getRoute func(context.Context, string) (*domain.Route, error) | |
| 20 | 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 | 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 | 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 | 266 | func TestBuildAndPush_BuildArgs(t *testing.T) { | |
| 240 | 267 | // Verify buildImageArgs produces --load instead of --push | |
| 241 | 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 | 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 | 738 | func TestIsInsufficientScope(t *testing.T) { | |
| 609 | 739 | tests := []struct { | |
| 610 | 740 | 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 --push | 267 | // 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 string | 740 | name string | ||
M internal/domain/errors.go +1 -0
| @@ -21,6 +21,7 @@ var ( | |||
| 21 | 21 | ErrRouteNotFound = errors.New("route not found") | |
| 22 | 22 | ErrRouteExists = errors.New("route already exists") | |
| 23 | 23 | ErrInvalidRoute = errors.New("invalid route configuration") | |
| 24 | + | ErrNoRouteForImage = errors.New("no route configured for image") | |
| 24 | 25 | ErrRouteDomainEmpty = errors.New("route domain cannot be empty") | |
| 25 | 26 | ErrRouteImageEmpty = errors.New("route image cannot be empty") | |
| 26 | 27 | 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") | ||