Merge pull request #132 from bnema/feature/push-token-auth
feat(push): auto-exchange token for registry auth in remote mode
18 files · +1532 -214
A .github/actions/setup-gordon/action.yml +78 -0
| @@ -0,0 +1,78 @@ | |||
| 1 | + | name: 'Setup Gordon' | |
| 2 | + | description: 'Install the Gordon CLI binary into the runner PATH' | |
| 3 | + | author: 'Gordon Contributors' | |
| 4 | + | ||
| 5 | + | branding: | |
| 6 | + | icon: 'download-cloud' | |
| 7 | + | color: 'blue' | |
| 8 | + | ||
| 9 | + | inputs: | |
| 10 | + | version: | |
| 11 | + | description: 'Gordon version to install (e.g. v2.0.0 or latest)' | |
| 12 | + | required: false | |
| 13 | + | default: 'latest' | |
| 14 | + | ||
| 15 | + | outputs: | |
| 16 | + | version: | |
| 17 | + | description: 'The version of Gordon that was installed' | |
| 18 | + | value: ${{ steps.install.outputs.version }} | |
| 19 | + | path: | |
| 20 | + | description: 'Path to the installed Gordon binary' | |
| 21 | + | value: ${{ steps.install.outputs.path }} | |
| 22 | + | ||
| 23 | + | runs: | |
| 24 | + | using: 'composite' | |
| 25 | + | steps: | |
| 26 | + | - name: Install Gordon | |
| 27 | + | id: install | |
| 28 | + | shell: bash | |
| 29 | + | run: | | |
| 30 | + | set -euo pipefail | |
| 31 | + | ||
| 32 | + | # Map RUNNER_OS to gordon OS identifier | |
| 33 | + | case "${RUNNER_OS}" in | |
| 34 | + | Linux) os="linux" ;; | |
| 35 | + | macOS) os="darwin" ;; | |
| 36 | + | *) echo "Unsupported OS: ${RUNNER_OS}" >&2; exit 1 ;; | |
| 37 | + | esac | |
| 38 | + | ||
| 39 | + | # Map RUNNER_ARCH to gordon arch identifier | |
| 40 | + | case "${RUNNER_ARCH}" in | |
| 41 | + | X64) arch="amd64" ;; | |
| 42 | + | ARM64) arch="arm64" ;; | |
| 43 | + | *) echo "Unsupported architecture: ${RUNNER_ARCH}" >&2; exit 1 ;; | |
| 44 | + | esac | |
| 45 | + | ||
| 46 | + | version="${{ inputs.version }}" | |
| 47 | + | ||
| 48 | + | # Resolve 'latest' to an actual tag via redirect URL | |
| 49 | + | if [ "${version}" = "latest" ]; then | |
| 50 | + | redirect_url="$(curl -fsSL -o /dev/null -w '%{redirect_url}' "https://github.com/bnema/gordon/releases/latest")" | |
| 51 | + | version="${redirect_url##*/}" | |
| 52 | + | if [ -z "${version}" ]; then | |
| 53 | + | echo "Failed to resolve latest Gordon version" >&2 | |
| 54 | + | exit 1 | |
| 55 | + | fi | |
| 56 | + | fi | |
| 57 | + | ||
| 58 | + | archive="gordon_${os}_${arch}.tar.gz" | |
| 59 | + | url="https://github.com/bnema/gordon/releases/download/${version}/${archive}" | |
| 60 | + | dest="/tmp/${archive}" | |
| 61 | + | install_path="/usr/local/bin/gordon" | |
| 62 | + | ||
| 63 | + | echo "Installing Gordon ${version} (${os}/${arch}) from ${url}" | |
| 64 | + | ||
| 65 | + | # Download the release archive | |
| 66 | + | curl -fsSL -o "${dest}" "${url}" | |
| 67 | + | ||
| 68 | + | # Extract just the gordon binary and clean up | |
| 69 | + | tar -xzf "${dest}" -C /usr/local/bin/ gordon | |
| 70 | + | rm -f "${dest}" | |
| 71 | + | ||
| 72 | + | # Make executable and verify | |
| 73 | + | chmod +x "${install_path}" | |
| 74 | + | gordon version | |
| 75 | + | ||
| 76 | + | # Set outputs | |
| 77 | + | echo "version=${version}" >> "${GITHUB_OUTPUT}" | |
| 78 | + | echo "path=${install_path}" >> "${GITHUB_OUTPUT}" | |
| @@ -0,0 +1,78 @@ | |||||
| 1 | + | name: 'Setup Gordon' | |||
| 2 | + | description: 'Install the Gordon CLI binary into the runner PATH' | |||
| 3 | + | author: 'Gordon Contributors' | |||
| 4 | + | ||||
| 5 | + | branding: | |||
| 6 | + | icon: 'download-cloud' | |||
| 7 | + | color: 'blue' | |||
| 8 | + | ||||
| 9 | + | inputs: | |||
| 10 | + | version: | |||
| 11 | + | description: 'Gordon version to install (e.g. v2.0.0 or latest)' | |||
| 12 | + | required: false | |||
| 13 | + | default: 'latest' | |||
| 14 | + | ||||
| 15 | + | outputs: | |||
| 16 | + | version: | |||
| 17 | + | description: 'The version of Gordon that was installed' | |||
| 18 | + | value: ${{ steps.install.outputs.version }} | |||
| 19 | + | path: | |||
| 20 | + | description: 'Path to the installed Gordon binary' | |||
| 21 | + | value: ${{ steps.install.outputs.path }} | |||
| 22 | + | ||||
| 23 | + | runs: | |||
| 24 | + | using: 'composite' | |||
| 25 | + | steps: | |||
| 26 | + | - name: Install Gordon | |||
| 27 | + | id: install | |||
| 28 | + | shell: bash | |||
| 29 | + | run: | | |||
| 30 | + | set -euo pipefail | |||
| 31 | + | ||||
| 32 | + | # Map RUNNER_OS to gordon OS identifier | |||
| 33 | + | case "${RUNNER_OS}" in | |||
| 34 | + | Linux) os="linux" ;; | |||
| 35 | + | macOS) os="darwin" ;; | |||
| 36 | + | *) echo "Unsupported OS: ${RUNNER_OS}" >&2; exit 1 ;; | |||
| 37 | + | esac | |||
| 38 | + | ||||
| 39 | + | # Map RUNNER_ARCH to gordon arch identifier | |||
| 40 | + | case "${RUNNER_ARCH}" in | |||
| 41 | + | X64) arch="amd64" ;; | |||
| 42 | + | ARM64) arch="arm64" ;; | |||
| 43 | + | *) echo "Unsupported architecture: ${RUNNER_ARCH}" >&2; exit 1 ;; | |||
| 44 | + | esac | |||
| 45 | + | ||||
| 46 | + | version="${{ inputs.version }}" | |||
| 47 | + | ||||
| 48 | + | # Resolve 'latest' to an actual tag via redirect URL | |||
| 49 | + | if [ "${version}" = "latest" ]; then | |||
| 50 | + | redirect_url="$(curl -fsSL -o /dev/null -w '%{redirect_url}' "https://github.com/bnema/gordon/releases/latest")" | |||
| 51 | + | version="${redirect_url##*/}" | |||
| 52 | + | if [ -z "${version}" ]; then | |||
| 53 | + | echo "Failed to resolve latest Gordon version" >&2 | |||
| 54 | + | exit 1 | |||
| 55 | + | fi | |||
| 56 | + | fi | |||
| 57 | + | ||||
| 58 | + | archive="gordon_${os}_${arch}.tar.gz" | |||
| 59 | + | url="https://github.com/bnema/gordon/releases/download/${version}/${archive}" | |||
| 60 | + | dest="/tmp/${archive}" | |||
| 61 | + | install_path="/usr/local/bin/gordon" | |||
| 62 | + | ||||
| 63 | + | echo "Installing Gordon ${version} (${os}/${arch}) from ${url}" | |||
| 64 | + | ||||
| 65 | + | # Download the release archive | |||
| 66 | + | curl -fsSL -o "${dest}" "${url}" | |||
| 67 | + | ||||
| 68 | + | # Extract just the gordon binary and clean up | |||
| 69 | + | tar -xzf "${dest}" -C /usr/local/bin/ gordon | |||
| 70 | + | rm -f "${dest}" | |||
| 71 | + | ||||
| 72 | + | # Make executable and verify | |||
| 73 | + | chmod +x "${install_path}" | |||
| 74 | + | gordon version | |||
| 75 | + | ||||
| 76 | + | # Set outputs | |||
| 77 | + | echo "version=${version}" >> "${GITHUB_OUTPUT}" | |||
| 78 | + | echo "path=${install_path}" >> "${GITHUB_OUTPUT}" | |||
M docs/cli/push.md +62 -12
| @@ -7,14 +7,14 @@ Tag, push, and optionally deploy an image to the Gordon registry. | |||
| 7 | 7 | ### Synopsis | |
| 8 | 8 | ||
| 9 | 9 | ```bash | |
| 10 | - | gordon push <domain> [options] | |
| 10 | + | gordon push [image] [options] | |
| 11 | 11 | ``` | |
| 12 | 12 | ||
| 13 | 13 | ### Arguments | |
| 14 | 14 | ||
| 15 | 15 | | Argument | Description | | |
| 16 | 16 | |----------|-------------| | |
| 17 | - | | `<domain>` | The route domain to resolve the registry and repository from | | |
| 17 | + | | `[image]` | Image name to push (optional). If omitted, auto-detected from Dockerfile labels or current directory name | | |
| 18 | 18 | ||
| 19 | 19 | ### Options | |
| 20 | 20 | ||
| @@ -27,14 +27,20 @@ gordon push <domain> [options] | |||
| 27 | 27 | | `--tag` | Override 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 | 31 | | `--remote` | Remote Gordon URL | | |
| 31 | 32 | | `--token` | Authentication token for remote | | |
| 32 | 33 | ||
| 33 | 34 | ### Description | |
| 34 | 35 | ||
| 35 | - | `gordon push` resolves the route image for `<domain>`, tags the image for the | |
| 36 | + | `gordon push` resolves the target image from the backend, tags it for the | |
| 36 | 37 | Gordon registry, pushes it, and optionally deploys it. | |
| 37 | 38 | ||
| 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 | |
| 43 | + | ||
| 38 | 44 | To push attachment images (databases, caches, etc.), use `gordon attachments push`. | |
| 39 | 45 | ||
| 40 | 46 | - For first deploys, run `gordon bootstrap` first to create or update the route, attachments, and secrets, then run `gordon push` to upload and deploy the image. | |
| @@ -43,33 +49,75 @@ To push attachment images (databases, caches, etc.), use `gordon attachments pus | |||
| 43 | 49 | then falls back to `git describe --tags --dirty` (for example | |
| 44 | 50 | `v1.2.3-4-gabc1234` or `v1.2.3-dirty`). If no tag is found, `latest` is used. | |
| 45 | 51 | - When `--build` is set, the command builds with `docker buildx build --load` | |
| 46 | - | and injects `VERSION` into the build environment plus any `--build-arg` values. | |
| 52 | + | and injects `VERSION`, `GIT_TAG`, `GIT_SHA`, and `BUILD_TIME` into the build | |
| 53 | + | environment plus any `--build-arg` values. To use these in your Dockerfile, | |
| 54 | + | declare them with `ARG` (e.g., `ARG VERSION`) then reference via `ENV` or | |
| 55 | + | in build steps. | |
| 47 | 56 | - Use `-f/--file` to build from a Dockerfile outside the current directory root. | |
| 48 | 57 | - The version tag and `latest` are both pushed (unless the version is `latest`). | |
| 49 | 58 | ||
| 59 | + | ### Authentication | |
| 60 | + | ||
| 61 | + | When used with `--remote`, gordon push authenticates in two ways: | |
| 62 | + | ||
| 63 | + | - **Admin API** (route resolution, deploy): uses `--token` or `$GORDON_TOKEN` as Bearer token | |
| 64 | + | - **Registry push**: automatically exchanges the token for a short-lived (5 min) registry access token via `/auth/token` -- no `docker login` required | |
| 65 | + | ||
| 66 | + | This means CI/CD pipelines only need a single secret (`GORDON_TOKEN`). | |
| 67 | + | ||
| 68 | + | ### Deploy Modes | |
| 69 | + | ||
| 70 | + | When the token has `admin:config:write` scope, the CLI manages deployment explicitly | |
| 71 | + | (DeployIntent → push → Deploy). This gives the CLI control over deploy timing. | |
| 72 | + | ||
| 73 | + | When the token lacks `admin:config:write`, the CLI pushes the image and the server | |
| 74 | + | auto-deploys when it receives it via its event listener. The CLI logs: | |
| 75 | + | `info: deploy intent skipped (insufficient scope), server will auto-deploy on image receive` | |
| 76 | + | ||
| 77 | + | ### Version Auto-Detection | |
| 78 | + | ||
| 79 | + | Gordon reads version tags from CI environment variables (in priority order): | |
| 80 | + | ||
| 81 | + | | CI System | Variable | Example | | |
| 82 | + | |-----------|----------|---------| | |
| 83 | + | | GitHub Actions | `$GITHUB_REF` | `refs/tags/v1.2.0` | | |
| 84 | + | | GitHub Actions | `$GITHUB_REF_TYPE` + `$GITHUB_REF_NAME` | `tag` + `v1.2.0` | | |
| 85 | + | | GitLab CI | `$CI_COMMIT_TAG` | `v1.2.0` | | |
| 86 | + | | Azure DevOps | `$BUILD_SOURCEBRANCH` | `refs/tags/v1.2.0` | | |
| 87 | + | | Any | `git describe --tags` | `v1.2.3-4-gabc1234` | | |
| 88 | + | | Fallback | - | `latest` | | |
| 89 | + | ||
| 50 | 90 | ### Examples | |
| 51 | 91 | ||
| 52 | 92 | ```bash | |
| 53 | - | # Build, push, and deploy | |
| 54 | - | gordon push myapp.example.com --build | |
| 93 | + | # Build, push, and deploy (auto-detect image name) | |
| 94 | + | gordon push --build --remote https://gordon.example.com --no-confirm | |
| 95 | + | ||
| 96 | + | # Specify image name explicitly | |
| 97 | + | gordon push myapp --build --remote https://gordon.example.com --no-confirm | |
| 55 | 98 | ||
| 56 | 99 | # Push existing local image, skip deploy | |
| 57 | - | gordon push myapp.example.com --tag v1.2.0 --no-deploy | |
| 100 | + | gordon push myapp --tag v1.2.0 --no-deploy | |
| 58 | 101 | ||
| 59 | 102 | # Push and deploy without confirmation | |
| 60 | - | gordon push myapp.example.com --no-confirm | |
| 103 | + | gordon push myapp --no-confirm | |
| 61 | 104 | ||
| 62 | 105 | # Build for ARM and pass build args | |
| 63 | - | gordon push myapp.example.com --build --platform linux/arm64 --build-arg CGO_ENABLED=0 | |
| 106 | + | gordon push myapp --build --platform linux/arm64 --build-arg CGO_ENABLED=0 | |
| 64 | 107 | ||
| 65 | 108 | # Build from a custom Dockerfile path | |
| 66 | - | gordon push myapp.example.com --build -f docker/app/Dockerfile | |
| 109 | + | gordon push myapp --build -f docker/app/Dockerfile | |
| 110 | + | ||
| 111 | + | # CI/CD usage (single env var, no docker login needed) | |
| 112 | + | export GORDON_TOKEN="your-token" | |
| 113 | + | gordon push --build --remote https://gordon.example.com --no-confirm | |
| 67 | 114 | ``` | |
| 68 | 115 | ||
| 69 | 116 | ### Notes | |
| 70 | 117 | ||
| 71 | 118 | - Remote mode required. See [CLI Overview](./index.md) for targeting options. | |
| 72 | - | - `gordon push` still requires the route to already exist so it can resolve the target image. | |
| 119 | + | - `gordon push` requires the route to already exist so it can resolve the target image. | |
| 120 | + | Use `gordon bootstrap` for first deploys. | |
| 73 | 121 | - `--build` requires Docker with Buildx. Docker Desktop includes it; on Linux, | |
| 74 | 122 | install the `docker-buildx-plugin` package. | |
| 75 | 123 | - Gordon uses native registry uploads instead of shelling out to `docker push`. | |
| @@ -81,6 +129,8 @@ gordon push myapp.example.com --build -f docker/app/Dockerfile | |||
| 81 | 129 | ## Related | |
| 82 | 130 | ||
| 83 | 131 | - [CLI Overview](./index.md) | |
| 132 | + | - [Deployment Overview](../deployment/index.md) | |
| 133 | + | - [GitHub Actions](../deployment/github-actions.md) | |
| 84 | 134 | - [Attachments Commands](./attachments.md) | |
| 85 | 135 | - [Routes Command](./routes.md) | |
| 86 | - | - [Serve Command](./serve.md) | |
| 136 | + | - [Authentication](../config/auth.md) | |
| @@ -7,14 +7,14 @@ Tag, push, and optionally deploy an image to the Gordon registry. | |||||
| 7 | ### Synopsis | 7 | ### Synopsis | ||
| 8 | 8 | ||||
| 9 | ```bash | 9 | ```bash | ||
| 10 | - | gordon push <domain> [options] | 10 | + | gordon push [image] [options] |
| 11 | ``` | 11 | ``` | ||
| 12 | 12 | ||||
| 13 | ### Arguments | 13 | ### Arguments | ||
| 14 | 14 | ||||
| 15 | | Argument | Description | | 15 | | Argument | Description | | ||
| 16 | |----------|-------------| | 16 | |----------|-------------| | ||
| 17 | - | | `<domain>` | The route domain to resolve the registry and repository from | | 17 | + | | `[image]` | Image name to push (optional). If omitted, auto-detected from Dockerfile labels or current directory name | |
| 18 | 18 | ||||
| 19 | ### Options | 19 | ### Options | ||
| 20 | 20 | ||||
| @@ -27,14 +27,20 @@ gordon push <domain> [options] | |||||
| 27 | | `--tag` | Override version tag (default: tag ref from CI, then `git describe --tags --dirty`) | | 27 | | `--tag` | Override 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 | | `--remote` | Remote Gordon URL | | 31 | | `--remote` | Remote Gordon URL | | ||
| 31 | | `--token` | Authentication token for remote | | 32 | | `--token` | Authentication token for remote | | ||
| 32 | 33 | ||||
| 33 | ### Description | 34 | ### Description | ||
| 34 | 35 | ||||
| 35 | - | `gordon push` resolves the route image for `<domain>`, tags the image for the | 36 | + | `gordon push` resolves the target image from the backend, tags it for the |
| 36 | Gordon registry, pushes it, and optionally deploys it. | 37 | Gordon registry, pushes it, and optionally deploys it. | ||
| 37 | 38 | ||||
| 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 | |||
| 43 | + | ||||
| 38 | To push attachment images (databases, caches, etc.), use `gordon attachments push`. | 44 | To push attachment images (databases, caches, etc.), use `gordon attachments push`. | ||
| 39 | 45 | ||||
| 40 | - For first deploys, run `gordon bootstrap` first to create or update the route, attachments, and secrets, then run `gordon push` to upload and deploy the image. | 46 | - For first deploys, run `gordon bootstrap` first to create or update the route, attachments, and secrets, then run `gordon push` to upload and deploy the image. | ||
| @@ -43,33 +49,75 @@ To push attachment images (databases, caches, etc.), use `gordon attachments pus | |||||
| 43 | then falls back to `git describe --tags --dirty` (for example | 49 | then falls back to `git describe --tags --dirty` (for example | ||
| 44 | `v1.2.3-4-gabc1234` or `v1.2.3-dirty`). If no tag is found, `latest` is used. | 50 | `v1.2.3-4-gabc1234` or `v1.2.3-dirty`). If no tag is found, `latest` is used. | ||
| 45 | - When `--build` is set, the command builds with `docker buildx build --load` | 51 | - When `--build` is set, the command builds with `docker buildx build --load` | ||
| 46 | - | and injects `VERSION` into the build environment plus any `--build-arg` values. | 52 | + | and injects `VERSION`, `GIT_TAG`, `GIT_SHA`, and `BUILD_TIME` into the build |
| 53 | + | environment plus any `--build-arg` values. To use these in your Dockerfile, | |||
| 54 | + | declare them with `ARG` (e.g., `ARG VERSION`) then reference via `ENV` or | |||
| 55 | + | in build steps. | |||
| 47 | - Use `-f/--file` to build from a Dockerfile outside the current directory root. | 56 | - Use `-f/--file` to build from a Dockerfile outside the current directory root. | ||
| 48 | - The version tag and `latest` are both pushed (unless the version is `latest`). | 57 | - The version tag and `latest` are both pushed (unless the version is `latest`). | ||
| 49 | 58 | ||||
| 59 | + | ### Authentication | |||
| 60 | + | ||||
| 61 | + | When used with `--remote`, gordon push authenticates in two ways: | |||
| 62 | + | ||||
| 63 | + | - **Admin API** (route resolution, deploy): uses `--token` or `$GORDON_TOKEN` as Bearer token | |||
| 64 | + | - **Registry push**: automatically exchanges the token for a short-lived (5 min) registry access token via `/auth/token` -- no `docker login` required | |||
| 65 | + | ||||
| 66 | + | This means CI/CD pipelines only need a single secret (`GORDON_TOKEN`). | |||
| 67 | + | ||||
| 68 | + | ### Deploy Modes | |||
| 69 | + | ||||
| 70 | + | When the token has `admin:config:write` scope, the CLI manages deployment explicitly | |||
| 71 | + | (DeployIntent → push → Deploy). This gives the CLI control over deploy timing. | |||
| 72 | + | ||||
| 73 | + | When the token lacks `admin:config:write`, the CLI pushes the image and the server | |||
| 74 | + | auto-deploys when it receives it via its event listener. The CLI logs: | |||
| 75 | + | `info: deploy intent skipped (insufficient scope), server will auto-deploy on image receive` | |||
| 76 | + | ||||
| 77 | + | ### Version Auto-Detection | |||
| 78 | + | ||||
| 79 | + | Gordon reads version tags from CI environment variables (in priority order): | |||
| 80 | + | ||||
| 81 | + | | CI System | Variable | Example | | |||
| 82 | + | |-----------|----------|---------| | |||
| 83 | + | | GitHub Actions | `$GITHUB_REF` | `refs/tags/v1.2.0` | | |||
| 84 | + | | GitHub Actions | `$GITHUB_REF_TYPE` + `$GITHUB_REF_NAME` | `tag` + `v1.2.0` | | |||
| 85 | + | | GitLab CI | `$CI_COMMIT_TAG` | `v1.2.0` | | |||
| 86 | + | | Azure DevOps | `$BUILD_SOURCEBRANCH` | `refs/tags/v1.2.0` | | |||
| 87 | + | | Any | `git describe --tags` | `v1.2.3-4-gabc1234` | | |||
| 88 | + | | Fallback | - | `latest` | | |||
| 89 | + | ||||
| 50 | ### Examples | 90 | ### Examples | ||
| 51 | 91 | ||||
| 52 | ```bash | 92 | ```bash | ||
| 53 | - | # Build, push, and deploy | 93 | + | # Build, push, and deploy (auto-detect image name) |
| 54 | - | gordon push myapp.example.com --build | 94 | + | gordon push --build --remote https://gordon.example.com --no-confirm |
| 95 | + | ||||
| 96 | + | # Specify image name explicitly | |||
| 97 | + | gordon push myapp --build --remote https://gordon.example.com --no-confirm | |||
| 55 | 98 | ||||
| 56 | # Push existing local image, skip deploy | 99 | # Push existing local image, skip deploy | ||
| 57 | - | gordon push myapp.example.com --tag v1.2.0 --no-deploy | 100 | + | gordon push myapp --tag v1.2.0 --no-deploy |
| 58 | 101 | ||||
| 59 | # Push and deploy without confirmation | 102 | # Push and deploy without confirmation | ||
| 60 | - | gordon push myapp.example.com --no-confirm | 103 | + | gordon push myapp --no-confirm |
| 61 | 104 | ||||
| 62 | # Build for ARM and pass build args | 105 | # Build for ARM and pass build args | ||
| 63 | - | gordon push myapp.example.com --build --platform linux/arm64 --build-arg CGO_ENABLED=0 | 106 | + | gordon push myapp --build --platform linux/arm64 --build-arg CGO_ENABLED=0 |
| 64 | 107 | ||||
| 65 | # Build from a custom Dockerfile path | 108 | # Build from a custom Dockerfile path | ||
| 66 | - | gordon push myapp.example.com --build -f docker/app/Dockerfile | 109 | + | gordon push myapp --build -f docker/app/Dockerfile |
| 110 | + | ||||
| 111 | + | # CI/CD usage (single env var, no docker login needed) | |||
| 112 | + | export GORDON_TOKEN="your-token" | |||
| 113 | + | gordon push --build --remote https://gordon.example.com --no-confirm | |||
| 67 | ``` | 114 | ``` | ||
| 68 | 115 | ||||
| 69 | ### Notes | 116 | ### Notes | ||
| 70 | 117 | ||||
| 71 | - Remote mode required. See [CLI Overview](./index.md) for targeting options. | 118 | - Remote mode required. See [CLI Overview](./index.md) for targeting options. | ||
| 72 | - | - `gordon push` still requires the route to already exist so it can resolve the target image. | 119 | + | - `gordon push` requires the route to already exist so it can resolve the target image. |
| 120 | + | Use `gordon bootstrap` for first deploys. | |||
| 73 | - `--build` requires Docker with Buildx. Docker Desktop includes it; on Linux, | 121 | - `--build` requires Docker with Buildx. Docker Desktop includes it; on Linux, | ||
| 74 | install the `docker-buildx-plugin` package. | 122 | install the `docker-buildx-plugin` package. | ||
| 75 | - Gordon uses native registry uploads instead of shelling out to `docker push`. | 123 | - Gordon uses native registry uploads instead of shelling out to `docker push`. | ||
| @@ -81,6 +129,8 @@ gordon push myapp.example.com --build -f docker/app/Dockerfile | |||||
| 81 | ## Related | 129 | ## Related | ||
| 82 | 130 | ||||
| 83 | - [CLI Overview](./index.md) | 131 | - [CLI Overview](./index.md) | ||
| 132 | + | - [Deployment Overview](../deployment/index.md) | |||
| 133 | + | - [GitHub Actions](../deployment/github-actions.md) | |||
| 84 | - [Attachments Commands](./attachments.md) | 134 | - [Attachments Commands](./attachments.md) | ||
| 85 | - [Routes Command](./routes.md) | 135 | - [Routes Command](./routes.md) | ||
| 86 | - | - [Serve Command](./serve.md) | 136 | + | - [Authentication](../config/auth.md) |
A docs/deployment/generic-ci.md +203 -0
| @@ -0,0 +1,203 @@ | |||
| 1 | + | # Generic CI/CD Deployment | |
| 2 | + | ||
| 3 | + | Deploy with Gordon from any CI/CD system. | |
| 4 | + | ||
| 5 | + | ## Requirements | |
| 6 | + | ||
| 7 | + | - Docker available on CI runner (for building images) | |
| 8 | + | - Network access to your Gordon server (HTTPS) | |
| 9 | + | - Gordon binary (optional but recommended) | |
| 10 | + | ||
| 11 | + | ## Recommended: gordon push | |
| 12 | + | ||
| 13 | + | ### 1. Generate Deployment Token | |
| 14 | + | ||
| 15 | + | On your Gordon server: | |
| 16 | + | ||
| 17 | + | ```bash | |
| 18 | + | gordon auth token generate \ | |
| 19 | + | --subject ci-deploy \ | |
| 20 | + | --scopes "push,pull,admin:routes:read,admin:config:write" \ | |
| 21 | + | --expiry 0 | |
| 22 | + | ``` | |
| 23 | + | ||
| 24 | + | ### 2. Configure CI Secrets | |
| 25 | + | ||
| 26 | + | Add these secrets to your CI system: | |
| 27 | + | ||
| 28 | + | | Secret | Value | | |
| 29 | + | |--------|-------| | |
| 30 | + | | `GORDON_TOKEN` | The generated token | | |
| 31 | + | | `GORDON_REMOTE` | `https://gordon.example.com` | | |
| 32 | + | ||
| 33 | + | ### 3. Pipeline Steps | |
| 34 | + | ||
| 35 | + | ```bash | |
| 36 | + | # 1. Install Gordon binary | |
| 37 | + | curl -fsSL https://github.com/bnema/gordon/releases/latest/download/gordon_linux_amd64 \ | |
| 38 | + | -o /usr/local/bin/gordon | |
| 39 | + | chmod +x /usr/local/bin/gordon | |
| 40 | + | ||
| 41 | + | # 2. Build, push, and deploy | |
| 42 | + | export GORDON_TOKEN="$GORDON_TOKEN" | |
| 43 | + | gordon push --build \ | |
| 44 | + | --remote "$GORDON_REMOTE" \ | |
| 45 | + | --no-confirm | |
| 46 | + | ``` | |
| 47 | + | ||
| 48 | + | Gordon auto-detects the version from CI environment variables: | |
| 49 | + | ||
| 50 | + | | CI System | Variable | Example | | |
| 51 | + | |-----------|----------|---------| | |
| 52 | + | | GitHub Actions | `$GITHUB_REF` | `refs/tags/v1.2.0` | | |
| 53 | + | | GitLab CI | `$CI_COMMIT_TAG` | `v1.2.0` | | |
| 54 | + | | Azure DevOps | `$BUILD_SOURCEBRANCH` | `refs/tags/v1.2.0` | | |
| 55 | + | | Other | `--tag` flag | `gordon push --tag v1.2.0` | | |
| 56 | + | ||
| 57 | + | ## Alternative: docker push | |
| 58 | + | ||
| 59 | + | If installing the Gordon binary is not possible: | |
| 60 | + | ||
| 61 | + | ```bash | |
| 62 | + | # 1. Login to Gordon registry | |
| 63 | + | echo "$GORDON_TOKEN" | docker login gordon.example.com \ | |
| 64 | + | -u ci-deploy --password-stdin | |
| 65 | + | ||
| 66 | + | # 2. Build and push | |
| 67 | + | docker build -t gordon.example.com/myapp:v1.0.0 . | |
| 68 | + | docker push gordon.example.com/myapp:v1.0.0 | |
| 69 | + | ``` | |
| 70 | + | ||
| 71 | + | This requires the token subject (`ci-deploy`) as the username. | |
| 72 | + | Gordon auto-deploys when it receives the image. | |
| 73 | + | ||
| 74 | + | ## CI System Examples | |
| 75 | + | ||
| 76 | + | ### Jenkins | |
| 77 | + | ||
| 78 | + | ```groovy | |
| 79 | + | pipeline { | |
| 80 | + | agent any | |
| 81 | + | environment { | |
| 82 | + | GORDON_TOKEN = credentials('gordon-token') | |
| 83 | + | GORDON_REMOTE = 'https://gordon.example.com' | |
| 84 | + | } | |
| 85 | + | stages { | |
| 86 | + | stage('Deploy') { | |
| 87 | + | steps { | |
| 88 | + | sh ''' | |
| 89 | + | curl -fsSL https://github.com/bnema/gordon/releases/latest/download/gordon_linux_amd64 \ | |
| 90 | + | -o /usr/local/bin/gordon | |
| 91 | + | chmod +x /usr/local/bin/gordon | |
| 92 | + | gordon push --build --remote "$GORDON_REMOTE" --no-confirm | |
| 93 | + | ''' | |
| 94 | + | } | |
| 95 | + | } | |
| 96 | + | } | |
| 97 | + | } | |
| 98 | + | ``` | |
| 99 | + | ||
| 100 | + | ### CircleCI | |
| 101 | + | ||
| 102 | + | ```yaml | |
| 103 | + | version: 2.1 | |
| 104 | + | ||
| 105 | + | jobs: | |
| 106 | + | deploy: | |
| 107 | + | docker: | |
| 108 | + | - image: docker:24 | |
| 109 | + | steps: | |
| 110 | + | - checkout | |
| 111 | + | - setup_remote_docker | |
| 112 | + | - run: | |
| 113 | + | name: Install Gordon | |
| 114 | + | command: | | |
| 115 | + | apk add --no-cache curl | |
| 116 | + | curl -fsSL https://github.com/bnema/gordon/releases/latest/download/gordon_linux_amd64 \ | |
| 117 | + | -o /usr/local/bin/gordon | |
| 118 | + | chmod +x /usr/local/bin/gordon | |
| 119 | + | - run: | |
| 120 | + | name: Deploy | |
| 121 | + | command: | | |
| 122 | + | gordon push --build \ | |
| 123 | + | --remote "$GORDON_REMOTE" \ | |
| 124 | + | --no-confirm | |
| 125 | + | ||
| 126 | + | workflows: | |
| 127 | + | deploy: | |
| 128 | + | jobs: | |
| 129 | + | - deploy: | |
| 130 | + | filters: | |
| 131 | + | tags: | |
| 132 | + | only: /^v.*/ | |
| 133 | + | branches: | |
| 134 | + | ignore: /.*/ | |
| 135 | + | ``` | |
| 136 | + | ||
| 137 | + | ### Drone | |
| 138 | + | ||
| 139 | + | ```yaml | |
| 140 | + | kind: pipeline | |
| 141 | + | type: docker | |
| 142 | + | name: deploy | |
| 143 | + | ||
| 144 | + | steps: | |
| 145 | + | - name: deploy | |
| 146 | + | image: docker:24 | |
| 147 | + | environment: | |
| 148 | + | GORDON_TOKEN: | |
| 149 | + | from_secret: gordon_token | |
| 150 | + | GORDON_REMOTE: | |
| 151 | + | from_secret: gordon_remote | |
| 152 | + | commands: | |
| 153 | + | - apk add --no-cache curl | |
| 154 | + | - curl -fsSL https://github.com/bnema/gordon/releases/latest/download/gordon_linux_amd64 -o /usr/local/bin/gordon | |
| 155 | + | - chmod +x /usr/local/bin/gordon | |
| 156 | + | - gordon push --build --remote "$GORDON_REMOTE" --no-confirm | |
| 157 | + | ||
| 158 | + | trigger: | |
| 159 | + | event: | |
| 160 | + | - tag | |
| 161 | + | ``` | |
| 162 | + | ||
| 163 | + | ## Token Scopes Reference | |
| 164 | + | ||
| 165 | + | | Workflow | Required Scopes | | |
| 166 | + | |----------|----------------| | |
| 167 | + | | Build + push + deploy | `push,pull,admin:routes:read,admin:config:write` | | |
| 168 | + | | Push only (no deploy) | `push,pull,admin:routes:read` | | |
| 169 | + | | docker push (auto-deploy) | `push,pull` | | |
| 170 | + | ||
| 171 | + | ## Troubleshooting | |
| 172 | + | ||
| 173 | + | ### "failed to verify token" | |
| 174 | + | ||
| 175 | + | The token is invalid or has been revoked. Generate a new one. | |
| 176 | + | ||
| 177 | + | ### "no route configured for image" | |
| 178 | + | ||
| 179 | + | The route must exist before pushing. Create it with: | |
| 180 | + | ||
| 181 | + | ```bash | |
| 182 | + | gordon routes add myapp.example.com myapp | |
| 183 | + | # or for first deploy: | |
| 184 | + | gordon bootstrap myapp.example.com myapp | |
| 185 | + | ``` | |
| 186 | + | ||
| 187 | + | ### Version shows "latest" | |
| 188 | + | ||
| 189 | + | Gordon could not detect a version tag. Either: | |
| 190 | + | - Tag your git repo: `git tag v1.0.0 && git push --tags` | |
| 191 | + | - Pass explicitly: `gordon push --tag v1.0.0` | |
| 192 | + | ||
| 193 | + | ### Large images time out | |
| 194 | + | ||
| 195 | + | Gordon uploads in 50MB chunks. For very large images (> 1GB), the push may take several minutes. Ensure your CI runner timeout is sufficient. | |
| 196 | + | ||
| 197 | + | ## Related | |
| 198 | + | ||
| 199 | + | - [GitHub Actions](./github-actions.md) | |
| 200 | + | - [GitLab CI](./gitlab-ci.md) | |
| 201 | + | - [Deployment Overview](./index.md) | |
| 202 | + | - [Authentication](../config/auth.md) | |
| 203 | + | - [Push Command](../cli/push.md) | |
| @@ -0,0 +1,203 @@ | |||||
| 1 | + | # Generic CI/CD Deployment | |||
| 2 | + | ||||
| 3 | + | Deploy with Gordon from any CI/CD system. | |||
| 4 | + | ||||
| 5 | + | ## Requirements | |||
| 6 | + | ||||
| 7 | + | - Docker available on CI runner (for building images) | |||
| 8 | + | - Network access to your Gordon server (HTTPS) | |||
| 9 | + | - Gordon binary (optional but recommended) | |||
| 10 | + | ||||
| 11 | + | ## Recommended: gordon push | |||
| 12 | + | ||||
| 13 | + | ### 1. Generate Deployment Token | |||
| 14 | + | ||||
| 15 | + | On your Gordon server: | |||
| 16 | + | ||||
| 17 | + | ```bash | |||
| 18 | + | gordon auth token generate \ | |||
| 19 | + | --subject ci-deploy \ | |||
| 20 | + | --scopes "push,pull,admin:routes:read,admin:config:write" \ | |||
| 21 | + | --expiry 0 | |||
| 22 | + | ``` | |||
| 23 | + | ||||
| 24 | + | ### 2. Configure CI Secrets | |||
| 25 | + | ||||
| 26 | + | Add these secrets to your CI system: | |||
| 27 | + | ||||
| 28 | + | | Secret | Value | | |||
| 29 | + | |--------|-------| | |||
| 30 | + | | `GORDON_TOKEN` | The generated token | | |||
| 31 | + | | `GORDON_REMOTE` | `https://gordon.example.com` | | |||
| 32 | + | ||||
| 33 | + | ### 3. Pipeline Steps | |||
| 34 | + | ||||
| 35 | + | ```bash | |||
| 36 | + | # 1. Install Gordon binary | |||
| 37 | + | curl -fsSL https://github.com/bnema/gordon/releases/latest/download/gordon_linux_amd64 \ | |||
| 38 | + | -o /usr/local/bin/gordon | |||
| 39 | + | chmod +x /usr/local/bin/gordon | |||
| 40 | + | ||||
| 41 | + | # 2. Build, push, and deploy | |||
| 42 | + | export GORDON_TOKEN="$GORDON_TOKEN" | |||
| 43 | + | gordon push --build \ | |||
| 44 | + | --remote "$GORDON_REMOTE" \ | |||
| 45 | + | --no-confirm | |||
| 46 | + | ``` | |||
| 47 | + | ||||
| 48 | + | Gordon auto-detects the version from CI environment variables: | |||
| 49 | + | ||||
| 50 | + | | CI System | Variable | Example | | |||
| 51 | + | |-----------|----------|---------| | |||
| 52 | + | | GitHub Actions | `$GITHUB_REF` | `refs/tags/v1.2.0` | | |||
| 53 | + | | GitLab CI | `$CI_COMMIT_TAG` | `v1.2.0` | | |||
| 54 | + | | Azure DevOps | `$BUILD_SOURCEBRANCH` | `refs/tags/v1.2.0` | | |||
| 55 | + | | Other | `--tag` flag | `gordon push --tag v1.2.0` | | |||
| 56 | + | ||||
| 57 | + | ## Alternative: docker push | |||
| 58 | + | ||||
| 59 | + | If installing the Gordon binary is not possible: | |||
| 60 | + | ||||
| 61 | + | ```bash | |||
| 62 | + | # 1. Login to Gordon registry | |||
| 63 | + | echo "$GORDON_TOKEN" | docker login gordon.example.com \ | |||
| 64 | + | -u ci-deploy --password-stdin | |||
| 65 | + | ||||
| 66 | + | # 2. Build and push | |||
| 67 | + | docker build -t gordon.example.com/myapp:v1.0.0 . | |||
| 68 | + | docker push gordon.example.com/myapp:v1.0.0 | |||
| 69 | + | ``` | |||
| 70 | + | ||||
| 71 | + | This requires the token subject (`ci-deploy`) as the username. | |||
| 72 | + | Gordon auto-deploys when it receives the image. | |||
| 73 | + | ||||
| 74 | + | ## CI System Examples | |||
| 75 | + | ||||
| 76 | + | ### Jenkins | |||
| 77 | + | ||||
| 78 | + | ```groovy | |||
| 79 | + | pipeline { | |||
| 80 | + | agent any | |||
| 81 | + | environment { | |||
| 82 | + | GORDON_TOKEN = credentials('gordon-token') | |||
| 83 | + | GORDON_REMOTE = 'https://gordon.example.com' | |||
| 84 | + | } | |||
| 85 | + | stages { | |||
| 86 | + | stage('Deploy') { | |||
| 87 | + | steps { | |||
| 88 | + | sh ''' | |||
| 89 | + | curl -fsSL https://github.com/bnema/gordon/releases/latest/download/gordon_linux_amd64 \ | |||
| 90 | + | -o /usr/local/bin/gordon | |||
| 91 | + | chmod +x /usr/local/bin/gordon | |||
| 92 | + | gordon push --build --remote "$GORDON_REMOTE" --no-confirm | |||
| 93 | + | ''' | |||
| 94 | + | } | |||
| 95 | + | } | |||
| 96 | + | } | |||
| 97 | + | } | |||
| 98 | + | ``` | |||
| 99 | + | ||||
| 100 | + | ### CircleCI | |||
| 101 | + | ||||
| 102 | + | ```yaml | |||
| 103 | + | version: 2.1 | |||
| 104 | + | ||||
| 105 | + | jobs: | |||
| 106 | + | deploy: | |||
| 107 | + | docker: | |||
| 108 | + | - image: docker:24 | |||
| 109 | + | steps: | |||
| 110 | + | - checkout | |||
| 111 | + | - setup_remote_docker | |||
| 112 | + | - run: | |||
| 113 | + | name: Install Gordon | |||
| 114 | + | command: | | |||
| 115 | + | apk add --no-cache curl | |||
| 116 | + | curl -fsSL https://github.com/bnema/gordon/releases/latest/download/gordon_linux_amd64 \ | |||
| 117 | + | -o /usr/local/bin/gordon | |||
| 118 | + | chmod +x /usr/local/bin/gordon | |||
| 119 | + | - run: | |||
| 120 | + | name: Deploy | |||
| 121 | + | command: | | |||
| 122 | + | gordon push --build \ | |||
| 123 | + | --remote "$GORDON_REMOTE" \ | |||
| 124 | + | --no-confirm | |||
| 125 | + | ||||
| 126 | + | workflows: | |||
| 127 | + | deploy: | |||
| 128 | + | jobs: | |||
| 129 | + | - deploy: | |||
| 130 | + | filters: | |||
| 131 | + | tags: | |||
| 132 | + | only: /^v.*/ | |||
| 133 | + | branches: | |||
| 134 | + | ignore: /.*/ | |||
| 135 | + | ``` | |||
| 136 | + | ||||
| 137 | + | ### Drone | |||
| 138 | + | ||||
| 139 | + | ```yaml | |||
| 140 | + | kind: pipeline | |||
| 141 | + | type: docker | |||
| 142 | + | name: deploy | |||
| 143 | + | ||||
| 144 | + | steps: | |||
| 145 | + | - name: deploy | |||
| 146 | + | image: docker:24 | |||
| 147 | + | environment: | |||
| 148 | + | GORDON_TOKEN: | |||
| 149 | + | from_secret: gordon_token | |||
| 150 | + | GORDON_REMOTE: | |||
| 151 | + | from_secret: gordon_remote | |||
| 152 | + | commands: | |||
| 153 | + | - apk add --no-cache curl | |||
| 154 | + | - curl -fsSL https://github.com/bnema/gordon/releases/latest/download/gordon_linux_amd64 -o /usr/local/bin/gordon | |||
| 155 | + | - chmod +x /usr/local/bin/gordon | |||
| 156 | + | - gordon push --build --remote "$GORDON_REMOTE" --no-confirm | |||
| 157 | + | ||||
| 158 | + | trigger: | |||
| 159 | + | event: | |||
| 160 | + | - tag | |||
| 161 | + | ``` | |||
| 162 | + | ||||
| 163 | + | ## Token Scopes Reference | |||
| 164 | + | ||||
| 165 | + | | Workflow | Required Scopes | | |||
| 166 | + | |----------|----------------| | |||
| 167 | + | | Build + push + deploy | `push,pull,admin:routes:read,admin:config:write` | | |||
| 168 | + | | Push only (no deploy) | `push,pull,admin:routes:read` | | |||
| 169 | + | | docker push (auto-deploy) | `push,pull` | | |||
| 170 | + | ||||
| 171 | + | ## Troubleshooting | |||
| 172 | + | ||||
| 173 | + | ### "failed to verify token" | |||
| 174 | + | ||||
| 175 | + | The token is invalid or has been revoked. Generate a new one. | |||
| 176 | + | ||||
| 177 | + | ### "no route configured for image" | |||
| 178 | + | ||||
| 179 | + | The route must exist before pushing. Create it with: | |||
| 180 | + | ||||
| 181 | + | ```bash | |||
| 182 | + | gordon routes add myapp.example.com myapp | |||
| 183 | + | # or for first deploy: | |||
| 184 | + | gordon bootstrap myapp.example.com myapp | |||
| 185 | + | ``` | |||
| 186 | + | ||||
| 187 | + | ### Version shows "latest" | |||
| 188 | + | ||||
| 189 | + | Gordon could not detect a version tag. Either: | |||
| 190 | + | - Tag your git repo: `git tag v1.0.0 && git push --tags` | |||
| 191 | + | - Pass explicitly: `gordon push --tag v1.0.0` | |||
| 192 | + | ||||
| 193 | + | ### Large images time out | |||
| 194 | + | ||||
| 195 | + | Gordon uploads in 50MB chunks. For very large images (> 1GB), the push may take several minutes. Ensure your CI runner timeout is sufficient. | |||
| 196 | + | ||||
| 197 | + | ## Related | |||
| 198 | + | ||||
| 199 | + | - [GitHub Actions](./github-actions.md) | |||
| 200 | + | - [GitLab CI](./gitlab-ci.md) | |||
| 201 | + | - [Deployment Overview](./index.md) | |||
| 202 | + | - [Authentication](../config/auth.md) | |||
| 203 | + | - [Push Command](../cli/push.md) | |||
M docs/deployment/github-actions.md +252 -52
| @@ -1,21 +1,26 @@ | |||
| 1 | 1 | # GitHub Actions Deployment | |
| 2 | 2 | ||
| 3 | - | Automated deployment with GitHub Actions using Gordon's official action. | |
| 3 | + | Two approaches for deploying with GitHub Actions: the `gordon push` CLI (recommended) or a Docker-based workflow using Gordon's official action. | |
| 4 | 4 | ||
| 5 | 5 | ## Prerequisites | |
| 6 | 6 | ||
| 7 | 7 | 1. Gordon server running with registry authentication enabled | |
| 8 | - | 2. Generated deployment token | |
| 8 | + | 2. Deployment token generated with the required scopes | |
| 9 | 9 | 3. GitHub repository secrets configured | |
| 10 | 10 | ||
| 11 | - | ## Quick Setup | |
| 11 | + | ## Recommended: gordon push | |
| 12 | 12 | ||
| 13 | - | ### 1. Generate Deployment Token | |
| 13 | + | Use the `gordon push` CLI for a lightweight, single-step build and deploy. | |
| 14 | + | ||
| 15 | + | ### 1. Generate Token | |
| 14 | 16 | ||
| 15 | 17 | On your Gordon server: | |
| 16 | 18 | ||
| 17 | 19 | ```bash | |
| 18 | - | gordon auth token generate --subject github-actions --scopes push,pull --expiry 0 | |
| 20 | + | gordon auth token generate \ | |
| 21 | + | --subject github-actions \ | |
| 22 | + | --scopes "push,pull,admin:routes:read,admin:config:write" \ | |
| 23 | + | --expiry 0 | |
| 19 | 24 | ``` | |
| 20 | 25 | ||
| 21 | 26 | Save the token output. | |
| @@ -26,70 +31,238 @@ In your repository: Settings → Secrets → Actions → New repository secret | |||
| 26 | 31 | ||
| 27 | 32 | | Secret | Value | | |
| 28 | 33 | |--------|-------| | |
| 29 | - | | `GORDON_REGISTRY` | `registry.mydomain.com` | | |
| 30 | - | | `GORDON_USERNAME` | `github-actions` | | |
| 31 | 34 | | `GORDON_TOKEN` | The generated token | | |
| 35 | + | | `GORDON_REMOTE` | `https://gordon.example.com` | | |
| 32 | 36 | ||
| 33 | - | ### 3. Create Workflow | |
| 37 | + | ### 3. Setup Gordon CLI | |
| 34 | 38 | ||
| 35 | - | Create `.github/workflows/deploy.yml`: | |
| 39 | + | Add this step to your workflow to install the Gordon binary: | |
| 40 | + | ||
| 41 | + | ```yaml | |
| 42 | + | - name: Setup Gordon | |
| 43 | + | uses: bnema/gordon/.github/actions/setup-gordon@main | |
| 44 | + | # with: | |
| 45 | + | # version: latest # default, or pin to e.g. v2.0.0 | |
| 46 | + | ``` | |
| 47 | + | ||
| 48 | + | ### 4. Workflow Examples | |
| 49 | + | ||
| 50 | + | #### Deploy on Tag Push | |
| 51 | + | ||
| 52 | + | Gordon auto-detects the version from `$GITHUB_REF` (e.g., `refs/tags/v1.2.0` → `v1.2.0`). | |
| 36 | 53 | ||
| 37 | 54 | ```yaml | |
| 38 | 55 | name: Deploy to Gordon | |
| 39 | 56 | ||
| 40 | 57 | on: | |
| 41 | 58 | push: | |
| 42 | - | tags: | |
| 43 | - | - 'v*' | |
| 59 | + | tags: ['v*'] | |
| 44 | 60 | ||
| 45 | 61 | jobs: | |
| 46 | 62 | deploy: | |
| 47 | 63 | runs-on: ubuntu-latest | |
| 48 | 64 | steps: | |
| 49 | - | - uses: actions/checkout@v6 | |
| 65 | + | - uses: actions/checkout@v4 | |
| 66 | + | ||
| 67 | + | - name: Setup Gordon | |
| 68 | + | uses: bnema/gordon/.github/actions/setup-gordon@main | |
| 69 | + | ||
| 70 | + | - name: Build and Deploy | |
| 71 | + | env: | |
| 72 | + | GORDON_TOKEN: ${{ secrets.GORDON_TOKEN }} | |
| 73 | + | run: | | |
| 74 | + | gordon push --build \ | |
| 75 | + | --remote ${{ secrets.GORDON_REMOTE }} \ | |
| 76 | + | --no-confirm | |
| 77 | + | ``` | |
| 50 | 78 | ||
| 51 | - | - name: Deploy to Gordon | |
| 52 | - | uses: bnema/gordon/.github/actions/deploy@main | |
| 79 | + | #### Continuous Deploy on Main | |
| 80 | + | ||
| 81 | + | Deploy on every push to main. The version defaults to `latest` or the output of `git describe`. | |
| 82 | + | ||
| 83 | + | ```yaml | |
| 84 | + | name: Deploy to Gordon | |
| 85 | + | ||
| 86 | + | on: | |
| 87 | + | push: | |
| 88 | + | branches: | |
| 89 | + | - main | |
| 90 | + | ||
| 91 | + | jobs: | |
| 92 | + | deploy: | |
| 93 | + | runs-on: ubuntu-latest | |
| 94 | + | steps: | |
| 95 | + | - uses: actions/checkout@v4 | |
| 53 | 96 | with: | |
| 54 | - | registry: ${{ secrets.GORDON_REGISTRY }} | |
| 55 | - | username: ${{ secrets.GORDON_USERNAME }} | |
| 56 | - | password: ${{ secrets.GORDON_TOKEN }} | |
| 97 | + | fetch-depth: 0 | |
| 98 | + | ||
| 99 | + | - name: Setup Gordon | |
| 100 | + | uses: bnema/gordon/.github/actions/setup-gordon@main | |
| 101 | + | ||
| 102 | + | - name: Build and Deploy | |
| 103 | + | env: | |
| 104 | + | GORDON_TOKEN: ${{ secrets.GORDON_TOKEN }} | |
| 105 | + | run: | | |
| 106 | + | gordon push --build \ | |
| 107 | + | --remote ${{ secrets.GORDON_REMOTE }} \ | |
| 108 | + | --tag latest \ | |
| 109 | + | --no-confirm | |
| 57 | 110 | ``` | |
| 58 | 111 | ||
| 59 | - | ### 4. Deploy | |
| 112 | + | #### Manual Dispatch | |
| 60 | 113 | ||
| 61 | - | ```bash | |
| 62 | - | git tag v1.0.0 | |
| 63 | - | git push origin v1.0.0 | |
| 114 | + | Allow manual deployments with an optional tag input: | |
| 115 | + | ||
| 116 | + | ```yaml | |
| 117 | + | name: Deploy to Gordon | |
| 118 | + | ||
| 119 | + | on: | |
| 120 | + | workflow_dispatch: | |
| 121 | + | inputs: | |
| 122 | + | tag: | |
| 123 | + | description: 'Tag to deploy (leave empty to use git describe)' | |
| 124 | + | required: false | |
| 125 | + | ||
| 126 | + | jobs: | |
| 127 | + | deploy: | |
| 128 | + | runs-on: ubuntu-latest | |
| 129 | + | steps: | |
| 130 | + | - uses: actions/checkout@v4 | |
| 131 | + | with: | |
| 132 | + | ref: ${{ github.event.inputs.tag || github.ref }} | |
| 133 | + | fetch-depth: 0 | |
| 134 | + | ||
| 135 | + | - name: Setup Gordon | |
| 136 | + | uses: bnema/gordon/.github/actions/setup-gordon@main | |
| 137 | + | ||
| 138 | + | - name: Build and Deploy | |
| 139 | + | env: | |
| 140 | + | GORDON_TOKEN: ${{ secrets.GORDON_TOKEN }} | |
| 141 | + | run: | | |
| 142 | + | gordon push --build \ | |
| 143 | + | --remote ${{ secrets.GORDON_REMOTE }} \ | |
| 144 | + | ${{ github.event.inputs.tag && format('--tag {0}', github.event.inputs.tag) || '' }} \ | |
| 145 | + | --no-confirm | |
| 146 | + | ``` | |
| 147 | + | ||
| 148 | + | #### Monorepo | |
| 149 | + | ||
| 150 | + | Deploy multiple services with separate `gordon push` calls: | |
| 151 | + | ||
| 152 | + | ```yaml | |
| 153 | + | name: Deploy Services | |
| 154 | + | ||
| 155 | + | on: | |
| 156 | + | push: | |
| 157 | + | tags: ['v*'] | |
| 158 | + | ||
| 159 | + | jobs: | |
| 160 | + | deploy: | |
| 161 | + | runs-on: ubuntu-latest | |
| 162 | + | steps: | |
| 163 | + | - uses: actions/checkout@v4 | |
| 164 | + | ||
| 165 | + | - name: Setup Gordon | |
| 166 | + | uses: bnema/gordon/.github/actions/setup-gordon@main | |
| 167 | + | ||
| 168 | + | - name: Deploy API | |
| 169 | + | env: | |
| 170 | + | GORDON_TOKEN: ${{ secrets.GORDON_TOKEN }} | |
| 171 | + | run: | | |
| 172 | + | gordon push --build \ | |
| 173 | + | --remote ${{ secrets.GORDON_REMOTE }} \ | |
| 174 | + | --file ./services/api/Dockerfile \ | |
| 175 | + | myapp-api \ | |
| 176 | + | --no-confirm | |
| 177 | + | ||
| 178 | + | - name: Deploy Web | |
| 179 | + | env: | |
| 180 | + | GORDON_TOKEN: ${{ secrets.GORDON_TOKEN }} | |
| 181 | + | run: | | |
| 182 | + | gordon push --build \ | |
| 183 | + | --remote ${{ secrets.GORDON_REMOTE }} \ | |
| 184 | + | --file ./services/web/Dockerfile \ | |
| 185 | + | myapp-web \ | |
| 186 | + | --no-confirm | |
| 64 | 187 | ``` | |
| 65 | 188 | ||
| 66 | - | ## Action Reference | |
| 189 | + | #### With Build Args | |
| 190 | + | ||
| 191 | + | Pass build arguments to the Docker build: | |
| 192 | + | ||
| 193 | + | ```yaml | |
| 194 | + | - name: Build and Deploy | |
| 195 | + | env: | |
| 196 | + | GORDON_TOKEN: ${{ secrets.GORDON_TOKEN }} | |
| 197 | + | run: | | |
| 198 | + | gordon push --build \ | |
| 199 | + | --remote ${{ secrets.GORDON_REMOTE }} \ | |
| 200 | + | --build-arg NODE_ENV=production \ | |
| 201 | + | --build-arg API_URL=https://api.example.com \ | |
| 202 | + | --build-arg BUILD_DATE=${{ github.event.head_commit.timestamp }} \ | |
| 203 | + | --no-confirm | |
| 204 | + | ``` | |
| 67 | 205 | ||
| 68 | - | ### Inputs | |
| 206 | + | ### setup-gordon Action Reference | |
| 69 | 207 | ||
| 70 | 208 | | Input | Required | Default | Description | | |
| 71 | 209 | |-------|----------|---------|-------------| | |
| 72 | - | | `registry` | Yes | - | Gordon registry URL | | |
| 73 | - | | `username` | Yes | - | Registry username | | |
| 74 | - | | `password` | Yes | - | Registry token | | |
| 75 | - | | `image` | No | repo name | Image name | | |
| 76 | - | | `tag` | No | git tag/SHA | Image tag | | |
| 77 | - | | `dockerfile` | No | `./Dockerfile` | Dockerfile path | | |
| 78 | - | | `context` | No | `.` | Build context | | |
| 79 | - | | `push-latest` | No | `false` | Also push :latest tag | | |
| 80 | - | | `platforms` | No | `linux/amd64` | Target platforms | | |
| 81 | - | | `build-args` | No | - | Build arguments | | |
| 82 | - | ||
| 83 | - | ### Outputs | |
| 210 | + | | `version` | No | `latest` | Gordon version to install (`v2.0.0` or `latest`) | | |
| 84 | 211 | ||
| 85 | 212 | | Output | Description | | |
| 86 | 213 | |--------|-------------| | |
| 87 | - | | `image` | Full image name | | |
| 88 | - | | `tag` | Image tag | | |
| 214 | + | | `version` | The version actually installed | | |
| 215 | + | | `path` | Path to the installed binary | | |
| 216 | + | ||
| 217 | + | ## Alternative: Docker-based Workflow | |
| 89 | 218 | ||
| 90 | - | ## Workflow Examples | |
| 219 | + | For environments where installing the Gordon binary is not desired, use Docker directly to build and push to the Gordon registry. Gordon auto-deploys when it receives the image — no explicit deploy step is needed. | |
| 91 | 220 | ||
| 92 | - | ### Deploy on Tag Push | |
| 221 | + | ### Setup | |
| 222 | + | ||
| 223 | + | Add these secrets to your repository: | |
| 224 | + | ||
| 225 | + | | Secret | Value | | |
| 226 | + | |--------|-------| | |
| 227 | + | | `GORDON_REGISTRY` | `registry.gordon.example.com` | | |
| 228 | + | | `GORDON_USERNAME` | `github-actions` | | |
| 229 | + | | `GORDON_TOKEN` | The generated token | | |
| 230 | + | ||
| 231 | + | ### Workflow | |
| 232 | + | ||
| 233 | + | ```yaml | |
| 234 | + | name: Deploy to Gordon | |
| 235 | + | ||
| 236 | + | on: | |
| 237 | + | push: | |
| 238 | + | tags: ['v*'] | |
| 239 | + | ||
| 240 | + | jobs: | |
| 241 | + | deploy: | |
| 242 | + | runs-on: ubuntu-latest | |
| 243 | + | steps: | |
| 244 | + | - uses: actions/checkout@v4 | |
| 245 | + | ||
| 246 | + | - name: Login to Gordon Registry | |
| 247 | + | run: echo "${{ secrets.GORDON_TOKEN }}" | docker login ${{ secrets.GORDON_REGISTRY }} -u ${{ secrets.GORDON_USERNAME }} --password-stdin | |
| 248 | + | ||
| 249 | + | - name: Build and Push | |
| 250 | + | run: | | |
| 251 | + | docker build -t ${{ secrets.GORDON_REGISTRY }}/myapp:${{ github.ref_name }} . | |
| 252 | + | docker push ${{ secrets.GORDON_REGISTRY }}/myapp:${{ github.ref_name }} | |
| 253 | + | ``` | |
| 254 | + | ||
| 255 | + | ## Alternative: Gordon GitHub Action | |
| 256 | + | ||
| 257 | + | Use the official `bnema/gordon` action for a fully declarative workflow. This action handles login, build, and push in a single step. | |
| 258 | + | ||
| 259 | + | ### Setup | |
| 260 | + | ||
| 261 | + | Requires the same 3 secrets as the Docker-based workflow: `GORDON_REGISTRY`, `GORDON_USERNAME`, `GORDON_TOKEN`. | |
| 262 | + | ||
| 263 | + | ### Workflow Examples | |
| 264 | + | ||
| 265 | + | #### Deploy on Tag Push | |
| 93 | 266 | ||
| 94 | 267 | ```yaml | |
| 95 | 268 | name: Deploy to Gordon | |
| @@ -103,7 +276,7 @@ jobs: | |||
| 103 | 276 | deploy: | |
| 104 | 277 | runs-on: ubuntu-latest | |
| 105 | 278 | steps: | |
| 106 | - | - uses: actions/checkout@v6 | |
| 279 | + | - uses: actions/checkout@v4 | |
| 107 | 280 | ||
| 108 | 281 | - name: Deploy to Gordon | |
| 109 | 282 | uses: bnema/gordon/.github/actions/deploy@main | |
| @@ -113,7 +286,7 @@ jobs: | |||
| 113 | 286 | password: ${{ secrets.GORDON_TOKEN }} | |
| 114 | 287 | ``` | |
| 115 | 288 | ||
| 116 | - | ### Continuous Deployment | |
| 289 | + | #### Continuous Deployment | |
| 117 | 290 | ||
| 118 | 291 | Deploy on every push to main: | |
| 119 | 292 | ||
| @@ -129,7 +302,7 @@ jobs: | |||
| 129 | 302 | deploy: | |
| 130 | 303 | runs-on: ubuntu-latest | |
| 131 | 304 | steps: | |
| 132 | - | - uses: actions/checkout@v6 | |
| 305 | + | - uses: actions/checkout@v4 | |
| 133 | 306 | ||
| 134 | 307 | - name: Deploy to Gordon | |
| 135 | 308 | uses: bnema/gordon/.github/actions/deploy@main | |
| @@ -140,9 +313,9 @@ jobs: | |||
| 140 | 313 | push-latest: 'true' | |
| 141 | 314 | ``` | |
| 142 | 315 | ||
| 143 | - | ### Manual Deployment | |
| 316 | + | #### Manual Deployment | |
| 144 | 317 | ||
| 145 | - | Allow manual deployments with custom tag: | |
| 318 | + | Allow manual deployments with a custom tag: | |
| 146 | 319 | ||
| 147 | 320 | ```yaml | |
| 148 | 321 | name: Deploy to Gordon | |
| @@ -158,7 +331,7 @@ jobs: | |||
| 158 | 331 | deploy: | |
| 159 | 332 | runs-on: ubuntu-latest | |
| 160 | 333 | steps: | |
| 161 | - | - uses: actions/checkout@v6 | |
| 334 | + | - uses: actions/checkout@v4 | |
| 162 | 335 | with: | |
| 163 | 336 | ref: ${{ github.event.inputs.tag || github.ref }} | |
| 164 | 337 | ||
| @@ -171,7 +344,7 @@ jobs: | |||
| 171 | 344 | tag: ${{ github.event.inputs.tag }} | |
| 172 | 345 | ``` | |
| 173 | 346 | ||
| 174 | - | ### Monorepo Deployment | |
| 347 | + | #### Monorepo Deployment | |
| 175 | 348 | ||
| 176 | 349 | Deploy multiple services: | |
| 177 | 350 | ||
| @@ -187,7 +360,7 @@ jobs: | |||
| 187 | 360 | deploy-api: | |
| 188 | 361 | runs-on: ubuntu-latest | |
| 189 | 362 | steps: | |
| 190 | - | - uses: actions/checkout@v6 | |
| 363 | + | - uses: actions/checkout@v4 | |
| 191 | 364 | - uses: bnema/gordon/.github/actions/deploy@main | |
| 192 | 365 | with: | |
| 193 | 366 | registry: ${{ secrets.GORDON_REGISTRY }} | |
| @@ -200,7 +373,7 @@ jobs: | |||
| 200 | 373 | deploy-web: | |
| 201 | 374 | runs-on: ubuntu-latest | |
| 202 | 375 | steps: | |
| 203 | - | - uses: actions/checkout@v6 | |
| 376 | + | - uses: actions/checkout@v4 | |
| 204 | 377 | - uses: bnema/gordon/.github/actions/deploy@main | |
| 205 | 378 | with: | |
| 206 | 379 | registry: ${{ secrets.GORDON_REGISTRY }} | |
| @@ -211,7 +384,7 @@ jobs: | |||
| 211 | 384 | context: ./services/web | |
| 212 | 385 | ``` | |
| 213 | 386 | ||
| 214 | - | ### With Build Arguments | |
| 387 | + | #### With Build Arguments | |
| 215 | 388 | ||
| 216 | 389 | ```yaml | |
| 217 | 390 | - uses: bnema/gordon/.github/actions/deploy@main | |
| @@ -225,7 +398,7 @@ jobs: | |||
| 225 | 398 | BUILD_DATE=${{ github.event.head_commit.timestamp }} | |
| 226 | 399 | ``` | |
| 227 | 400 | ||
| 228 | - | ### Multi-Platform Build | |
| 401 | + | #### Multi-Platform Build | |
| 229 | 402 | ||
| 230 | 403 | ```yaml | |
| 231 | 404 | - uses: bnema/gordon/.github/actions/deploy@main | |
| @@ -236,7 +409,31 @@ jobs: | |||
| 236 | 409 | platforms: linux/amd64,linux/arm64 | |
| 237 | 410 | ``` | |
| 238 | 411 | ||
| 239 | - | ## Deployment Summary | |
| 412 | + | ### Action Reference | |
| 413 | + | ||
| 414 | + | #### Inputs | |
| 415 | + | ||
| 416 | + | | Input | Required | Default | Description | | |
| 417 | + | |-------|----------|---------|-------------| | |
| 418 | + | | `registry` | Yes | - | Gordon registry URL | | |
| 419 | + | | `username` | Yes | - | Registry username | | |
| 420 | + | | `password` | Yes | - | Registry token | | |
| 421 | + | | `image` | No | repo name | Image name | | |
| 422 | + | | `tag` | No | git tag/SHA | Image tag | | |
| 423 | + | | `dockerfile` | No | `./Dockerfile` | Dockerfile path | | |
| 424 | + | | `context` | No | `.` | Build context | | |
| 425 | + | | `push-latest` | No | `false` | Also push :latest tag | | |
| 426 | + | | `platforms` | No | `linux/amd64` | Target platforms | | |
| 427 | + | | `build-args` | No | - | Build arguments | | |
| 428 | + | ||
| 429 | + | #### Outputs | |
| 430 | + | ||
| 431 | + | | Output | Description | | |
| 432 | + | |--------|-------------| | |
| 433 | + | | `image` | Full image name | | |
| 434 | + | | `tag` | Image tag | | |
| 435 | + | ||
| 436 | + | #### Deployment Summary | |
| 240 | 437 | ||
| 241 | 438 | Add a deployment summary to your workflow: | |
| 242 | 439 | ||
| @@ -277,6 +474,9 @@ Error: unauthorized: authentication required | |||
| 277 | 474 | ||
| 278 | 475 | ## Related | |
| 279 | 476 | ||
| 280 | - | - [Authentication](../config/auth.md) | |
| 477 | + | - [GitLab CI](./gitlab-ci.md) | |
| 478 | + | - [Generic CI](./generic-ci.md) | |
| 281 | 479 | - [Deployment Overview](./index.md) | |
| 480 | + | - [Authentication](../config/auth.md) | |
| 481 | + | - [Push Command](../cli/push.md) | |
| 282 | 482 | - [Rollback](./rollback.md) | |
| @@ -1,21 +1,26 @@ | |||||
| 1 | # GitHub Actions Deployment | 1 | # GitHub Actions Deployment | ||
| 2 | 2 | ||||
| 3 | - | Automated deployment with GitHub Actions using Gordon's official action. | 3 | + | Two approaches for deploying with GitHub Actions: the `gordon push` CLI (recommended) or a Docker-based workflow using Gordon's official action. |
| 4 | 4 | ||||
| 5 | ## Prerequisites | 5 | ## Prerequisites | ||
| 6 | 6 | ||||
| 7 | 1. Gordon server running with registry authentication enabled | 7 | 1. Gordon server running with registry authentication enabled | ||
| 8 | - | 2. Generated deployment token | 8 | + | 2. Deployment token generated with the required scopes |
| 9 | 3. GitHub repository secrets configured | 9 | 3. GitHub repository secrets configured | ||
| 10 | 10 | ||||
| 11 | - | ## Quick Setup | 11 | + | ## Recommended: gordon push |
| 12 | 12 | ||||
| 13 | - | ### 1. Generate Deployment Token | 13 | + | Use the `gordon push` CLI for a lightweight, single-step build and deploy. |
| 14 | + | ||||
| 15 | + | ### 1. Generate Token | |||
| 14 | 16 | ||||
| 15 | On your Gordon server: | 17 | On your Gordon server: | ||
| 16 | 18 | ||||
| 17 | ```bash | 19 | ```bash | ||
| 18 | - | gordon auth token generate --subject github-actions --scopes push,pull --expiry 0 | 20 | + | gordon auth token generate \ |
| 21 | + | --subject github-actions \ | |||
| 22 | + | --scopes "push,pull,admin:routes:read,admin:config:write" \ | |||
| 23 | + | --expiry 0 | |||
| 19 | ``` | 24 | ``` | ||
| 20 | 25 | ||||
| 21 | Save the token output. | 26 | Save the token output. | ||
| @@ -26,70 +31,238 @@ In your repository: Settings → Secrets → Actions → New repository secret | |||||
| 26 | 31 | ||||
| 27 | | Secret | Value | | 32 | | Secret | Value | | ||
| 28 | |--------|-------| | 33 | |--------|-------| | ||
| 29 | - | | `GORDON_REGISTRY` | `registry.mydomain.com` | | |||
| 30 | - | | `GORDON_USERNAME` | `github-actions` | | |||
| 31 | | `GORDON_TOKEN` | The generated token | | 34 | | `GORDON_TOKEN` | The generated token | | ||
| 35 | + | | `GORDON_REMOTE` | `https://gordon.example.com` | | |||
| 32 | 36 | ||||
| 33 | - | ### 3. Create Workflow | 37 | + | ### 3. Setup Gordon CLI |
| 34 | 38 | ||||
| 35 | - | Create `.github/workflows/deploy.yml`: | 39 | + | Add this step to your workflow to install the Gordon binary: |
| 40 | + | ||||
| 41 | + | ```yaml | |||
| 42 | + | - name: Setup Gordon | |||
| 43 | + | uses: bnema/gordon/.github/actions/setup-gordon@main | |||
| 44 | + | # with: | |||
| 45 | + | # version: latest # default, or pin to e.g. v2.0.0 | |||
| 46 | + | ``` | |||
| 47 | + | ||||
| 48 | + | ### 4. Workflow Examples | |||
| 49 | + | ||||
| 50 | + | #### Deploy on Tag Push | |||
| 51 | + | ||||
| 52 | + | Gordon auto-detects the version from `$GITHUB_REF` (e.g., `refs/tags/v1.2.0` → `v1.2.0`). | |||
| 36 | 53 | ||||
| 37 | ```yaml | 54 | ```yaml | ||
| 38 | name: Deploy to Gordon | 55 | name: Deploy to Gordon | ||
| 39 | 56 | ||||
| 40 | on: | 57 | on: | ||
| 41 | push: | 58 | push: | ||
| 42 | - | tags: | 59 | + | tags: ['v*'] |
| 43 | - | - 'v*' | |||
| 44 | 60 | ||||
| 45 | jobs: | 61 | jobs: | ||
| 46 | deploy: | 62 | deploy: | ||
| 47 | runs-on: ubuntu-latest | 63 | runs-on: ubuntu-latest | ||
| 48 | steps: | 64 | steps: | ||
| 49 | - | - uses: actions/checkout@v6 | 65 | + | - uses: actions/checkout@v4 |
| 66 | + | ||||
| 67 | + | - name: Setup Gordon | |||
| 68 | + | uses: bnema/gordon/.github/actions/setup-gordon@main | |||
| 69 | + | ||||
| 70 | + | - name: Build and Deploy | |||
| 71 | + | env: | |||
| 72 | + | GORDON_TOKEN: ${{ secrets.GORDON_TOKEN }} | |||
| 73 | + | run: | | |||
| 74 | + | gordon push --build \ | |||
| 75 | + | --remote ${{ secrets.GORDON_REMOTE }} \ | |||
| 76 | + | --no-confirm | |||
| 77 | + | ``` | |||
| 50 | 78 | ||||
| 51 | - | - name: Deploy to Gordon | 79 | + | #### Continuous Deploy on Main |
| 52 | - | uses: bnema/gordon/.github/actions/deploy@main | 80 | + | |
| 81 | + | Deploy on every push to main. The version defaults to `latest` or the output of `git describe`. | |||
| 82 | + | ||||
| 83 | + | ```yaml | |||
| 84 | + | name: Deploy to Gordon | |||
| 85 | + | ||||
| 86 | + | on: | |||
| 87 | + | push: | |||
| 88 | + | branches: | |||
| 89 | + | - main | |||
| 90 | + | ||||
| 91 | + | jobs: | |||
| 92 | + | deploy: | |||
| 93 | + | runs-on: ubuntu-latest | |||
| 94 | + | steps: | |||
| 95 | + | - uses: actions/checkout@v4 | |||
| 53 | with: | 96 | with: | ||
| 54 | - | registry: ${{ secrets.GORDON_REGISTRY }} | 97 | + | fetch-depth: 0 |
| 55 | - | username: ${{ secrets.GORDON_USERNAME }} | 98 | + | |
| 56 | - | password: ${{ secrets.GORDON_TOKEN }} | 99 | + | - name: Setup Gordon |
| 100 | + | uses: bnema/gordon/.github/actions/setup-gordon@main | |||
| 101 | + | ||||
| 102 | + | - name: Build and Deploy | |||
| 103 | + | env: | |||
| 104 | + | GORDON_TOKEN: ${{ secrets.GORDON_TOKEN }} | |||
| 105 | + | run: | | |||
| 106 | + | gordon push --build \ | |||
| 107 | + | --remote ${{ secrets.GORDON_REMOTE }} \ | |||
| 108 | + | --tag latest \ | |||
| 109 | + | --no-confirm | |||
| 57 | ``` | 110 | ``` | ||
| 58 | 111 | ||||
| 59 | - | ### 4. Deploy | 112 | + | #### Manual Dispatch |
| 60 | 113 | ||||
| 61 | - | ```bash | 114 | + | Allow manual deployments with an optional tag input: |
| 62 | - | git tag v1.0.0 | 115 | + | |
| 63 | - | git push origin v1.0.0 | 116 | + | ```yaml |
| 117 | + | name: Deploy to Gordon | |||
| 118 | + | ||||
| 119 | + | on: | |||
| 120 | + | workflow_dispatch: | |||
| 121 | + | inputs: | |||
| 122 | + | tag: | |||
| 123 | + | description: 'Tag to deploy (leave empty to use git describe)' | |||
| 124 | + | required: false | |||
| 125 | + | ||||
| 126 | + | jobs: | |||
| 127 | + | deploy: | |||
| 128 | + | runs-on: ubuntu-latest | |||
| 129 | + | steps: | |||
| 130 | + | - uses: actions/checkout@v4 | |||
| 131 | + | with: | |||
| 132 | + | ref: ${{ github.event.inputs.tag || github.ref }} | |||
| 133 | + | fetch-depth: 0 | |||
| 134 | + | ||||
| 135 | + | - name: Setup Gordon | |||
| 136 | + | uses: bnema/gordon/.github/actions/setup-gordon@main | |||
| 137 | + | ||||
| 138 | + | - name: Build and Deploy | |||
| 139 | + | env: | |||
| 140 | + | GORDON_TOKEN: ${{ secrets.GORDON_TOKEN }} | |||
| 141 | + | run: | | |||
| 142 | + | gordon push --build \ | |||
| 143 | + | --remote ${{ secrets.GORDON_REMOTE }} \ | |||
| 144 | + | ${{ github.event.inputs.tag && format('--tag {0}', github.event.inputs.tag) || '' }} \ | |||
| 145 | + | --no-confirm | |||
| 146 | + | ``` | |||
| 147 | + | ||||
| 148 | + | #### Monorepo | |||
| 149 | + | ||||
| 150 | + | Deploy multiple services with separate `gordon push` calls: | |||
| 151 | + | ||||
| 152 | + | ```yaml | |||
| 153 | + | name: Deploy Services | |||
| 154 | + | ||||
| 155 | + | on: | |||
| 156 | + | push: | |||
| 157 | + | tags: ['v*'] | |||
| 158 | + | ||||
| 159 | + | jobs: | |||
| 160 | + | deploy: | |||
| 161 | + | runs-on: ubuntu-latest | |||
| 162 | + | steps: | |||
| 163 | + | - uses: actions/checkout@v4 | |||
| 164 | + | ||||
| 165 | + | - name: Setup Gordon | |||
| 166 | + | uses: bnema/gordon/.github/actions/setup-gordon@main | |||
| 167 | + | ||||
| 168 | + | - name: Deploy API | |||
| 169 | + | env: | |||
| 170 | + | GORDON_TOKEN: ${{ secrets.GORDON_TOKEN }} | |||
| 171 | + | run: | | |||
| 172 | + | gordon push --build \ | |||
| 173 | + | --remote ${{ secrets.GORDON_REMOTE }} \ | |||
| 174 | + | --file ./services/api/Dockerfile \ | |||
| 175 | + | myapp-api \ | |||
| 176 | + | --no-confirm | |||
| 177 | + | ||||
| 178 | + | - name: Deploy Web | |||
| 179 | + | env: | |||
| 180 | + | GORDON_TOKEN: ${{ secrets.GORDON_TOKEN }} | |||
| 181 | + | run: | | |||
| 182 | + | gordon push --build \ | |||
| 183 | + | --remote ${{ secrets.GORDON_REMOTE }} \ | |||
| 184 | + | --file ./services/web/Dockerfile \ | |||
| 185 | + | myapp-web \ | |||
| 186 | + | --no-confirm | |||
| 64 | ``` | 187 | ``` | ||
| 65 | 188 | ||||
| 66 | - | ## Action Reference | 189 | + | #### With Build Args |
| 190 | + | ||||
| 191 | + | Pass build arguments to the Docker build: | |||
| 192 | + | ||||
| 193 | + | ```yaml | |||
| 194 | + | - name: Build and Deploy | |||
| 195 | + | env: | |||
| 196 | + | GORDON_TOKEN: ${{ secrets.GORDON_TOKEN }} | |||
| 197 | + | run: | | |||
| 198 | + | gordon push --build \ | |||
| 199 | + | --remote ${{ secrets.GORDON_REMOTE }} \ | |||
| 200 | + | --build-arg NODE_ENV=production \ | |||
| 201 | + | --build-arg API_URL=https://api.example.com \ | |||
| 202 | + | --build-arg BUILD_DATE=${{ github.event.head_commit.timestamp }} \ | |||
| 203 | + | --no-confirm | |||
| 204 | + | ``` | |||
| 67 | 205 | ||||
| 68 | - | ### Inputs | 206 | + | ### setup-gordon Action Reference |
| 69 | 207 | ||||
| 70 | | Input | Required | Default | Description | | 208 | | Input | Required | Default | Description | | ||
| 71 | |-------|----------|---------|-------------| | 209 | |-------|----------|---------|-------------| | ||
| 72 | - | | `registry` | Yes | - | Gordon registry URL | | 210 | + | | `version` | No | `latest` | Gordon version to install (`v2.0.0` or `latest`) | |
| 73 | - | | `username` | Yes | - | Registry username | | |||
| 74 | - | | `password` | Yes | - | Registry token | | |||
| 75 | - | | `image` | No | repo name | Image name | | |||
| 76 | - | | `tag` | No | git tag/SHA | Image tag | | |||
| 77 | - | | `dockerfile` | No | `./Dockerfile` | Dockerfile path | | |||
| 78 | - | | `context` | No | `.` | Build context | | |||
| 79 | - | | `push-latest` | No | `false` | Also push :latest tag | | |||
| 80 | - | | `platforms` | No | `linux/amd64` | Target platforms | | |||
| 81 | - | | `build-args` | No | - | Build arguments | | |||
| 82 | - | ||||
| 83 | - | ### Outputs | |||
| 84 | 211 | ||||
| 85 | | Output | Description | | 212 | | Output | Description | | ||
| 86 | |--------|-------------| | 213 | |--------|-------------| | ||
| 87 | - | | `image` | Full image name | | 214 | + | | `version` | The version actually installed | |
| 88 | - | | `tag` | Image tag | | 215 | + | | `path` | Path to the installed binary | |
| 216 | + | ||||
| 217 | + | ## Alternative: Docker-based Workflow | |||
| 89 | 218 | ||||
| 90 | - | ## Workflow Examples | 219 | + | For environments where installing the Gordon binary is not desired, use Docker directly to build and push to the Gordon registry. Gordon auto-deploys when it receives the image — no explicit deploy step is needed. |
| 91 | 220 | ||||
| 92 | - | ### Deploy on Tag Push | 221 | + | ### Setup |
| 222 | + | ||||
| 223 | + | Add these secrets to your repository: | |||
| 224 | + | ||||
| 225 | + | | Secret | Value | | |||
| 226 | + | |--------|-------| | |||
| 227 | + | | `GORDON_REGISTRY` | `registry.gordon.example.com` | | |||
| 228 | + | | `GORDON_USERNAME` | `github-actions` | | |||
| 229 | + | | `GORDON_TOKEN` | The generated token | | |||
| 230 | + | ||||
| 231 | + | ### Workflow | |||
| 232 | + | ||||
| 233 | + | ```yaml | |||
| 234 | + | name: Deploy to Gordon | |||
| 235 | + | ||||
| 236 | + | on: | |||
| 237 | + | push: | |||
| 238 | + | tags: ['v*'] | |||
| 239 | + | ||||
| 240 | + | jobs: | |||
| 241 | + | deploy: | |||
| 242 | + | runs-on: ubuntu-latest | |||
| 243 | + | steps: | |||
| 244 | + | - uses: actions/checkout@v4 | |||
| 245 | + | ||||
| 246 | + | - name: Login to Gordon Registry | |||
| 247 | + | run: echo "${{ secrets.GORDON_TOKEN }}" | docker login ${{ secrets.GORDON_REGISTRY }} -u ${{ secrets.GORDON_USERNAME }} --password-stdin | |||
| 248 | + | ||||
| 249 | + | - name: Build and Push | |||
| 250 | + | run: | | |||
| 251 | + | docker build -t ${{ secrets.GORDON_REGISTRY }}/myapp:${{ github.ref_name }} . | |||
| 252 | + | docker push ${{ secrets.GORDON_REGISTRY }}/myapp:${{ github.ref_name }} | |||
| 253 | + | ``` | |||
| 254 | + | ||||
| 255 | + | ## Alternative: Gordon GitHub Action | |||
| 256 | + | ||||
| 257 | + | Use the official `bnema/gordon` action for a fully declarative workflow. This action handles login, build, and push in a single step. | |||
| 258 | + | ||||
| 259 | + | ### Setup | |||
| 260 | + | ||||
| 261 | + | Requires the same 3 secrets as the Docker-based workflow: `GORDON_REGISTRY`, `GORDON_USERNAME`, `GORDON_TOKEN`. | |||
| 262 | + | ||||
| 263 | + | ### Workflow Examples | |||
| 264 | + | ||||
| 265 | + | #### Deploy on Tag Push | |||
| 93 | 266 | ||||
| 94 | ```yaml | 267 | ```yaml | ||
| 95 | name: Deploy to Gordon | 268 | name: Deploy to Gordon | ||
| @@ -103,7 +276,7 @@ jobs: | |||||
| 103 | deploy: | 276 | deploy: | ||
| 104 | runs-on: ubuntu-latest | 277 | runs-on: ubuntu-latest | ||
| 105 | steps: | 278 | steps: | ||
| 106 | - | - uses: actions/checkout@v6 | 279 | + | - uses: actions/checkout@v4 |
| 107 | 280 | ||||
| 108 | - name: Deploy to Gordon | 281 | - name: Deploy to Gordon | ||
| 109 | uses: bnema/gordon/.github/actions/deploy@main | 282 | uses: bnema/gordon/.github/actions/deploy@main | ||
| @@ -113,7 +286,7 @@ jobs: | |||||
| 113 | password: ${{ secrets.GORDON_TOKEN }} | 286 | password: ${{ secrets.GORDON_TOKEN }} | ||
| 114 | ``` | 287 | ``` | ||
| 115 | 288 | ||||
| 116 | - | ### Continuous Deployment | 289 | + | #### Continuous Deployment |
| 117 | 290 | ||||
| 118 | Deploy on every push to main: | 291 | Deploy on every push to main: | ||
| 119 | 292 | ||||
| @@ -129,7 +302,7 @@ jobs: | |||||
| 129 | deploy: | 302 | deploy: | ||
| 130 | runs-on: ubuntu-latest | 303 | runs-on: ubuntu-latest | ||
| 131 | steps: | 304 | steps: | ||
| 132 | - | - uses: actions/checkout@v6 | 305 | + | - uses: actions/checkout@v4 |
| 133 | 306 | ||||
| 134 | - name: Deploy to Gordon | 307 | - name: Deploy to Gordon | ||
| 135 | uses: bnema/gordon/.github/actions/deploy@main | 308 | uses: bnema/gordon/.github/actions/deploy@main | ||
| @@ -140,9 +313,9 @@ jobs: | |||||
| 140 | push-latest: 'true' | 313 | push-latest: 'true' | ||
| 141 | ``` | 314 | ``` | ||
| 142 | 315 | ||||
| 143 | - | ### Manual Deployment | 316 | + | #### Manual Deployment |
| 144 | 317 | ||||
| 145 | - | Allow manual deployments with custom tag: | 318 | + | Allow manual deployments with a custom tag: |
| 146 | 319 | ||||
| 147 | ```yaml | 320 | ```yaml | ||
| 148 | name: Deploy to Gordon | 321 | name: Deploy to Gordon | ||
| @@ -158,7 +331,7 @@ jobs: | |||||
| 158 | deploy: | 331 | deploy: | ||
| 159 | runs-on: ubuntu-latest | 332 | runs-on: ubuntu-latest | ||
| 160 | steps: | 333 | steps: | ||
| 161 | - | - uses: actions/checkout@v6 | 334 | + | - uses: actions/checkout@v4 |
| 162 | with: | 335 | with: | ||
| 163 | ref: ${{ github.event.inputs.tag || github.ref }} | 336 | ref: ${{ github.event.inputs.tag || github.ref }} | ||
| 164 | 337 | ||||
| @@ -171,7 +344,7 @@ jobs: | |||||
| 171 | tag: ${{ github.event.inputs.tag }} | 344 | tag: ${{ github.event.inputs.tag }} | ||
| 172 | ``` | 345 | ``` | ||
| 173 | 346 | ||||
| 174 | - | ### Monorepo Deployment | 347 | + | #### Monorepo Deployment |
| 175 | 348 | ||||
| 176 | Deploy multiple services: | 349 | Deploy multiple services: | ||
| 177 | 350 | ||||
| @@ -187,7 +360,7 @@ jobs: | |||||
| 187 | deploy-api: | 360 | deploy-api: | ||
| 188 | runs-on: ubuntu-latest | 361 | runs-on: ubuntu-latest | ||
| 189 | steps: | 362 | steps: | ||
| 190 | - | - uses: actions/checkout@v6 | 363 | + | - uses: actions/checkout@v4 |
| 191 | - uses: bnema/gordon/.github/actions/deploy@main | 364 | - uses: bnema/gordon/.github/actions/deploy@main | ||
| 192 | with: | 365 | with: | ||
| 193 | registry: ${{ secrets.GORDON_REGISTRY }} | 366 | registry: ${{ secrets.GORDON_REGISTRY }} | ||
| @@ -200,7 +373,7 @@ jobs: | |||||
| 200 | deploy-web: | 373 | deploy-web: | ||
| 201 | runs-on: ubuntu-latest | 374 | runs-on: ubuntu-latest | ||
| 202 | steps: | 375 | steps: | ||
| 203 | - | - uses: actions/checkout@v6 | 376 | + | - uses: actions/checkout@v4 |
| 204 | - uses: bnema/gordon/.github/actions/deploy@main | 377 | - uses: bnema/gordon/.github/actions/deploy@main | ||
| 205 | with: | 378 | with: | ||
| 206 | registry: ${{ secrets.GORDON_REGISTRY }} | 379 | registry: ${{ secrets.GORDON_REGISTRY }} | ||
| @@ -211,7 +384,7 @@ jobs: | |||||
| 211 | context: ./services/web | 384 | context: ./services/web | ||
| 212 | ``` | 385 | ``` | ||
| 213 | 386 | ||||
| 214 | - | ### With Build Arguments | 387 | + | #### With Build Arguments |
| 215 | 388 | ||||
| 216 | ```yaml | 389 | ```yaml | ||
| 217 | - uses: bnema/gordon/.github/actions/deploy@main | 390 | - uses: bnema/gordon/.github/actions/deploy@main | ||
| @@ -225,7 +398,7 @@ jobs: | |||||
| 225 | BUILD_DATE=${{ github.event.head_commit.timestamp }} | 398 | BUILD_DATE=${{ github.event.head_commit.timestamp }} | ||
| 226 | ``` | 399 | ``` | ||
| 227 | 400 | ||||
| 228 | - | ### Multi-Platform Build | 401 | + | #### Multi-Platform Build |
| 229 | 402 | ||||
| 230 | ```yaml | 403 | ```yaml | ||
| 231 | - uses: bnema/gordon/.github/actions/deploy@main | 404 | - uses: bnema/gordon/.github/actions/deploy@main | ||
| @@ -236,7 +409,31 @@ jobs: | |||||
| 236 | platforms: linux/amd64,linux/arm64 | 409 | platforms: linux/amd64,linux/arm64 | ||
| 237 | ``` | 410 | ``` | ||
| 238 | 411 | ||||
| 239 | - | ## Deployment Summary | 412 | + | ### Action Reference |
| 413 | + | ||||
| 414 | + | #### Inputs | |||
| 415 | + | ||||
| 416 | + | | Input | Required | Default | Description | | |||
| 417 | + | |-------|----------|---------|-------------| | |||
| 418 | + | | `registry` | Yes | - | Gordon registry URL | | |||
| 419 | + | | `username` | Yes | - | Registry username | | |||
| 420 | + | | `password` | Yes | - | Registry token | | |||
| 421 | + | | `image` | No | repo name | Image name | | |||
| 422 | + | | `tag` | No | git tag/SHA | Image tag | | |||
| 423 | + | | `dockerfile` | No | `./Dockerfile` | Dockerfile path | | |||
| 424 | + | | `context` | No | `.` | Build context | | |||
| 425 | + | | `push-latest` | No | `false` | Also push :latest tag | | |||
| 426 | + | | `platforms` | No | `linux/amd64` | Target platforms | | |||
| 427 | + | | `build-args` | No | - | Build arguments | | |||
| 428 | + | ||||
| 429 | + | #### Outputs | |||
| 430 | + | ||||
| 431 | + | | Output | Description | | |||
| 432 | + | |--------|-------------| | |||
| 433 | + | | `image` | Full image name | | |||
| 434 | + | | `tag` | Image tag | | |||
| 435 | + | ||||
| 436 | + | #### Deployment Summary | |||
| 240 | 437 | ||||
| 241 | Add a deployment summary to your workflow: | 438 | Add a deployment summary to your workflow: | ||
| 242 | 439 | ||||
| @@ -277,6 +474,9 @@ Error: unauthorized: authentication required | |||||
| 277 | 474 | ||||
| 278 | ## Related | 475 | ## Related | ||
| 279 | 476 | ||||
| 280 | - | - [Authentication](../config/auth.md) | 477 | + | - [GitLab CI](./gitlab-ci.md) |
| 478 | + | - [Generic CI](./generic-ci.md) | |||
| 281 | - [Deployment Overview](./index.md) | 479 | - [Deployment Overview](./index.md) | ||
| 480 | + | - [Authentication](../config/auth.md) | |||
| 481 | + | - [Push Command](../cli/push.md) | |||
| 282 | - [Rollback](./rollback.md) | 482 | - [Rollback](./rollback.md) | ||
A docs/deployment/gitlab-ci.md +211 -0
| @@ -0,0 +1,211 @@ | |||
| 1 | + | # GitLab CI/CD Deployment | |
| 2 | + | ||
| 3 | + | Automated deployment with GitLab CI/CD using Gordon's push command. | |
| 4 | + | ||
| 5 | + | ## Prerequisites | |
| 6 | + | ||
| 7 | + | 1. Gordon server running with registry authentication enabled | |
| 8 | + | 2. Generated deployment token | |
| 9 | + | 3. GitLab CI/CD variables configured | |
| 10 | + | ||
| 11 | + | ## Quick Setup | |
| 12 | + | ||
| 13 | + | ### 1. Generate Deployment Token | |
| 14 | + | ||
| 15 | + | On your Gordon server: | |
| 16 | + | ||
| 17 | + | ```bash | |
| 18 | + | gordon auth token generate \ | |
| 19 | + | --subject gitlab-ci \ | |
| 20 | + | --scopes "push,pull,admin:routes:read,admin:config:write" \ | |
| 21 | + | --expiry 0 | |
| 22 | + | ``` | |
| 23 | + | ||
| 24 | + | ### 2. Add CI/CD Variables | |
| 25 | + | ||
| 26 | + | In your project: Settings > CI/CD > Variables | |
| 27 | + | ||
| 28 | + | | Variable | Value | Protected | Masked | | |
| 29 | + | |----------|-------|-----------|--------| | |
| 30 | + | | `GORDON_TOKEN` | The generated token | Yes | Yes | | |
| 31 | + | | `GORDON_REMOTE` | `https://gordon.example.com` | Yes | No | | |
| 32 | + | ||
| 33 | + | ### 3. Create Pipeline | |
| 34 | + | ||
| 35 | + | Create `.gitlab-ci.yml`: | |
| 36 | + | ||
| 37 | + | ```yaml | |
| 38 | + | deploy: | |
| 39 | + | stage: deploy | |
| 40 | + | image: docker:24 | |
| 41 | + | services: | |
| 42 | + | - docker:24-dind | |
| 43 | + | before_script: | |
| 44 | + | - apk add --no-cache curl | |
| 45 | + | - curl -fsSL https://github.com/bnema/gordon/releases/latest/download/gordon_linux_amd64 -o /usr/local/bin/gordon | |
| 46 | + | - chmod +x /usr/local/bin/gordon | |
| 47 | + | script: | |
| 48 | + | - gordon push --build | |
| 49 | + | --remote "$GORDON_REMOTE" | |
| 50 | + | --token "$GORDON_TOKEN" | |
| 51 | + | --no-confirm | |
| 52 | + | only: | |
| 53 | + | - tags | |
| 54 | + | ``` | |
| 55 | + | ||
| 56 | + | Gordon auto-detects the version from `$CI_COMMIT_TAG`. | |
| 57 | + | ||
| 58 | + | ## Pipeline Examples | |
| 59 | + | ||
| 60 | + | ### Deploy on Tag Push | |
| 61 | + | ||
| 62 | + | ```yaml | |
| 63 | + | stages: | |
| 64 | + | - deploy | |
| 65 | + | ||
| 66 | + | deploy: | |
| 67 | + | stage: deploy | |
| 68 | + | image: docker:24 | |
| 69 | + | services: | |
| 70 | + | - docker:24-dind | |
| 71 | + | before_script: | |
| 72 | + | - apk add --no-cache curl | |
| 73 | + | - curl -fsSL https://github.com/bnema/gordon/releases/latest/download/gordon_linux_amd64 -o /usr/local/bin/gordon | |
| 74 | + | - chmod +x /usr/local/bin/gordon | |
| 75 | + | script: | |
| 76 | + | - gordon push --build | |
| 77 | + | --remote "$GORDON_REMOTE" | |
| 78 | + | --token "$GORDON_TOKEN" | |
| 79 | + | --no-confirm | |
| 80 | + | rules: | |
| 81 | + | - if: $CI_COMMIT_TAG | |
| 82 | + | ``` | |
| 83 | + | ||
| 84 | + | ### Continuous Deployment | |
| 85 | + | ||
| 86 | + | Deploy on every push to main: | |
| 87 | + | ||
| 88 | + | ```yaml | |
| 89 | + | deploy: | |
| 90 | + | stage: deploy | |
| 91 | + | image: docker:24 | |
| 92 | + | services: | |
| 93 | + | - docker:24-dind | |
| 94 | + | before_script: | |
| 95 | + | - apk add --no-cache curl | |
| 96 | + | - curl -fsSL https://github.com/bnema/gordon/releases/latest/download/gordon_linux_amd64 -o /usr/local/bin/gordon | |
| 97 | + | - chmod +x /usr/local/bin/gordon | |
| 98 | + | script: | |
| 99 | + | - gordon push --build | |
| 100 | + | --remote "$GORDON_REMOTE" | |
| 101 | + | --token "$GORDON_TOKEN" | |
| 102 | + | --no-confirm | |
| 103 | + | rules: | |
| 104 | + | - if: $CI_COMMIT_BRANCH == "main" | |
| 105 | + | ``` | |
| 106 | + | ||
| 107 | + | ### Manual Deployment | |
| 108 | + | ||
| 109 | + | ```yaml | |
| 110 | + | deploy: | |
| 111 | + | stage: deploy | |
| 112 | + | image: docker:24 | |
| 113 | + | services: | |
| 114 | + | - docker:24-dind | |
| 115 | + | before_script: | |
| 116 | + | - apk add --no-cache curl | |
| 117 | + | - curl -fsSL https://github.com/bnema/gordon/releases/latest/download/gordon_linux_amd64 -o /usr/local/bin/gordon | |
| 118 | + | - chmod +x /usr/local/bin/gordon | |
| 119 | + | script: | |
| 120 | + | - gordon push --build | |
| 121 | + | --remote "$GORDON_REMOTE" | |
| 122 | + | --token "$GORDON_TOKEN" | |
| 123 | + | --no-confirm | |
| 124 | + | when: manual | |
| 125 | + | ``` | |
| 126 | + | ||
| 127 | + | ### With Build Arguments | |
| 128 | + | ||
| 129 | + | ```yaml | |
| 130 | + | deploy: | |
| 131 | + | stage: deploy | |
| 132 | + | image: docker:24 | |
| 133 | + | services: | |
| 134 | + | - docker:24-dind | |
| 135 | + | before_script: | |
| 136 | + | - apk add --no-cache curl | |
| 137 | + | - curl -fsSL https://github.com/bnema/gordon/releases/latest/download/gordon_linux_amd64 -o /usr/local/bin/gordon | |
| 138 | + | - chmod +x /usr/local/bin/gordon | |
| 139 | + | script: | |
| 140 | + | - gordon push --build | |
| 141 | + | --remote "$GORDON_REMOTE" | |
| 142 | + | --token "$GORDON_TOKEN" | |
| 143 | + | --build-arg NODE_ENV=production | |
| 144 | + | --build-arg API_URL=https://api.example.com | |
| 145 | + | --no-confirm | |
| 146 | + | rules: | |
| 147 | + | - if: $CI_COMMIT_TAG | |
| 148 | + | ``` | |
| 149 | + | ||
| 150 | + | ## Alternative: Docker-based Pipeline | |
| 151 | + | ||
| 152 | + | For environments where installing the Gordon binary is not desired: | |
| 153 | + | ||
| 154 | + | ```yaml | |
| 155 | + | deploy: | |
| 156 | + | stage: deploy | |
| 157 | + | image: docker:24 | |
| 158 | + | services: | |
| 159 | + | - docker:24-dind | |
| 160 | + | script: | |
| 161 | + | - echo "$GORDON_TOKEN" | docker login "$GORDON_REGISTRY" -u "$GORDON_USERNAME" --password-stdin | |
| 162 | + | - docker build -t "$GORDON_REGISTRY/myapp:$CI_COMMIT_TAG" . | |
| 163 | + | - docker push "$GORDON_REGISTRY/myapp:$CI_COMMIT_TAG" | |
| 164 | + | rules: | |
| 165 | + | - if: $CI_COMMIT_TAG | |
| 166 | + | ``` | |
| 167 | + | ||
| 168 | + | This requires additional CI/CD variables: `GORDON_REGISTRY` and `GORDON_USERNAME`. | |
| 169 | + | Gordon auto-deploys when it receives the image. | |
| 170 | + | ||
| 171 | + | ## Version Detection | |
| 172 | + | ||
| 173 | + | Gordon automatically detects the version tag from GitLab CI environment variables: | |
| 174 | + | ||
| 175 | + | | Variable | Example | Result | | |
| 176 | + | |----------|---------|--------| | |
| 177 | + | | `$CI_COMMIT_TAG` | `v1.2.0` | Tag `v1.2.0` | | |
| 178 | + | | No tag | - | Falls back to `git describe` or `latest` | | |
| 179 | + | ||
| 180 | + | ## Troubleshooting | |
| 181 | + | ||
| 182 | + | ### Authentication Failed | |
| 183 | + | ||
| 184 | + | ``` | |
| 185 | + | Error: failed to verify token | |
| 186 | + | ``` | |
| 187 | + | ||
| 188 | + | Verify `GORDON_TOKEN` is correctly set and the token has not been revoked. | |
| 189 | + | ||
| 190 | + | ### Docker-in-Docker Issues | |
| 191 | + | ||
| 192 | + | If using `docker:dind`, ensure the service is properly configured: | |
| 193 | + | ||
| 194 | + | ```yaml | |
| 195 | + | services: | |
| 196 | + | - docker:24-dind | |
| 197 | + | variables: | |
| 198 | + | DOCKER_TLS_CERTDIR: "/certs" | |
| 199 | + | ``` | |
| 200 | + | ||
| 201 | + | ### Build Context | |
| 202 | + | ||
| 203 | + | GitLab CI clones your repository automatically. The build context defaults to the repository root. | |
| 204 | + | ||
| 205 | + | ## Related | |
| 206 | + | ||
| 207 | + | - [GitHub Actions](./github-actions.md) | |
| 208 | + | - [Generic CI](./generic-ci.md) | |
| 209 | + | - [Deployment Overview](./index.md) | |
| 210 | + | - [Authentication](../config/auth.md) | |
| 211 | + | - [Push Command](../cli/push.md) | |
| @@ -0,0 +1,211 @@ | |||||
| 1 | + | # GitLab CI/CD Deployment | |||
| 2 | + | ||||
| 3 | + | Automated deployment with GitLab CI/CD using Gordon's push command. | |||
| 4 | + | ||||
| 5 | + | ## Prerequisites | |||
| 6 | + | ||||
| 7 | + | 1. Gordon server running with registry authentication enabled | |||
| 8 | + | 2. Generated deployment token | |||
| 9 | + | 3. GitLab CI/CD variables configured | |||
| 10 | + | ||||
| 11 | + | ## Quick Setup | |||
| 12 | + | ||||
| 13 | + | ### 1. Generate Deployment Token | |||
| 14 | + | ||||
| 15 | + | On your Gordon server: | |||
| 16 | + | ||||
| 17 | + | ```bash | |||
| 18 | + | gordon auth token generate \ | |||
| 19 | + | --subject gitlab-ci \ | |||
| 20 | + | --scopes "push,pull,admin:routes:read,admin:config:write" \ | |||
| 21 | + | --expiry 0 | |||
| 22 | + | ``` | |||
| 23 | + | ||||
| 24 | + | ### 2. Add CI/CD Variables | |||
| 25 | + | ||||
| 26 | + | In your project: Settings > CI/CD > Variables | |||
| 27 | + | ||||
| 28 | + | | Variable | Value | Protected | Masked | | |||
| 29 | + | |----------|-------|-----------|--------| | |||
| 30 | + | | `GORDON_TOKEN` | The generated token | Yes | Yes | | |||
| 31 | + | | `GORDON_REMOTE` | `https://gordon.example.com` | Yes | No | | |||
| 32 | + | ||||
| 33 | + | ### 3. Create Pipeline | |||
| 34 | + | ||||
| 35 | + | Create `.gitlab-ci.yml`: | |||
| 36 | + | ||||
| 37 | + | ```yaml | |||
| 38 | + | deploy: | |||
| 39 | + | stage: deploy | |||
| 40 | + | image: docker:24 | |||
| 41 | + | services: | |||
| 42 | + | - docker:24-dind | |||
| 43 | + | before_script: | |||
| 44 | + | - apk add --no-cache curl | |||
| 45 | + | - curl -fsSL https://github.com/bnema/gordon/releases/latest/download/gordon_linux_amd64 -o /usr/local/bin/gordon | |||
| 46 | + | - chmod +x /usr/local/bin/gordon | |||
| 47 | + | script: | |||
| 48 | + | - gordon push --build | |||
| 49 | + | --remote "$GORDON_REMOTE" | |||
| 50 | + | --token "$GORDON_TOKEN" | |||
| 51 | + | --no-confirm | |||
| 52 | + | only: | |||
| 53 | + | - tags | |||
| 54 | + | ``` | |||
| 55 | + | ||||
| 56 | + | Gordon auto-detects the version from `$CI_COMMIT_TAG`. | |||
| 57 | + | ||||
| 58 | + | ## Pipeline Examples | |||
| 59 | + | ||||
| 60 | + | ### Deploy on Tag Push | |||
| 61 | + | ||||
| 62 | + | ```yaml | |||
| 63 | + | stages: | |||
| 64 | + | - deploy | |||
| 65 | + | ||||
| 66 | + | deploy: | |||
| 67 | + | stage: deploy | |||
| 68 | + | image: docker:24 | |||
| 69 | + | services: | |||
| 70 | + | - docker:24-dind | |||
| 71 | + | before_script: | |||
| 72 | + | - apk add --no-cache curl | |||
| 73 | + | - curl -fsSL https://github.com/bnema/gordon/releases/latest/download/gordon_linux_amd64 -o /usr/local/bin/gordon | |||
| 74 | + | - chmod +x /usr/local/bin/gordon | |||
| 75 | + | script: | |||
| 76 | + | - gordon push --build | |||
| 77 | + | --remote "$GORDON_REMOTE" | |||
| 78 | + | --token "$GORDON_TOKEN" | |||
| 79 | + | --no-confirm | |||
| 80 | + | rules: | |||
| 81 | + | - if: $CI_COMMIT_TAG | |||
| 82 | + | ``` | |||
| 83 | + | ||||
| 84 | + | ### Continuous Deployment | |||
| 85 | + | ||||
| 86 | + | Deploy on every push to main: | |||
| 87 | + | ||||
| 88 | + | ```yaml | |||
| 89 | + | deploy: | |||
| 90 | + | stage: deploy | |||
| 91 | + | image: docker:24 | |||
| 92 | + | services: | |||
| 93 | + | - docker:24-dind | |||
| 94 | + | before_script: | |||
| 95 | + | - apk add --no-cache curl | |||
| 96 | + | - curl -fsSL https://github.com/bnema/gordon/releases/latest/download/gordon_linux_amd64 -o /usr/local/bin/gordon | |||
| 97 | + | - chmod +x /usr/local/bin/gordon | |||
| 98 | + | script: | |||
| 99 | + | - gordon push --build | |||
| 100 | + | --remote "$GORDON_REMOTE" | |||
| 101 | + | --token "$GORDON_TOKEN" | |||
| 102 | + | --no-confirm | |||
| 103 | + | rules: | |||
| 104 | + | - if: $CI_COMMIT_BRANCH == "main" | |||
| 105 | + | ``` | |||
| 106 | + | ||||
| 107 | + | ### Manual Deployment | |||
| 108 | + | ||||
| 109 | + | ```yaml | |||
| 110 | + | deploy: | |||
| 111 | + | stage: deploy | |||
| 112 | + | image: docker:24 | |||
| 113 | + | services: | |||
| 114 | + | - docker:24-dind | |||
| 115 | + | before_script: | |||
| 116 | + | - apk add --no-cache curl | |||
| 117 | + | - curl -fsSL https://github.com/bnema/gordon/releases/latest/download/gordon_linux_amd64 -o /usr/local/bin/gordon | |||
| 118 | + | - chmod +x /usr/local/bin/gordon | |||
| 119 | + | script: | |||
| 120 | + | - gordon push --build | |||
| 121 | + | --remote "$GORDON_REMOTE" | |||
| 122 | + | --token "$GORDON_TOKEN" | |||
| 123 | + | --no-confirm | |||
| 124 | + | when: manual | |||
| 125 | + | ``` | |||
| 126 | + | ||||
| 127 | + | ### With Build Arguments | |||
| 128 | + | ||||
| 129 | + | ```yaml | |||
| 130 | + | deploy: | |||
| 131 | + | stage: deploy | |||
| 132 | + | image: docker:24 | |||
| 133 | + | services: | |||
| 134 | + | - docker:24-dind | |||
| 135 | + | before_script: | |||
| 136 | + | - apk add --no-cache curl | |||
| 137 | + | - curl -fsSL https://github.com/bnema/gordon/releases/latest/download/gordon_linux_amd64 -o /usr/local/bin/gordon | |||
| 138 | + | - chmod +x /usr/local/bin/gordon | |||
| 139 | + | script: | |||
| 140 | + | - gordon push --build | |||
| 141 | + | --remote "$GORDON_REMOTE" | |||
| 142 | + | --token "$GORDON_TOKEN" | |||
| 143 | + | --build-arg NODE_ENV=production | |||
| 144 | + | --build-arg API_URL=https://api.example.com | |||
| 145 | + | --no-confirm | |||
| 146 | + | rules: | |||
| 147 | + | - if: $CI_COMMIT_TAG | |||
| 148 | + | ``` | |||
| 149 | + | ||||
| 150 | + | ## Alternative: Docker-based Pipeline | |||
| 151 | + | ||||
| 152 | + | For environments where installing the Gordon binary is not desired: | |||
| 153 | + | ||||
| 154 | + | ```yaml | |||
| 155 | + | deploy: | |||
| 156 | + | stage: deploy | |||
| 157 | + | image: docker:24 | |||
| 158 | + | services: | |||
| 159 | + | - docker:24-dind | |||
| 160 | + | script: | |||
| 161 | + | - echo "$GORDON_TOKEN" | docker login "$GORDON_REGISTRY" -u "$GORDON_USERNAME" --password-stdin | |||
| 162 | + | - docker build -t "$GORDON_REGISTRY/myapp:$CI_COMMIT_TAG" . | |||
| 163 | + | - docker push "$GORDON_REGISTRY/myapp:$CI_COMMIT_TAG" | |||
| 164 | + | rules: | |||
| 165 | + | - if: $CI_COMMIT_TAG | |||
| 166 | + | ``` | |||
| 167 | + | ||||
| 168 | + | This requires additional CI/CD variables: `GORDON_REGISTRY` and `GORDON_USERNAME`. | |||
| 169 | + | Gordon auto-deploys when it receives the image. | |||
| 170 | + | ||||
| 171 | + | ## Version Detection | |||
| 172 | + | ||||
| 173 | + | Gordon automatically detects the version tag from GitLab CI environment variables: | |||
| 174 | + | ||||
| 175 | + | | Variable | Example | Result | | |||
| 176 | + | |----------|---------|--------| | |||
| 177 | + | | `$CI_COMMIT_TAG` | `v1.2.0` | Tag `v1.2.0` | | |||
| 178 | + | | No tag | - | Falls back to `git describe` or `latest` | | |||
| 179 | + | ||||
| 180 | + | ## Troubleshooting | |||
| 181 | + | ||||
| 182 | + | ### Authentication Failed | |||
| 183 | + | ||||
| 184 | + | ``` | |||
| 185 | + | Error: failed to verify token | |||
| 186 | + | ``` | |||
| 187 | + | ||||
| 188 | + | Verify `GORDON_TOKEN` is correctly set and the token has not been revoked. | |||
| 189 | + | ||||
| 190 | + | ### Docker-in-Docker Issues | |||
| 191 | + | ||||
| 192 | + | If using `docker:dind`, ensure the service is properly configured: | |||
| 193 | + | ||||
| 194 | + | ```yaml | |||
| 195 | + | services: | |||
| 196 | + | - docker:24-dind | |||
| 197 | + | variables: | |||
| 198 | + | DOCKER_TLS_CERTDIR: "/certs" | |||
| 199 | + | ``` | |||
| 200 | + | ||||
| 201 | + | ### Build Context | |||
| 202 | + | ||||
| 203 | + | GitLab CI clones your repository automatically. The build context defaults to the repository root. | |||
| 204 | + | ||||
| 205 | + | ## Related | |||
| 206 | + | ||||
| 207 | + | - [GitHub Actions](./github-actions.md) | |||
| 208 | + | - [Generic CI](./generic-ci.md) | |||
| 209 | + | - [Deployment Overview](./index.md) | |||
| 210 | + | - [Authentication](../config/auth.md) | |||
| 211 | + | - [Push Command](../cli/push.md) | |||
M docs/deployment/index.md +118 -116
| @@ -1,41 +1,120 @@ | |||
| 1 | 1 | # Deployment Overview | |
| 2 | 2 | ||
| 3 | - | Strategies for deploying applications with Gordon. | |
| 3 | + | Gordon deploys containers when you push images to its built-in registry. Three deployment methods are available depending on your workflow and infrastructure. | |
| 4 | 4 | ||
| 5 | - | ## Deployment Methods | |
| 5 | + | ## Recommended: gordon push | |
| 6 | 6 | ||
| 7 | - | | Method | Use Case | | |
| 8 | - | |--------|----------| | |
| 9 | - | | Manual Push | Development, one-off deploys | | |
| 10 | - | | GitHub Actions | Automated CI/CD | | |
| 11 | - | | GitLab CI | Automated CI/CD | | |
| 12 | - | | Generic CI | Any CI/CD system | | |
| 7 | + | `gordon push --build --remote` is the simplest way to build, push, and deploy from CI/CD pipelines. | |
| 13 | 8 | ||
| 14 | - | ## Basic Deployment Workflow | |
| 15 | - | ||
| 16 | - | ### 1. Build Locally | |
| 9 | + | - Single command: build + push + deploy | |
| 10 | + | - Single secret (`GORDON_TOKEN`): auto-exchanges for a short-lived registry token | |
| 11 | + | - Auto-detects version from CI environment (`$GITHUB_REF`, `$CI_COMMIT_TAG`, `$BUILD_SOURCEBRANCH`, or `git describe`) | |
| 12 | + | - Chunked uploads (50MB chunks) — works behind Cloudflare and restrictive proxies | |
| 17 | 13 | ||
| 18 | 14 | ```bash | |
| 19 | - | docker build -t myapp . | |
| 15 | + | gordon push --build --remote https://gordon.example.com --no-confirm | |
| 20 | 16 | ``` | |
| 21 | 17 | ||
| 22 | - | ### 2. Tag for Registry | |
| 18 | + | ## All Deployment Methods | |
| 19 | + | ||
| 20 | + | | Method | Best For | Secrets Needed | Registry Access | Deploy Control | | |
| 21 | + | |--------|----------|----------------|-----------------|----------------| | |
| 22 | + | | `gordon push` (Recommended) | CI/CD pipelines | 1 (`GORDON_TOKEN`) | Via gordon domain (HTTPS) | Explicit (CLI-triggered) | | |
| 23 | + | | `docker push` | Simple setups, existing Docker workflows | 2 (`username` + `token`) | Via gordon domain (HTTPS) | Automatic (event-based) | | |
| 24 | + | | Docker labels + auto-route | GitOps, zero-config deploys | 2 (`username` + `token`) | Via gordon domain (HTTPS) | Automatic (label-driven) | | |
| 25 | + | ||
| 26 | + | ### Method 1: gordon push (Recommended) | |
| 27 | + | ||
| 28 | + | The Gordon CLI handles authentication, image building, and deployment in a single step. | |
| 29 | + | ||
| 30 | + | - Single token handles everything: admin API access + registry auth via automatic token exchange | |
| 31 | + | - Version tag auto-detected from `$GITHUB_REF`, `$CI_COMMIT_TAG`, `$BUILD_SOURCEBRANCH`, or `git describe` | |
| 32 | + | - Use `--no-deploy` for push-only workflows (useful for staging images without triggering deployment) | |
| 33 | + | - Requires the Gordon binary on the CI runner | |
| 23 | 34 | ||
| 24 | 35 | ```bash | |
| 25 | - | docker tag myapp registry.mydomain.com/myapp:latest | |
| 36 | + | # Build, push, and deploy | |
| 37 | + | gordon push --build --remote https://gordon.example.com --no-confirm | |
| 38 | + | ||
| 39 | + | # Push only, no deploy | |
| 40 | + | gordon push --build --remote https://gordon.example.com --no-deploy --no-confirm | |
| 26 | 41 | ``` | |
| 27 | 42 | ||
| 28 | - | ### 3. Push to Deploy | |
| 43 | + | ### Method 2: docker push | |
| 44 | + | ||
| 45 | + | Standard Docker workflow — no Gordon binary required on the runner. | |
| 46 | + | ||
| 47 | + | - Use `docker login`, `docker build`, and `docker push` as usual | |
| 48 | + | - Gordon auto-deploys when it receives the image (event-based, no explicit trigger needed) | |
| 49 | + | - No Gordon binary needed on the CI runner | |
| 50 | + | - Registry endpoint is `gordon.example.com` (not a separate registry host) | |
| 29 | 51 | ||
| 30 | 52 | ```bash | |
| 31 | - | docker push registry.mydomain.com/myapp:latest | |
| 53 | + | echo "$GORDON_TOKEN" | docker login -u ci-bot --password-stdin gordon.example.com | |
| 54 | + | docker build -t gordon.example.com/myapp:v1.2.0 . | |
| 55 | + | docker push gordon.example.com/myapp:v1.2.0 | |
| 32 | 56 | ``` | |
| 33 | 57 | ||
| 34 | - | Gordon automatically: | |
| 35 | - | 1. Receives the image | |
| 36 | - | 2. Deploys a new container | |
| 37 | - | 3. Routes traffic to it | |
| 38 | - | 4. Stops the old container | |
| 58 | + | ### Method 3: Docker labels + auto-route | |
| 59 | + | ||
| 60 | + | Add a `gordon.domain` label to your image and Gordon creates the route automatically on push. | |
| 61 | + | ||
| 62 | + | - Add `gordon.domain=app.example.com` label to your Dockerfile | |
| 63 | + | - Push image — Gordon creates or updates the route without manual config | |
| 64 | + | - Gated by the `auto_route_allowed_domains` allowlist in `gordon.toml` | |
| 65 | + | - Best for GitOps workflows where routes are defined alongside the application | |
| 66 | + | ||
| 67 | + | ```dockerfile | |
| 68 | + | LABEL gordon.domain="app.example.com" | |
| 69 | + | ``` | |
| 70 | + | ||
| 71 | + | See [Auto-Route](../config/auto-route.md) for configuration details. | |
| 72 | + | ||
| 73 | + | ## Registry Access | |
| 74 | + | ||
| 75 | + | Gordon's registry is served through the main gordon domain over HTTPS on port 443. The internal registry port (5000) is never exposed externally. All push methods use `https://gordon.example.com/v2/...`. | |
| 76 | + | ||
| 77 | + | ### Network Topologies | |
| 78 | + | ||
| 79 | + | | Setup | Configuration | Use Case | | |
| 80 | + | |-------|--------------|----------| | |
| 81 | + | | Public (default) | `auth.enabled = true` | Hosted CI runners (GitHub Actions, GitLab CI) | | |
| 82 | + | | Tailscale only | `registry_allowed_ips = ["100.64.0.0/10"]` | Self-hosted runners in Tailnet | | |
| 83 | + | | Localhost only | `auth.enabled = false` | Local development, single-machine deploys | | |
| 84 | + | ||
| 85 | + | ## Token Setup | |
| 86 | + | ||
| 87 | + | See the examples below for the right scopes for each workflow. | |
| 88 | + | ||
| 89 | + | ```bash | |
| 90 | + | # Minimum scope for gordon push — route lookup + registry push | |
| 91 | + | # Server auto-deploys when it receives the image | |
| 92 | + | gordon auth token generate \ | |
| 93 | + | --subject ci-bot \ | |
| 94 | + | --scopes "push,pull,admin:routes:read" \ | |
| 95 | + | --expiry 90d | |
| 96 | + | ||
| 97 | + | # With explicit CLI-managed deploy (adds config:write for deploy control) | |
| 98 | + | gordon auth token generate \ | |
| 99 | + | --subject ci-bot \ | |
| 100 | + | --scopes "push,pull,admin:routes:read,admin:config:write" \ | |
| 101 | + | --expiry 90d | |
| 102 | + | ||
| 103 | + | # Scoped to a specific repository | |
| 104 | + | gordon auth token generate \ | |
| 105 | + | --subject ci-bot \ | |
| 106 | + | --repo myapp \ | |
| 107 | + | --scopes "push,pull,admin:routes:read" \ | |
| 108 | + | --expiry 90d | |
| 109 | + | ||
| 110 | + | # For docker push — registry scopes only | |
| 111 | + | gordon auth token generate \ | |
| 112 | + | --subject ci-bot \ | |
| 113 | + | --scopes "push,pull" \ | |
| 114 | + | --expiry 90d | |
| 115 | + | ``` | |
| 116 | + | ||
| 117 | + | Set the generated token as `GORDON_TOKEN` in your CI environment. | |
| 39 | 118 | ||
| 40 | 119 | ## Version Strategies | |
| 41 | 120 | ||
| @@ -44,54 +123,48 @@ Gordon automatically: | |||
| 44 | 123 | Always deploy the most recent build: | |
| 45 | 124 | ||
| 46 | 125 | ```bash | |
| 47 | - | docker tag myapp registry.mydomain.com/myapp:latest | |
| 48 | - | docker push registry.mydomain.com/myapp:latest | |
| 126 | + | docker tag myapp gordon.example.com/myapp:latest | |
| 127 | + | docker push gordon.example.com/myapp:latest | |
| 49 | 128 | ``` | |
| 50 | 129 | ||
| 51 | - | Configure route: | |
| 52 | 130 | ```toml | |
| 53 | 131 | [routes] | |
| 54 | - | "app.mydomain.com" = "myapp:latest" | |
| 132 | + | "app.example.com" = "myapp:latest" | |
| 55 | 133 | ``` | |
| 56 | 134 | ||
| 57 | 135 | ### Semantic Versioning | |
| 58 | 136 | ||
| 59 | - | Pin to specific versions: | |
| 137 | + | Pin routes to specific versions and update config to roll forward: | |
| 60 | 138 | ||
| 61 | 139 | ```bash | |
| 62 | - | docker tag myapp registry.mydomain.com/myapp:v2.1.0 | |
| 63 | - | docker push registry.mydomain.com/myapp:v2.1.0 | |
| 140 | + | docker tag myapp gordon.example.com/myapp:v2.1.0 | |
| 141 | + | docker push gordon.example.com/myapp:v2.1.0 | |
| 64 | 142 | ``` | |
| 65 | 143 | ||
| 66 | - | Configure route: | |
| 67 | 144 | ```toml | |
| 68 | 145 | [routes] | |
| 69 | - | "app.mydomain.com" = "myapp:v2.1.0" | |
| 146 | + | "app.example.com" = "myapp:v2.1.0" | |
| 70 | 147 | ``` | |
| 71 | 148 | ||
| 72 | - | Update config to deploy new version: | |
| 73 | - | ```toml | |
| 74 | - | [routes] | |
| 75 | - | "app.mydomain.com" = "myapp:v2.2.0" | |
| 76 | - | ``` | |
| 149 | + | To deploy a new version, update the tag in `gordon.toml` and push the new image. | |
| 77 | 150 | ||
| 78 | 151 | ### Git SHA Tags | |
| 79 | 152 | ||
| 80 | - | Tag with commit hash for traceability: | |
| 153 | + | Tag with commit hash for full traceability: | |
| 81 | 154 | ||
| 82 | 155 | ```bash | |
| 83 | 156 | VERSION=$(git rev-parse --short HEAD) | |
| 84 | - | docker tag myapp registry.mydomain.com/myapp:$VERSION | |
| 85 | - | docker push registry.mydomain.com/myapp:$VERSION | |
| 157 | + | docker tag myapp gordon.example.com/myapp:$VERSION | |
| 158 | + | docker push gordon.example.com/myapp:$VERSION | |
| 86 | 159 | ``` | |
| 87 | 160 | ||
| 88 | 161 | ## Zero-Downtime Deployment | |
| 89 | 162 | ||
| 90 | 163 | Gordon performs zero-downtime deployments by default: | |
| 91 | 164 | ||
| 92 | - | 1. **New container starts** while old is still running | |
| 93 | - | 2. **Health check** waits for new container readiness | |
| 94 | - | 3. **Traffic switches** to new container | |
| 165 | + | 1. **New container starts** while the old one is still running | |
| 166 | + | 2. **Health check** waits for the new container to become ready | |
| 167 | + | 3. **Traffic switches** to the new container | |
| 95 | 168 | 4. **Old container stops** after traffic moves | |
| 96 | 169 | ||
| 97 | 170 | ``` | |
| @@ -103,83 +176,12 @@ New Container: [══════════════════ | |||
| 103 | 176 | ↑ start ↑ traffic routed | |
| 104 | 177 | ``` | |
| 105 | 178 | ||
| 106 | - | ## Environment-Specific Deployments | |
| 107 | - | ||
| 108 | - | ### Development | |
| 109 | - | ||
| 110 | - | ```toml | |
| 111 | - | [routes] | |
| 112 | - | "dev.mydomain.com" = "myapp:latest" | |
| 113 | - | ``` | |
| 114 | - | ||
| 115 | - | ```bash | |
| 116 | - | docker push registry.mydomain.com/myapp:latest | |
| 117 | - | ``` | |
| 118 | - | ||
| 119 | - | ### Staging | |
| 120 | - | ||
| 121 | - | ```toml | |
| 122 | - | [routes] | |
| 123 | - | "staging.mydomain.com" = "myapp:staging" | |
| 124 | - | ``` | |
| 125 | - | ||
| 126 | - | ```bash | |
| 127 | - | docker tag myapp registry.mydomain.com/myapp:staging | |
| 128 | - | docker push registry.mydomain.com/myapp:staging | |
| 129 | - | ``` | |
| 130 | - | ||
| 131 | - | ### Production | |
| 132 | - | ||
| 133 | - | ```toml | |
| 134 | - | [routes] | |
| 135 | - | "app.mydomain.com" = "myapp:v2.1.0" | |
| 136 | - | ``` | |
| 137 | - | ||
| 138 | - | ```bash | |
| 139 | - | docker tag myapp registry.mydomain.com/myapp:v2.1.0 | |
| 140 | - | docker push registry.mydomain.com/myapp:v2.1.0 | |
| 141 | - | # Then update gordon.toml and reload | |
| 142 | - | ``` | |
| 143 | - | ||
| 144 | - | ## Deployment Checklist | |
| 145 | - | ||
| 146 | - | Before deploying: | |
| 147 | - | ||
| 148 | - | - [ ] Build passes locally | |
| 149 | - | - [ ] Tests pass | |
| 150 | - | - [ ] Environment file configured (`~/.gordon/env/app_mydomain_com.env`) | |
| 151 | - | - [ ] DNS pointing to Gordon server | |
| 152 | - | - [ ] Route configured in `gordon.toml` | |
| 153 | - | ||
| 154 | - | After deploying: | |
| 155 | - | ||
| 156 | - | - [ ] Container starts successfully | |
| 157 | - | - [ ] Application responds to requests | |
| 158 | - | - [ ] Logs show no errors | |
| 159 | - | - [ ] Health checks pass | |
| 160 | - | ||
| 161 | - | ## Monitoring Deployments | |
| 162 | - | ||
| 163 | - | ### Watch Logs | |
| 164 | - | ||
| 165 | - | ```bash | |
| 166 | - | gordon logs -f | |
| 167 | - | ``` | |
| 168 | - | ||
| 169 | - | ### Check Container Status | |
| 170 | - | ||
| 171 | - | ```bash | |
| 172 | - | docker ps | grep gordon | |
| 173 | - | ``` | |
| 174 | - | ||
| 175 | - | ### Verify Routing | |
| 176 | - | ||
| 177 | - | ```bash | |
| 178 | - | curl -I https://app.mydomain.com | |
| 179 | - | ``` | |
| 180 | - | ||
| 181 | 179 | ## Related | |
| 182 | 180 | ||
| 183 | 181 | - [GitHub Actions](./github-actions.md) | |
| 182 | + | - [GitLab CI](./gitlab-ci.md) | |
| 183 | + | - [Generic CI](./generic-ci.md) | |
| 184 | 184 | - [Rollback](./rollback.md) | |
| 185 | 185 | - [Routes Configuration](../config/routes.md) | |
| 186 | + | - [Authentication](../config/auth.md) | |
| 187 | + | - [Auto-Route](../config/auto-route.md) | |
| @@ -1,41 +1,120 @@ | |||||
| 1 | # Deployment Overview | 1 | # Deployment Overview | ||
| 2 | 2 | ||||
| 3 | - | Strategies for deploying applications with Gordon. | 3 | + | Gordon deploys containers when you push images to its built-in registry. Three deployment methods are available depending on your workflow and infrastructure. |
| 4 | 4 | ||||
| 5 | - | ## Deployment Methods | 5 | + | ## Recommended: gordon push |
| 6 | 6 | ||||
| 7 | - | | Method | Use Case | | 7 | + | `gordon push --build --remote` is the simplest way to build, push, and deploy from CI/CD pipelines. |
| 8 | - | |--------|----------| | |||
| 9 | - | | Manual Push | Development, one-off deploys | | |||
| 10 | - | | GitHub Actions | Automated CI/CD | | |||
| 11 | - | | GitLab CI | Automated CI/CD | | |||
| 12 | - | | Generic CI | Any CI/CD system | | |||
| 13 | 8 | ||||
| 14 | - | ## Basic Deployment Workflow | 9 | + | - Single command: build + push + deploy |
| 15 | - | 10 | + | - Single secret (`GORDON_TOKEN`): auto-exchanges for a short-lived registry token | |
| 16 | - | ### 1. Build Locally | 11 | + | - Auto-detects version from CI environment (`$GITHUB_REF`, `$CI_COMMIT_TAG`, `$BUILD_SOURCEBRANCH`, or `git describe`) |
| 12 | + | - Chunked uploads (50MB chunks) — works behind Cloudflare and restrictive proxies | |||
| 17 | 13 | ||||
| 18 | ```bash | 14 | ```bash | ||
| 19 | - | docker build -t myapp . | 15 | + | gordon push --build --remote https://gordon.example.com --no-confirm |
| 20 | ``` | 16 | ``` | ||
| 21 | 17 | ||||
| 22 | - | ### 2. Tag for Registry | 18 | + | ## All Deployment Methods |
| 19 | + | ||||
| 20 | + | | Method | Best For | Secrets Needed | Registry Access | Deploy Control | | |||
| 21 | + | |--------|----------|----------------|-----------------|----------------| | |||
| 22 | + | | `gordon push` (Recommended) | CI/CD pipelines | 1 (`GORDON_TOKEN`) | Via gordon domain (HTTPS) | Explicit (CLI-triggered) | | |||
| 23 | + | | `docker push` | Simple setups, existing Docker workflows | 2 (`username` + `token`) | Via gordon domain (HTTPS) | Automatic (event-based) | | |||
| 24 | + | | Docker labels + auto-route | GitOps, zero-config deploys | 2 (`username` + `token`) | Via gordon domain (HTTPS) | Automatic (label-driven) | | |||
| 25 | + | ||||
| 26 | + | ### Method 1: gordon push (Recommended) | |||
| 27 | + | ||||
| 28 | + | The Gordon CLI handles authentication, image building, and deployment in a single step. | |||
| 29 | + | ||||
| 30 | + | - Single token handles everything: admin API access + registry auth via automatic token exchange | |||
| 31 | + | - Version tag auto-detected from `$GITHUB_REF`, `$CI_COMMIT_TAG`, `$BUILD_SOURCEBRANCH`, or `git describe` | |||
| 32 | + | - Use `--no-deploy` for push-only workflows (useful for staging images without triggering deployment) | |||
| 33 | + | - Requires the Gordon binary on the CI runner | |||
| 23 | 34 | ||||
| 24 | ```bash | 35 | ```bash | ||
| 25 | - | docker tag myapp registry.mydomain.com/myapp:latest | 36 | + | # Build, push, and deploy |
| 37 | + | gordon push --build --remote https://gordon.example.com --no-confirm | |||
| 38 | + | ||||
| 39 | + | # Push only, no deploy | |||
| 40 | + | gordon push --build --remote https://gordon.example.com --no-deploy --no-confirm | |||
| 26 | ``` | 41 | ``` | ||
| 27 | 42 | ||||
| 28 | - | ### 3. Push to Deploy | 43 | + | ### Method 2: docker push |
| 44 | + | ||||
| 45 | + | Standard Docker workflow — no Gordon binary required on the runner. | |||
| 46 | + | ||||
| 47 | + | - Use `docker login`, `docker build`, and `docker push` as usual | |||
| 48 | + | - Gordon auto-deploys when it receives the image (event-based, no explicit trigger needed) | |||
| 49 | + | - No Gordon binary needed on the CI runner | |||
| 50 | + | - Registry endpoint is `gordon.example.com` (not a separate registry host) | |||
| 29 | 51 | ||||
| 30 | ```bash | 52 | ```bash | ||
| 31 | - | docker push registry.mydomain.com/myapp:latest | 53 | + | echo "$GORDON_TOKEN" | docker login -u ci-bot --password-stdin gordon.example.com |
| 54 | + | docker build -t gordon.example.com/myapp:v1.2.0 . | |||
| 55 | + | docker push gordon.example.com/myapp:v1.2.0 | |||
| 32 | ``` | 56 | ``` | ||
| 33 | 57 | ||||
| 34 | - | Gordon automatically: | 58 | + | ### Method 3: Docker labels + auto-route |
| 35 | - | 1. Receives the image | 59 | + | |
| 36 | - | 2. Deploys a new container | 60 | + | Add a `gordon.domain` label to your image and Gordon creates the route automatically on push. |
| 37 | - | 3. Routes traffic to it | 61 | + | |
| 38 | - | 4. Stops the old container | 62 | + | - Add `gordon.domain=app.example.com` label to your Dockerfile |
| 63 | + | - Push image — Gordon creates or updates the route without manual config | |||
| 64 | + | - Gated by the `auto_route_allowed_domains` allowlist in `gordon.toml` | |||
| 65 | + | - Best for GitOps workflows where routes are defined alongside the application | |||
| 66 | + | ||||
| 67 | + | ```dockerfile | |||
| 68 | + | LABEL gordon.domain="app.example.com" | |||
| 69 | + | ``` | |||
| 70 | + | ||||
| 71 | + | See [Auto-Route](../config/auto-route.md) for configuration details. | |||
| 72 | + | ||||
| 73 | + | ## Registry Access | |||
| 74 | + | ||||
| 75 | + | Gordon's registry is served through the main gordon domain over HTTPS on port 443. The internal registry port (5000) is never exposed externally. All push methods use `https://gordon.example.com/v2/...`. | |||
| 76 | + | ||||
| 77 | + | ### Network Topologies | |||
| 78 | + | ||||
| 79 | + | | Setup | Configuration | Use Case | | |||
| 80 | + | |-------|--------------|----------| | |||
| 81 | + | | Public (default) | `auth.enabled = true` | Hosted CI runners (GitHub Actions, GitLab CI) | | |||
| 82 | + | | Tailscale only | `registry_allowed_ips = ["100.64.0.0/10"]` | Self-hosted runners in Tailnet | | |||
| 83 | + | | Localhost only | `auth.enabled = false` | Local development, single-machine deploys | | |||
| 84 | + | ||||
| 85 | + | ## Token Setup | |||
| 86 | + | ||||
| 87 | + | See the examples below for the right scopes for each workflow. | |||
| 88 | + | ||||
| 89 | + | ```bash | |||
| 90 | + | # Minimum scope for gordon push — route lookup + registry push | |||
| 91 | + | # Server auto-deploys when it receives the image | |||
| 92 | + | gordon auth token generate \ | |||
| 93 | + | --subject ci-bot \ | |||
| 94 | + | --scopes "push,pull,admin:routes:read" \ | |||
| 95 | + | --expiry 90d | |||
| 96 | + | ||||
| 97 | + | # With explicit CLI-managed deploy (adds config:write for deploy control) | |||
| 98 | + | gordon auth token generate \ | |||
| 99 | + | --subject ci-bot \ | |||
| 100 | + | --scopes "push,pull,admin:routes:read,admin:config:write" \ | |||
| 101 | + | --expiry 90d | |||
| 102 | + | ||||
| 103 | + | # Scoped to a specific repository | |||
| 104 | + | gordon auth token generate \ | |||
| 105 | + | --subject ci-bot \ | |||
| 106 | + | --repo myapp \ | |||
| 107 | + | --scopes "push,pull,admin:routes:read" \ | |||
| 108 | + | --expiry 90d | |||
| 109 | + | ||||
| 110 | + | # For docker push — registry scopes only | |||
| 111 | + | gordon auth token generate \ | |||
| 112 | + | --subject ci-bot \ | |||
| 113 | + | --scopes "push,pull" \ | |||
| 114 | + | --expiry 90d | |||
| 115 | + | ``` | |||
| 116 | + | ||||
| 117 | + | Set the generated token as `GORDON_TOKEN` in your CI environment. | |||
| 39 | 118 | ||||
| 40 | ## Version Strategies | 119 | ## Version Strategies | ||
| 41 | 120 | ||||
| @@ -44,54 +123,48 @@ Gordon automatically: | |||||
| 44 | Always deploy the most recent build: | 123 | Always deploy the most recent build: | ||
| 45 | 124 | ||||
| 46 | ```bash | 125 | ```bash | ||
| 47 | - | docker tag myapp registry.mydomain.com/myapp:latest | 126 | + | docker tag myapp gordon.example.com/myapp:latest |
| 48 | - | docker push registry.mydomain.com/myapp:latest | 127 | + | docker push gordon.example.com/myapp:latest |
| 49 | ``` | 128 | ``` | ||
| 50 | 129 | ||||
| 51 | - | Configure route: | |||
| 52 | ```toml | 130 | ```toml | ||
| 53 | [routes] | 131 | [routes] | ||
| 54 | - | "app.mydomain.com" = "myapp:latest" | 132 | + | "app.example.com" = "myapp:latest" |
| 55 | ``` | 133 | ``` | ||
| 56 | 134 | ||||
| 57 | ### Semantic Versioning | 135 | ### Semantic Versioning | ||
| 58 | 136 | ||||
| 59 | - | Pin to specific versions: | 137 | + | Pin routes to specific versions and update config to roll forward: |
| 60 | 138 | ||||
| 61 | ```bash | 139 | ```bash | ||
| 62 | - | docker tag myapp registry.mydomain.com/myapp:v2.1.0 | 140 | + | docker tag myapp gordon.example.com/myapp:v2.1.0 |
| 63 | - | docker push registry.mydomain.com/myapp:v2.1.0 | 141 | + | docker push gordon.example.com/myapp:v2.1.0 |
| 64 | ``` | 142 | ``` | ||
| 65 | 143 | ||||
| 66 | - | Configure route: | |||
| 67 | ```toml | 144 | ```toml | ||
| 68 | [routes] | 145 | [routes] | ||
| 69 | - | "app.mydomain.com" = "myapp:v2.1.0" | 146 | + | "app.example.com" = "myapp:v2.1.0" |
| 70 | ``` | 147 | ``` | ||
| 71 | 148 | ||||
| 72 | - | Update config to deploy new version: | 149 | + | To deploy a new version, update the tag in `gordon.toml` and push the new image. |
| 73 | - | ```toml | |||
| 74 | - | [routes] | |||
| 75 | - | "app.mydomain.com" = "myapp:v2.2.0" | |||
| 76 | - | ``` | |||
| 77 | 150 | ||||
| 78 | ### Git SHA Tags | 151 | ### Git SHA Tags | ||
| 79 | 152 | ||||
| 80 | - | Tag with commit hash for traceability: | 153 | + | Tag with commit hash for full traceability: |
| 81 | 154 | ||||
| 82 | ```bash | 155 | ```bash | ||
| 83 | VERSION=$(git rev-parse --short HEAD) | 156 | VERSION=$(git rev-parse --short HEAD) | ||
| 84 | - | docker tag myapp registry.mydomain.com/myapp:$VERSION | 157 | + | docker tag myapp gordon.example.com/myapp:$VERSION |
| 85 | - | docker push registry.mydomain.com/myapp:$VERSION | 158 | + | docker push gordon.example.com/myapp:$VERSION |
| 86 | ``` | 159 | ``` | ||
| 87 | 160 | ||||
| 88 | ## Zero-Downtime Deployment | 161 | ## Zero-Downtime Deployment | ||
| 89 | 162 | ||||
| 90 | Gordon performs zero-downtime deployments by default: | 163 | Gordon performs zero-downtime deployments by default: | ||
| 91 | 164 | ||||
| 92 | - | 1. **New container starts** while old is still running | 165 | + | 1. **New container starts** while the old one is still running |
| 93 | - | 2. **Health check** waits for new container readiness | 166 | + | 2. **Health check** waits for the new container to become ready |
| 94 | - | 3. **Traffic switches** to new container | 167 | + | 3. **Traffic switches** to the new container |
| 95 | 4. **Old container stops** after traffic moves | 168 | 4. **Old container stops** after traffic moves | ||
| 96 | 169 | ||||
| 97 | ``` | 170 | ``` | ||
| @@ -103,83 +176,12 @@ New Container: [══════════════════ | |||||
| 103 | ↑ start ↑ traffic routed | 176 | ↑ start ↑ traffic routed | ||
| 104 | ``` | 177 | ``` | ||
| 105 | 178 | ||||
| 106 | - | ## Environment-Specific Deployments | |||
| 107 | - | ||||
| 108 | - | ### Development | |||
| 109 | - | ||||
| 110 | - | ```toml | |||
| 111 | - | [routes] | |||
| 112 | - | "dev.mydomain.com" = "myapp:latest" | |||
| 113 | - | ``` | |||
| 114 | - | ||||
| 115 | - | ```bash | |||
| 116 | - | docker push registry.mydomain.com/myapp:latest | |||
| 117 | - | ``` | |||
| 118 | - | ||||
| 119 | - | ### Staging | |||
| 120 | - | ||||
| 121 | - | ```toml | |||
| 122 | - | [routes] | |||
| 123 | - | "staging.mydomain.com" = "myapp:staging" | |||
| 124 | - | ``` | |||
| 125 | - | ||||
| 126 | - | ```bash | |||
| 127 | - | docker tag myapp registry.mydomain.com/myapp:staging | |||
| 128 | - | docker push registry.mydomain.com/myapp:staging | |||
| 129 | - | ``` | |||
| 130 | - | ||||
| 131 | - | ### Production | |||
| 132 | - | ||||
| 133 | - | ```toml | |||
| 134 | - | [routes] | |||
| 135 | - | "app.mydomain.com" = "myapp:v2.1.0" | |||
| 136 | - | ``` | |||
| 137 | - | ||||
| 138 | - | ```bash | |||
| 139 | - | docker tag myapp registry.mydomain.com/myapp:v2.1.0 | |||
| 140 | - | docker push registry.mydomain.com/myapp:v2.1.0 | |||
| 141 | - | # Then update gordon.toml and reload | |||
| 142 | - | ``` | |||
| 143 | - | ||||
| 144 | - | ## Deployment Checklist | |||
| 145 | - | ||||
| 146 | - | Before deploying: | |||
| 147 | - | ||||
| 148 | - | - [ ] Build passes locally | |||
| 149 | - | - [ ] Tests pass | |||
| 150 | - | - [ ] Environment file configured (`~/.gordon/env/app_mydomain_com.env`) | |||
| 151 | - | - [ ] DNS pointing to Gordon server | |||
| 152 | - | - [ ] Route configured in `gordon.toml` | |||
| 153 | - | ||||
| 154 | - | After deploying: | |||
| 155 | - | ||||
| 156 | - | - [ ] Container starts successfully | |||
| 157 | - | - [ ] Application responds to requests | |||
| 158 | - | - [ ] Logs show no errors | |||
| 159 | - | - [ ] Health checks pass | |||
| 160 | - | ||||
| 161 | - | ## Monitoring Deployments | |||
| 162 | - | ||||
| 163 | - | ### Watch Logs | |||
| 164 | - | ||||
| 165 | - | ```bash | |||
| 166 | - | gordon logs -f | |||
| 167 | - | ``` | |||
| 168 | - | ||||
| 169 | - | ### Check Container Status | |||
| 170 | - | ||||
| 171 | - | ```bash | |||
| 172 | - | docker ps | grep gordon | |||
| 173 | - | ``` | |||
| 174 | - | ||||
| 175 | - | ### Verify Routing | |||
| 176 | - | ||||
| 177 | - | ```bash | |||
| 178 | - | curl -I https://app.mydomain.com | |||
| 179 | - | ``` | |||
| 180 | - | ||||
| 181 | ## Related | 179 | ## Related | ||
| 182 | 180 | ||||
| 183 | - [GitHub Actions](./github-actions.md) | 181 | - [GitHub Actions](./github-actions.md) | ||
| 182 | + | - [GitLab CI](./gitlab-ci.md) | |||
| 183 | + | - [Generic CI](./generic-ci.md) | |||
| 184 | - [Rollback](./rollback.md) | 184 | - [Rollback](./rollback.md) | ||
| 185 | - [Routes Configuration](../config/routes.md) | 185 | - [Routes Configuration](../config/routes.md) | ||
| 186 | + | - [Authentication](../config/auth.md) | |||
| 187 | + | - [Auto-Route](../config/auto-route.md) | |||
M docs/index.md +3 -1
| @@ -60,8 +60,10 @@ Gordon runs on your VPS and provides: | |||
| 60 | 60 | ||
| 61 | 61 | ### Deployment | |
| 62 | 62 | ||
| 63 | - | - [Deployment Overview](./deployment/index.md) - Deployment strategies | |
| 63 | + | - [Deployment Overview](./deployment/index.md) - Deployment strategies and methods | |
| 64 | 64 | - [GitHub Actions](./deployment/github-actions.md) - CI/CD with GitHub | |
| 65 | + | - [GitLab CI](./deployment/gitlab-ci.md) - CI/CD with GitLab | |
| 66 | + | - [Generic CI](./deployment/generic-ci.md) - Jenkins, CircleCI, Drone, and others | |
| 65 | 67 | - [Rollback](./deployment/rollback.md) - Version management and rollback | |
| 66 | 68 | ||
| 67 | 69 | ### Reference | |
| @@ -60,8 +60,10 @@ Gordon runs on your VPS and provides: | |||||
| 60 | 60 | ||||
| 61 | ### Deployment | 61 | ### Deployment | ||
| 62 | 62 | ||||
| 63 | - | - [Deployment Overview](./deployment/index.md) - Deployment strategies | 63 | + | - [Deployment Overview](./deployment/index.md) - Deployment strategies and methods |
| 64 | - [GitHub Actions](./deployment/github-actions.md) - CI/CD with GitHub | 64 | - [GitHub Actions](./deployment/github-actions.md) - CI/CD with GitHub | ||
| 65 | + | - [GitLab CI](./deployment/gitlab-ci.md) - CI/CD with GitLab | |||
| 66 | + | - [Generic CI](./deployment/generic-ci.md) - Jenkins, CircleCI, Drone, and others | |||
| 65 | - [Rollback](./deployment/rollback.md) - Version management and rollback | 67 | - [Rollback](./deployment/rollback.md) - Version management and rollback | ||
| 66 | 68 | ||||
| 67 | ### Reference | 69 | ### Reference | ||
M internal/adapters/in/cli/auth.go +26 -11
| @@ -74,6 +74,7 @@ func newTokenGenerateCmd() *cobra.Command { | |||
| 74 | 74 | scopes string | |
| 75 | 75 | expiry string | |
| 76 | 76 | configPath string | |
| 77 | + | repo string | |
| 77 | 78 | ) | |
| 78 | 79 | ||
| 79 | 80 | cmd := &cobra.Command{ | |
| @@ -101,10 +102,15 @@ Admin scopes (for remote CLI access): | |||
| 101 | 102 | admin:routes:read Read-only routes access | |
| 102 | 103 | admin:status:read Read-only status access | |
| 103 | 104 | ||
| 105 | + | Repository scoping: | |
| 106 | + | --repo myapp Scope to specific repository | |
| 107 | + | --repo "*" All repositories (default) | |
| 108 | + | ||
| 104 | 109 | Combined examples: | |
| 105 | - | --scopes "push,pull" Registry only (default) | |
| 106 | - | --scopes "admin:*:*" Admin CLI only | |
| 107 | - | --scopes "push,pull,admin:*:*" Registry + full admin | |
| 110 | + | --scopes "push,pull" All repos, registry only (default) | |
| 111 | + | --scopes "push" --repo myapp Push to myapp only | |
| 112 | + | --scopes "push,pull,admin:routes:read" Minimum CI scope (all repos) | |
| 113 | + | --scopes "push,admin:routes:read" --repo app Minimum CI scope (specific repo) | |
| 108 | 114 | ||
| 109 | 115 | EXPIRY: | |
| 110 | 116 | ||
| @@ -115,7 +121,7 @@ Supports human-friendly durations: | |||
| 115 | 121 | Examples: 1y, 30d, 2w, 6M, 1y6M, 2w3d | |
| 116 | 122 | Use --expiry=0 for a token that never expires (useful for CI).`, | |
| 117 | 123 | RunE: func(cmd *cobra.Command, args []string) error { | |
| 118 | - | return runTokenGenerate(subject, scopes, expiry, configPath) | |
| 124 | + | return runTokenGenerate(subject, scopes, expiry, configPath, repo) | |
| 119 | 125 | }, | |
| 120 | 126 | } | |
| 121 | 127 | ||
| @@ -123,6 +129,7 @@ Use --expiry=0 for a token that never expires (useful for CI).`, | |||
| 123 | 129 | cmd.Flags().StringVar(&scopes, "scopes", "push,pull", "Comma-separated list of scopes") | |
| 124 | 130 | cmd.Flags().StringVar(&expiry, "expiry", "30d", "Token expiry duration (e.g., 1y, 30d, 2w, 24h, 0 for never)") | |
| 125 | 131 | cmd.Flags().StringVarP(&configPath, "config", "c", "", "Path to config file") | |
| 132 | + | cmd.Flags().StringVar(&repo, "repo", "*", "Repository to scope the token to (default: * for all repositories)") | |
| 126 | 133 | ||
| 127 | 134 | _ = cmd.MarkFlagRequired("subject") | |
| 128 | 135 | ||
| @@ -434,7 +441,12 @@ The hash can be stored in your secrets backend and referenced in the config: | |||
| 434 | 441 | // runTokenGenerate generates a new authentication token. | |
| 435 | 442 | // parseAndConvertScopes parses a comma-separated scope string and converts | |
| 436 | 443 | // simple scopes (push, pull, *) to Docker v2 format (repository:*:action). | |
| 437 | - | func parseAndConvertScopes(scopesStr string) []string { | |
| 444 | + | func parseAndConvertScopes(scopesStr, repo string) ([]string, error) { | |
| 445 | + | repo = strings.TrimSpace(repo) | |
| 446 | + | if repo == "" { | |
| 447 | + | return nil, fmt.Errorf("repository name cannot be empty") | |
| 448 | + | } | |
| 449 | + | ||
| 438 | 450 | rawScopes := strings.Split(scopesStr, ",") | |
| 439 | 451 | for i := range rawScopes { | |
| 440 | 452 | rawScopes[i] = strings.TrimSpace(rawScopes[i]) | |
| @@ -450,10 +462,10 @@ func parseAndConvertScopes(scopesStr string) []string { | |||
| 450 | 462 | } | |
| 451 | 463 | ||
| 452 | 464 | if !hasSimpleScope { | |
| 453 | - | return rawScopes | |
| 465 | + | return rawScopes, nil | |
| 454 | 466 | } | |
| 455 | 467 | ||
| 456 | - | // Convert simple scopes to v2 format: repository:*:push,pull | |
| 468 | + | // Convert simple scopes to v2 format: repository:<repo>:push,pull | |
| 457 | 469 | var scopes []string | |
| 458 | 470 | var actions []string | |
| 459 | 471 | for _, s := range rawScopes { | |
| @@ -465,12 +477,12 @@ func parseAndConvertScopes(scopesStr string) []string { | |||
| 465 | 477 | } | |
| 466 | 478 | } | |
| 467 | 479 | if len(actions) > 0 { | |
| 468 | - | scopes = append(scopes, "repository:*:"+strings.Join(actions, ",")) | |
| 480 | + | scopes = append(scopes, "repository:"+repo+":"+strings.Join(actions, ",")) | |
| 469 | 481 | } | |
| 470 | - | return scopes | |
| 482 | + | return scopes, nil | |
| 471 | 483 | } | |
| 472 | 484 | ||
| 473 | - | func runTokenGenerate(subject, scopesStr, expiryStr, configPath string) error { | |
| 485 | + | func runTokenGenerate(subject, scopesStr, expiryStr, configPath, repo string) error { | |
| 474 | 486 | cfg, err := loadAuthConfig(configPath) | |
| 475 | 487 | if err != nil { | |
| 476 | 488 | return err | |
| @@ -485,7 +497,10 @@ func runTokenGenerate(subject, scopesStr, expiryStr, configPath string) error { | |||
| 485 | 497 | } | |
| 486 | 498 | } | |
| 487 | 499 | ||
| 488 | - | scopes := parseAndConvertScopes(scopesStr) | |
| 500 | + | scopes, err := parseAndConvertScopes(scopesStr, repo) | |
| 501 | + | if err != nil { | |
| 502 | + | return fmt.Errorf("invalid scopes: %w", err) | |
| 503 | + | } | |
| 489 | 504 | ||
| 490 | 505 | // Create auth service | |
| 491 | 506 | log := zerowrap.New(zerowrap.Config{Level: "warn"}) | |
| @@ -74,6 +74,7 @@ func newTokenGenerateCmd() *cobra.Command { | |||||
| 74 | scopes string | 74 | scopes string | ||
| 75 | expiry string | 75 | expiry string | ||
| 76 | configPath string | 76 | configPath string | ||
| 77 | + | repo string | |||
| 77 | ) | 78 | ) | ||
| 78 | 79 | ||||
| 79 | cmd := &cobra.Command{ | 80 | cmd := &cobra.Command{ | ||
| @@ -101,10 +102,15 @@ Admin scopes (for remote CLI access): | |||||
| 101 | admin:routes:read Read-only routes access | 102 | admin:routes:read Read-only routes access | ||
| 102 | admin:status:read Read-only status access | 103 | admin:status:read Read-only status access | ||
| 103 | 104 | ||||
| 105 | + | Repository scoping: | |||
| 106 | + | --repo myapp Scope to specific repository | |||
| 107 | + | --repo "*" All repositories (default) | |||
| 108 | + | ||||
| 104 | Combined examples: | 109 | Combined examples: | ||
| 105 | - | --scopes "push,pull" Registry only (default) | 110 | + | --scopes "push,pull" All repos, registry only (default) |
| 106 | - | --scopes "admin:*:*" Admin CLI only | 111 | + | --scopes "push" --repo myapp Push to myapp only |
| 107 | - | --scopes "push,pull,admin:*:*" Registry + full admin | 112 | + | --scopes "push,pull,admin:routes:read" Minimum CI scope (all repos) |
| 113 | + | --scopes "push,admin:routes:read" --repo app Minimum CI scope (specific repo) | |||
| 108 | 114 | ||||
| 109 | EXPIRY: | 115 | EXPIRY: | ||
| 110 | 116 | ||||
| @@ -115,7 +121,7 @@ Supports human-friendly durations: | |||||
| 115 | Examples: 1y, 30d, 2w, 6M, 1y6M, 2w3d | 121 | Examples: 1y, 30d, 2w, 6M, 1y6M, 2w3d | ||
| 116 | Use --expiry=0 for a token that never expires (useful for CI).`, | 122 | Use --expiry=0 for a token that never expires (useful for CI).`, | ||
| 117 | RunE: func(cmd *cobra.Command, args []string) error { | 123 | RunE: func(cmd *cobra.Command, args []string) error { | ||
| 118 | - | return runTokenGenerate(subject, scopes, expiry, configPath) | 124 | + | return runTokenGenerate(subject, scopes, expiry, configPath, repo) |
| 119 | }, | 125 | }, | ||
| 120 | } | 126 | } | ||
| 121 | 127 | ||||
| @@ -123,6 +129,7 @@ Use --expiry=0 for a token that never expires (useful for CI).`, | |||||
| 123 | cmd.Flags().StringVar(&scopes, "scopes", "push,pull", "Comma-separated list of scopes") | 129 | cmd.Flags().StringVar(&scopes, "scopes", "push,pull", "Comma-separated list of scopes") | ||
| 124 | cmd.Flags().StringVar(&expiry, "expiry", "30d", "Token expiry duration (e.g., 1y, 30d, 2w, 24h, 0 for never)") | 130 | cmd.Flags().StringVar(&expiry, "expiry", "30d", "Token expiry duration (e.g., 1y, 30d, 2w, 24h, 0 for never)") | ||
| 125 | cmd.Flags().StringVarP(&configPath, "config", "c", "", "Path to config file") | 131 | cmd.Flags().StringVarP(&configPath, "config", "c", "", "Path to config file") | ||
| 132 | + | cmd.Flags().StringVar(&repo, "repo", "*", "Repository to scope the token to (default: * for all repositories)") | |||
| 126 | 133 | ||||
| 127 | _ = cmd.MarkFlagRequired("subject") | 134 | _ = cmd.MarkFlagRequired("subject") | ||
| 128 | 135 | ||||
| @@ -434,7 +441,12 @@ The hash can be stored in your secrets backend and referenced in the config: | |||||
| 434 | // runTokenGenerate generates a new authentication token. | 441 | // runTokenGenerate generates a new authentication token. | ||
| 435 | // parseAndConvertScopes parses a comma-separated scope string and converts | 442 | // parseAndConvertScopes parses a comma-separated scope string and converts | ||
| 436 | // simple scopes (push, pull, *) to Docker v2 format (repository:*:action). | 443 | // simple scopes (push, pull, *) to Docker v2 format (repository:*:action). | ||
| 437 | - | func parseAndConvertScopes(scopesStr string) []string { | 444 | + | func parseAndConvertScopes(scopesStr, repo string) ([]string, error) { |
| 445 | + | repo = strings.TrimSpace(repo) | |||
| 446 | + | if repo == "" { | |||
| 447 | + | return nil, fmt.Errorf("repository name cannot be empty") | |||
| 448 | + | } | |||
| 449 | + | ||||
| 438 | rawScopes := strings.Split(scopesStr, ",") | 450 | rawScopes := strings.Split(scopesStr, ",") | ||
| 439 | for i := range rawScopes { | 451 | for i := range rawScopes { | ||
| 440 | rawScopes[i] = strings.TrimSpace(rawScopes[i]) | 452 | rawScopes[i] = strings.TrimSpace(rawScopes[i]) | ||
| @@ -450,10 +462,10 @@ func parseAndConvertScopes(scopesStr string) []string { | |||||
| 450 | } | 462 | } | ||
| 451 | 463 | ||||
| 452 | if !hasSimpleScope { | 464 | if !hasSimpleScope { | ||
| 453 | - | return rawScopes | 465 | + | return rawScopes, nil |
| 454 | } | 466 | } | ||
| 455 | 467 | ||||
| 456 | - | // Convert simple scopes to v2 format: repository:*:push,pull | 468 | + | // Convert simple scopes to v2 format: repository:<repo>:push,pull |
| 457 | var scopes []string | 469 | var scopes []string | ||
| 458 | var actions []string | 470 | var actions []string | ||
| 459 | for _, s := range rawScopes { | 471 | for _, s := range rawScopes { | ||
| @@ -465,12 +477,12 @@ func parseAndConvertScopes(scopesStr string) []string { | |||||
| 465 | } | 477 | } | ||
| 466 | } | 478 | } | ||
| 467 | if len(actions) > 0 { | 479 | if len(actions) > 0 { | ||
| 468 | - | scopes = append(scopes, "repository:*:"+strings.Join(actions, ",")) | 480 | + | scopes = append(scopes, "repository:"+repo+":"+strings.Join(actions, ",")) |
| 469 | } | 481 | } | ||
| 470 | - | return scopes | 482 | + | return scopes, nil |
| 471 | } | 483 | } | ||
| 472 | 484 | ||||
| 473 | - | func runTokenGenerate(subject, scopesStr, expiryStr, configPath string) error { | 485 | + | func runTokenGenerate(subject, scopesStr, expiryStr, configPath, repo string) error { |
| 474 | cfg, err := loadAuthConfig(configPath) | 486 | cfg, err := loadAuthConfig(configPath) | ||
| 475 | if err != nil { | 487 | if err != nil { | ||
| 476 | return err | 488 | return err | ||
| @@ -485,7 +497,10 @@ func runTokenGenerate(subject, scopesStr, expiryStr, configPath string) error { | |||||
| 485 | } | 497 | } | ||
| 486 | } | 498 | } | ||
| 487 | 499 | ||||
| 488 | - | scopes := parseAndConvertScopes(scopesStr) | 500 | + | scopes, err := parseAndConvertScopes(scopesStr, repo) |
| 501 | + | if err != nil { | |||
| 502 | + | return fmt.Errorf("invalid scopes: %w", err) | |||
| 503 | + | } | |||
| 489 | 504 | ||||
| 490 | // Create auth service | 505 | // Create auth service | ||
| 491 | log := zerowrap.New(zerowrap.Config{Level: "warn"}) | 506 | log := zerowrap.New(zerowrap.Config{Level: "warn"}) | ||
A internal/adapters/in/cli/auth_scopes_test.go +49 -0
| @@ -0,0 +1,49 @@ | |||
| 1 | + | package cli | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/stretchr/testify/assert" | |
| 7 | + | "github.com/stretchr/testify/require" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | func TestParseAndConvertScopes_DefaultWildcard(t *testing.T) { | |
| 11 | + | got, err := parseAndConvertScopes("push,pull", "*") | |
| 12 | + | require.NoError(t, err) | |
| 13 | + | assert.Equal(t, []string{"repository:*:push,pull"}, got) | |
| 14 | + | } | |
| 15 | + | ||
| 16 | + | func TestParseAndConvertScopes_SpecificRepo(t *testing.T) { | |
| 17 | + | got, err := parseAndConvertScopes("push,pull", "myapp") | |
| 18 | + | require.NoError(t, err) | |
| 19 | + | assert.Equal(t, []string{"repository:myapp:push,pull"}, got) | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | func TestParseAndConvertScopes_SpecificRepoPushOnly(t *testing.T) { | |
| 23 | + | got, err := parseAndConvertScopes("push", "myapp") | |
| 24 | + | require.NoError(t, err) | |
| 25 | + | assert.Equal(t, []string{"repository:myapp:push"}, got) | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | func TestParseAndConvertScopes_MixedWithAdminScopes(t *testing.T) { | |
| 29 | + | got, err := parseAndConvertScopes("push,pull,admin:routes:read", "myapp") | |
| 30 | + | require.NoError(t, err) | |
| 31 | + | assert.ElementsMatch(t, []string{"repository:myapp:push,pull", "admin:routes:read"}, got) | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | func TestParseAndConvertScopes_AlreadyV2Format_RepoIgnored(t *testing.T) { | |
| 35 | + | // When scopes are already in v2 format, --repo is ignored (scopes returned as-is) | |
| 36 | + | got, err := parseAndConvertScopes("repository:specific:push", "*") | |
| 37 | + | require.NoError(t, err) | |
| 38 | + | assert.Equal(t, []string{"repository:specific:push"}, got) | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | func TestParseAndConvertScopes_EmptyRepo(t *testing.T) { | |
| 42 | + | _, err := parseAndConvertScopes("push,pull", "") | |
| 43 | + | assert.EqualError(t, err, "repository name cannot be empty") | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | func TestParseAndConvertScopes_WhitespaceRepo(t *testing.T) { | |
| 47 | + | _, err := parseAndConvertScopes("push,pull", " ") | |
| 48 | + | assert.EqualError(t, err, "repository name cannot be empty") | |
| 49 | + | } | |
| @@ -0,0 +1,49 @@ | |||||
| 1 | + | package cli | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/stretchr/testify/assert" | |||
| 7 | + | "github.com/stretchr/testify/require" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | func TestParseAndConvertScopes_DefaultWildcard(t *testing.T) { | |||
| 11 | + | got, err := parseAndConvertScopes("push,pull", "*") | |||
| 12 | + | require.NoError(t, err) | |||
| 13 | + | assert.Equal(t, []string{"repository:*:push,pull"}, got) | |||
| 14 | + | } | |||
| 15 | + | ||||
| 16 | + | func TestParseAndConvertScopes_SpecificRepo(t *testing.T) { | |||
| 17 | + | got, err := parseAndConvertScopes("push,pull", "myapp") | |||
| 18 | + | require.NoError(t, err) | |||
| 19 | + | assert.Equal(t, []string{"repository:myapp:push,pull"}, got) | |||
| 20 | + | } | |||
| 21 | + | ||||
| 22 | + | func TestParseAndConvertScopes_SpecificRepoPushOnly(t *testing.T) { | |||
| 23 | + | got, err := parseAndConvertScopes("push", "myapp") | |||
| 24 | + | require.NoError(t, err) | |||
| 25 | + | assert.Equal(t, []string{"repository:myapp:push"}, got) | |||
| 26 | + | } | |||
| 27 | + | ||||
| 28 | + | func TestParseAndConvertScopes_MixedWithAdminScopes(t *testing.T) { | |||
| 29 | + | got, err := parseAndConvertScopes("push,pull,admin:routes:read", "myapp") | |||
| 30 | + | require.NoError(t, err) | |||
| 31 | + | assert.ElementsMatch(t, []string{"repository:myapp:push,pull", "admin:routes:read"}, got) | |||
| 32 | + | } | |||
| 33 | + | ||||
| 34 | + | func TestParseAndConvertScopes_AlreadyV2Format_RepoIgnored(t *testing.T) { | |||
| 35 | + | // When scopes are already in v2 format, --repo is ignored (scopes returned as-is) | |||
| 36 | + | got, err := parseAndConvertScopes("repository:specific:push", "*") | |||
| 37 | + | require.NoError(t, err) | |||
| 38 | + | assert.Equal(t, []string{"repository:specific:push"}, got) | |||
| 39 | + | } | |||
| 40 | + | ||||
| 41 | + | func TestParseAndConvertScopes_EmptyRepo(t *testing.T) { | |||
| 42 | + | _, err := parseAndConvertScopes("push,pull", "") | |||
| 43 | + | assert.EqualError(t, err, "repository name cannot be empty") | |||
| 44 | + | } | |||
| 45 | + | ||||
| 46 | + | func TestParseAndConvertScopes_WhitespaceRepo(t *testing.T) { | |||
| 47 | + | _, err := parseAndConvertScopes("push,pull", " ") | |||
| 48 | + | assert.EqualError(t, err, "repository name cannot be empty") | |||
| 49 | + | } | |||
M internal/adapters/in/cli/push.go +27 -9
| @@ -3,7 +3,9 @@ package cli | |||
| 3 | 3 | import ( | |
| 4 | 4 | "bufio" | |
| 5 | 5 | "context" | |
| 6 | + | "errors" | |
| 6 | 7 | "fmt" | |
| 8 | + | "net/http" | |
| 7 | 9 | "os" | |
| 8 | 10 | "os/exec" | |
| 9 | 11 | "path/filepath" | |
| @@ -200,14 +202,7 @@ func runPush(ctx context.Context, req pushRequest) error { | |||
| 200 | 202 | } | |
| 201 | 203 | fmt.Printf("Domain: %s\n", styles.Theme.Bold.Render(pushDomain)) | |
| 202 | 204 | ||
| 203 | - | // Signal the server to suppress event-based deploys for this image. | |
| 204 | - | // The CLI will trigger an explicit deploy after push completes. | |
| 205 | - | if !req.NoDeploy { | |
| 206 | - | if err := handle.plane.DeployIntent(ctx, imageName); err != nil { | |
| 207 | - | // Non-fatal: worst case we get a redundant deploy via event | |
| 208 | - | fmt.Fprintf(os.Stderr, "warning: failed to register deploy intent: %v\n", err) | |
| 209 | - | } | |
| 210 | - | } | |
| 205 | + | skipExplicitDeploy := shouldSkipDeploy(ctx, handle.plane, imageName, req.NoDeploy) | |
| 211 | 206 | ||
| 212 | 207 | build := buildConfig{ | |
| 213 | 208 | Enabled: req.Build.Enabled, | |
| @@ -228,13 +223,36 @@ func runPush(ctx context.Context, req pushRequest) error { | |||
| 228 | 223 | ||
| 229 | 224 | fmt.Println(styles.RenderSuccess("Push complete")) | |
| 230 | 225 | ||
| 231 | - | if !req.NoDeploy { | |
| 226 | + | if !skipExplicitDeploy { | |
| 232 | 227 | return deployAfterPush(ctx, handle.plane, pushDomain, req.NoConfirm) | |
| 233 | 228 | } | |
| 234 | 229 | ||
| 235 | 230 | return nil | |
| 236 | 231 | } | |
| 237 | 232 | ||
| 233 | + | // shouldSkipDeploy signals deploy intent and returns whether the explicit deploy | |
| 234 | + | // should be skipped (either because --no-deploy was set, or the token lacks scope). | |
| 235 | + | func shouldSkipDeploy(ctx context.Context, plane ControlPlane, imageName string, noDeploy bool) bool { | |
| 236 | + | // Always call DeployIntent to suppress server-side auto-deploy, even with --no-deploy. | |
| 237 | + | // Without this, the server's event listener would still auto-deploy the pushed image. | |
| 238 | + | if err := plane.DeployIntent(ctx, imageName); err != nil { | |
| 239 | + | if isInsufficientScope(err) { | |
| 240 | + | fmt.Fprintln(os.Stderr, "info: deploy intent skipped (insufficient scope), server will auto-deploy on image receive") | |
| 241 | + | return true | |
| 242 | + | } | |
| 243 | + | // Non-fatal: worst case we get a redundant deploy via event | |
| 244 | + | fmt.Fprintf(os.Stderr, "warning: failed to register deploy intent: %v\n", err) | |
| 245 | + | } | |
| 246 | + | return noDeploy | |
| 247 | + | } | |
| 248 | + | ||
| 249 | + | // isInsufficientScope returns true if the error is an HTTP 403 Forbidden response, | |
| 250 | + | // meaning the token lacks the required admin scope. | |
| 251 | + | func isInsufficientScope(err error) bool { | |
| 252 | + | var httpErr *remote.HTTPError | |
| 253 | + | return errors.As(err, &httpErr) && httpErr.StatusCode == http.StatusForbidden | |
| 254 | + | } | |
| 255 | + | ||
| 238 | 256 | // resolveFromDomain resolves image info from a domain name (legacy mode). | |
| 239 | 257 | func resolveFromDomain(ctx context.Context, cp ControlPlane, pushDomain string) (registry, imageName, resolvedDomain string, err error) { | |
| 240 | 258 | route, err := cp.GetRoute(ctx, pushDomain) | |
| @@ -3,7 +3,9 @@ package cli | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "bufio" | 4 | "bufio" | ||
| 5 | "context" | 5 | "context" | ||
| 6 | + | "errors" | |||
| 6 | "fmt" | 7 | "fmt" | ||
| 8 | + | "net/http" | |||
| 7 | "os" | 9 | "os" | ||
| 8 | "os/exec" | 10 | "os/exec" | ||
| 9 | "path/filepath" | 11 | "path/filepath" | ||
| @@ -200,14 +202,7 @@ func runPush(ctx context.Context, req pushRequest) error { | |||||
| 200 | } | 202 | } | ||
| 201 | fmt.Printf("Domain: %s\n", styles.Theme.Bold.Render(pushDomain)) | 203 | fmt.Printf("Domain: %s\n", styles.Theme.Bold.Render(pushDomain)) | ||
| 202 | 204 | ||||
| 203 | - | // Signal the server to suppress event-based deploys for this image. | 205 | + | skipExplicitDeploy := shouldSkipDeploy(ctx, handle.plane, imageName, req.NoDeploy) |
| 204 | - | // The CLI will trigger an explicit deploy after push completes. | |||
| 205 | - | if !req.NoDeploy { | |||
| 206 | - | if err := handle.plane.DeployIntent(ctx, imageName); err != nil { | |||
| 207 | - | // Non-fatal: worst case we get a redundant deploy via event | |||
| 208 | - | fmt.Fprintf(os.Stderr, "warning: failed to register deploy intent: %v\n", err) | |||
| 209 | - | } | |||
| 210 | - | } | |||
| 211 | 206 | ||||
| 212 | build := buildConfig{ | 207 | build := buildConfig{ | ||
| 213 | Enabled: req.Build.Enabled, | 208 | Enabled: req.Build.Enabled, | ||
| @@ -228,13 +223,36 @@ func runPush(ctx context.Context, req pushRequest) error { | |||||
| 228 | 223 | ||||
| 229 | fmt.Println(styles.RenderSuccess("Push complete")) | 224 | fmt.Println(styles.RenderSuccess("Push complete")) | ||
| 230 | 225 | ||||
| 231 | - | if !req.NoDeploy { | 226 | + | if !skipExplicitDeploy { |
| 232 | return deployAfterPush(ctx, handle.plane, pushDomain, req.NoConfirm) | 227 | return deployAfterPush(ctx, handle.plane, pushDomain, req.NoConfirm) | ||
| 233 | } | 228 | } | ||
| 234 | 229 | ||||
| 235 | return nil | 230 | return nil | ||
| 236 | } | 231 | } | ||
| 237 | 232 | ||||
| 233 | + | // shouldSkipDeploy signals deploy intent and returns whether the explicit deploy | |||
| 234 | + | // should be skipped (either because --no-deploy was set, or the token lacks scope). | |||
| 235 | + | func shouldSkipDeploy(ctx context.Context, plane ControlPlane, imageName string, noDeploy bool) bool { | |||
| 236 | + | // Always call DeployIntent to suppress server-side auto-deploy, even with --no-deploy. | |||
| 237 | + | // Without this, the server's event listener would still auto-deploy the pushed image. | |||
| 238 | + | if err := plane.DeployIntent(ctx, imageName); err != nil { | |||
| 239 | + | if isInsufficientScope(err) { | |||
| 240 | + | fmt.Fprintln(os.Stderr, "info: deploy intent skipped (insufficient scope), server will auto-deploy on image receive") | |||
| 241 | + | return true | |||
| 242 | + | } | |||
| 243 | + | // Non-fatal: worst case we get a redundant deploy via event | |||
| 244 | + | fmt.Fprintf(os.Stderr, "warning: failed to register deploy intent: %v\n", err) | |||
| 245 | + | } | |||
| 246 | + | return noDeploy | |||
| 247 | + | } | |||
| 248 | + | ||||
| 249 | + | // isInsufficientScope returns true if the error is an HTTP 403 Forbidden response, | |||
| 250 | + | // meaning the token lacks the required admin scope. | |||
| 251 | + | func isInsufficientScope(err error) bool { | |||
| 252 | + | var httpErr *remote.HTTPError | |||
| 253 | + | return errors.As(err, &httpErr) && httpErr.StatusCode == http.StatusForbidden | |||
| 254 | + | } | |||
| 255 | + | ||||
| 238 | // resolveFromDomain resolves image info from a domain name (legacy mode). | 256 | // resolveFromDomain resolves image info from a domain name (legacy mode). | ||
| 239 | func resolveFromDomain(ctx context.Context, cp ControlPlane, pushDomain string) (registry, imageName, resolvedDomain string, err error) { | 257 | func resolveFromDomain(ctx context.Context, cp ControlPlane, pushDomain string) (registry, imageName, resolvedDomain string, err error) { | ||
| 240 | route, err := cp.GetRoute(ctx, pushDomain) | 258 | route, err := cp.GetRoute(ctx, pushDomain) | ||
M internal/adapters/in/cli/push_image_ops.go +90 -7
| @@ -2,10 +2,13 @@ package cli | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | + | "errors" | |
| 5 | 6 | "fmt" | |
| 6 | 7 | "io" | |
| 8 | + | "net/http" | |
| 7 | 9 | "os" | |
| 8 | 10 | "os/exec" | |
| 11 | + | "strings" | |
| 9 | 12 | ||
| 10 | 13 | cerrdefs "github.com/containerd/errdefs" | |
| 11 | 14 | "github.com/docker/docker/client" | |
| @@ -30,9 +33,10 @@ type pushImageOps interface { | |||
| 30 | 33 | ||
| 31 | 34 | // dockerImageOps implements pushImageOps using the Docker SDK and registrypush. | |
| 32 | 35 | type dockerImageOps struct { | |
| 33 | - | cli client.APIClient | |
| 34 | - | insecureTLS bool | |
| 35 | - | progress io.Writer | |
| 36 | + | cli client.APIClient | |
| 37 | + | insecureTLS bool | |
| 38 | + | progress io.Writer | |
| 39 | + | remoteClient *remote.Client // nil when local | |
| 36 | 40 | } | |
| 37 | 41 | ||
| 38 | 42 | // newDockerImageOps creates a dockerImageOps from the Docker environment. | |
| @@ -50,8 +54,20 @@ func newDockerImageOps(insecureTLS bool, progress io.Writer) (*dockerImageOps, e | |||
| 50 | 54 | ||
| 51 | 55 | // newImageOpsFromFlags resolves TLS settings from CLI flags/config and creates ops. | |
| 52 | 56 | func newImageOpsFromFlags() (pushImageOps, error) { | |
| 53 | - | _, _, insecureTLS, _ := remote.ResolveRemote(remoteFlag, tokenFlag, insecureTLSFlag) | |
| 54 | - | return newDockerImageOps(insecureTLS, os.Stderr) | |
| 57 | + | remoteURL, token, insecureTLS, _ := remote.ResolveRemote(remoteFlag, tokenFlag, insecureTLSFlag) | |
| 58 | + | ops, err := newDockerImageOps(insecureTLS, os.Stderr) | |
| 59 | + | if err != nil { | |
| 60 | + | return nil, err | |
| 61 | + | } | |
| 62 | + | if remoteURL != "" && token != "" { | |
| 63 | + | var clientOpts []remote.ClientOption | |
| 64 | + | clientOpts = append(clientOpts, remote.WithToken(token)) | |
| 65 | + | if insecureTLS { | |
| 66 | + | clientOpts = append(clientOpts, remote.WithInsecureTLS(true)) | |
| 67 | + | } | |
| 68 | + | ops.remoteClient = remote.NewClient(remoteURL, clientOpts...) | |
| 69 | + | } | |
| 70 | + | return ops, nil | |
| 55 | 71 | } | |
| 56 | 72 | ||
| 57 | 73 | func (d *dockerImageOps) Tag(ctx context.Context, sourceRef, targetRef string) error { | |
| @@ -62,13 +78,80 @@ func (d *dockerImageOps) Tag(ctx context.Context, sourceRef, targetRef string) e | |||
| 62 | 78 | } | |
| 63 | 79 | ||
| 64 | 80 | func (d *dockerImageOps) Push(ctx context.Context, ref string) error { | |
| 65 | - | pusher := registrypush.New( | |
| 81 | + | opts := []registrypush.Option{ | |
| 66 | 82 | registrypush.WithProgress(d.progress), | |
| 67 | 83 | registrypush.WithInsecureTLS(d.insecureTLS), | |
| 68 | - | ) | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | if d.remoteClient != nil { | |
| 87 | + | authHeader, err := d.exchangeRegistryAuth(ctx) | |
| 88 | + | if err != nil { | |
| 89 | + | return fmt.Errorf("failed to obtain registry credentials: %w", err) | |
| 90 | + | } | |
| 91 | + | opts = append(opts, registrypush.WithAuth(authHeader)) | |
| 92 | + | ||
| 93 | + | pusher := registrypush.New(opts...) | |
| 94 | + | err = pusher.Push(ctx, ref) | |
| 95 | + | if err != nil && isAuthError(err) { | |
| 96 | + | // Token may have expired mid-push; refresh once and retry. | |
| 97 | + | authHeader, refreshErr := d.exchangeRegistryAuth(ctx) | |
| 98 | + | if refreshErr != nil { | |
| 99 | + | return fmt.Errorf("failed to refresh registry credentials after auth error: %w (original: %v)", refreshErr, err) | |
| 100 | + | } | |
| 101 | + | retryOpts := []registrypush.Option{ | |
| 102 | + | registrypush.WithProgress(d.progress), | |
| 103 | + | registrypush.WithInsecureTLS(d.insecureTLS), | |
| 104 | + | registrypush.WithAuth(authHeader), | |
| 105 | + | } | |
| 106 | + | pusher = registrypush.New(retryOpts...) | |
| 107 | + | return pusher.Push(ctx, ref) | |
| 108 | + | } | |
| 109 | + | return err | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | pusher := registrypush.New(opts...) | |
| 69 | 113 | return pusher.Push(ctx, ref) | |
| 70 | 114 | } | |
| 71 | 115 | ||
| 116 | + | // isAuthError returns true if the error indicates an HTTP 401 authentication failure. | |
| 117 | + | // Prefers typed HTTPError from the remote client; falls back to string matching for | |
| 118 | + | // errors from registrypush which uses its own error format. | |
| 119 | + | func isAuthError(err error) bool { | |
| 120 | + | if err == nil { | |
| 121 | + | return false | |
| 122 | + | } | |
| 123 | + | var httpErr *remote.HTTPError | |
| 124 | + | if errors.As(err, &httpErr) { | |
| 125 | + | return httpErr.StatusCode == http.StatusUnauthorized | |
| 126 | + | } | |
| 127 | + | msg := err.Error() | |
| 128 | + | return strings.Contains(msg, "status 401") || strings.Contains(msg, "401 Unauthorized") | |
| 129 | + | } | |
| 130 | + | ||
| 131 | + | // exchangeRegistryAuth exchanges the long-lived Gordon token for a short-lived | |
| 132 | + | // registry access token. Returns the Authorization header value (e.g. "Bearer <token>"). | |
| 133 | + | func (d *dockerImageOps) exchangeRegistryAuth(ctx context.Context) (string, error) { | |
| 134 | + | // Step 1: Get subject from server (validates token is still active) | |
| 135 | + | verifyResp, err := d.remoteClient.VerifyAuth(ctx) | |
| 136 | + | if err != nil { | |
| 137 | + | return "", fmt.Errorf("failed to verify token: %w", err) | |
| 138 | + | } | |
| 139 | + | if !verifyResp.Valid { | |
| 140 | + | return "", fmt.Errorf("token is not valid") | |
| 141 | + | } | |
| 142 | + | if verifyResp.Subject == "" { | |
| 143 | + | return "", fmt.Errorf("empty subject in verified token") | |
| 144 | + | } | |
| 145 | + | ||
| 146 | + | // Step 2: Exchange long-lived token for short-lived registry token | |
| 147 | + | shortToken, err := d.remoteClient.ExchangeRegistryToken(ctx, verifyResp.Subject) | |
| 148 | + | if err != nil { | |
| 149 | + | return "", fmt.Errorf("failed to exchange registry token: %w", err) | |
| 150 | + | } | |
| 151 | + | ||
| 152 | + | return "Bearer " + shortToken, nil | |
| 153 | + | } | |
| 154 | + | ||
| 72 | 155 | func (d *dockerImageOps) Exists(ctx context.Context, imageRef string) (bool, error) { | |
| 73 | 156 | _, err := d.cli.ImageInspect(ctx, imageRef) | |
| 74 | 157 | if err != nil { | |
| @@ -2,10 +2,13 @@ package cli | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | + | "errors" | |||
| 5 | "fmt" | 6 | "fmt" | ||
| 6 | "io" | 7 | "io" | ||
| 8 | + | "net/http" | |||
| 7 | "os" | 9 | "os" | ||
| 8 | "os/exec" | 10 | "os/exec" | ||
| 11 | + | "strings" | |||
| 9 | 12 | ||||
| 10 | cerrdefs "github.com/containerd/errdefs" | 13 | cerrdefs "github.com/containerd/errdefs" | ||
| 11 | "github.com/docker/docker/client" | 14 | "github.com/docker/docker/client" | ||
| @@ -30,9 +33,10 @@ type pushImageOps interface { | |||||
| 30 | 33 | ||||
| 31 | // dockerImageOps implements pushImageOps using the Docker SDK and registrypush. | 34 | // dockerImageOps implements pushImageOps using the Docker SDK and registrypush. | ||
| 32 | type dockerImageOps struct { | 35 | type dockerImageOps struct { | ||
| 33 | - | cli client.APIClient | 36 | + | cli client.APIClient |
| 34 | - | insecureTLS bool | 37 | + | insecureTLS bool |
| 35 | - | progress io.Writer | 38 | + | progress io.Writer |
| 39 | + | remoteClient *remote.Client // nil when local | |||
| 36 | } | 40 | } | ||
| 37 | 41 | ||||
| 38 | // newDockerImageOps creates a dockerImageOps from the Docker environment. | 42 | // newDockerImageOps creates a dockerImageOps from the Docker environment. | ||
| @@ -50,8 +54,20 @@ func newDockerImageOps(insecureTLS bool, progress io.Writer) (*dockerImageOps, e | |||||
| 50 | 54 | ||||
| 51 | // newImageOpsFromFlags resolves TLS settings from CLI flags/config and creates ops. | 55 | // newImageOpsFromFlags resolves TLS settings from CLI flags/config and creates ops. | ||
| 52 | func newImageOpsFromFlags() (pushImageOps, error) { | 56 | func newImageOpsFromFlags() (pushImageOps, error) { | ||
| 53 | - | _, _, insecureTLS, _ := remote.ResolveRemote(remoteFlag, tokenFlag, insecureTLSFlag) | 57 | + | remoteURL, token, insecureTLS, _ := remote.ResolveRemote(remoteFlag, tokenFlag, insecureTLSFlag) |
| 54 | - | return newDockerImageOps(insecureTLS, os.Stderr) | 58 | + | ops, err := newDockerImageOps(insecureTLS, os.Stderr) |
| 59 | + | if err != nil { | |||
| 60 | + | return nil, err | |||
| 61 | + | } | |||
| 62 | + | if remoteURL != "" && token != "" { | |||
| 63 | + | var clientOpts []remote.ClientOption | |||
| 64 | + | clientOpts = append(clientOpts, remote.WithToken(token)) | |||
| 65 | + | if insecureTLS { | |||
| 66 | + | clientOpts = append(clientOpts, remote.WithInsecureTLS(true)) | |||
| 67 | + | } | |||
| 68 | + | ops.remoteClient = remote.NewClient(remoteURL, clientOpts...) | |||
| 69 | + | } | |||
| 70 | + | return ops, nil | |||
| 55 | } | 71 | } | ||
| 56 | 72 | ||||
| 57 | func (d *dockerImageOps) Tag(ctx context.Context, sourceRef, targetRef string) error { | 73 | func (d *dockerImageOps) Tag(ctx context.Context, sourceRef, targetRef string) error { | ||
| @@ -62,13 +78,80 @@ func (d *dockerImageOps) Tag(ctx context.Context, sourceRef, targetRef string) e | |||||
| 62 | } | 78 | } | ||
| 63 | 79 | ||||
| 64 | func (d *dockerImageOps) Push(ctx context.Context, ref string) error { | 80 | func (d *dockerImageOps) Push(ctx context.Context, ref string) error { | ||
| 65 | - | pusher := registrypush.New( | 81 | + | opts := []registrypush.Option{ |
| 66 | registrypush.WithProgress(d.progress), | 82 | registrypush.WithProgress(d.progress), | ||
| 67 | registrypush.WithInsecureTLS(d.insecureTLS), | 83 | registrypush.WithInsecureTLS(d.insecureTLS), | ||
| 68 | - | ) | 84 | + | } |
| 85 | + | ||||
| 86 | + | if d.remoteClient != nil { | |||
| 87 | + | authHeader, err := d.exchangeRegistryAuth(ctx) | |||
| 88 | + | if err != nil { | |||
| 89 | + | return fmt.Errorf("failed to obtain registry credentials: %w", err) | |||
| 90 | + | } | |||
| 91 | + | opts = append(opts, registrypush.WithAuth(authHeader)) | |||
| 92 | + | ||||
| 93 | + | pusher := registrypush.New(opts...) | |||
| 94 | + | err = pusher.Push(ctx, ref) | |||
| 95 | + | if err != nil && isAuthError(err) { | |||
| 96 | + | // Token may have expired mid-push; refresh once and retry. | |||
| 97 | + | authHeader, refreshErr := d.exchangeRegistryAuth(ctx) | |||
| 98 | + | if refreshErr != nil { | |||
| 99 | + | return fmt.Errorf("failed to refresh registry credentials after auth error: %w (original: %v)", refreshErr, err) | |||
| 100 | + | } | |||
| 101 | + | retryOpts := []registrypush.Option{ | |||
| 102 | + | registrypush.WithProgress(d.progress), | |||
| 103 | + | registrypush.WithInsecureTLS(d.insecureTLS), | |||
| 104 | + | registrypush.WithAuth(authHeader), | |||
| 105 | + | } | |||
| 106 | + | pusher = registrypush.New(retryOpts...) | |||
| 107 | + | return pusher.Push(ctx, ref) | |||
| 108 | + | } | |||
| 109 | + | return err | |||
| 110 | + | } | |||
| 111 | + | ||||
| 112 | + | pusher := registrypush.New(opts...) | |||
| 69 | return pusher.Push(ctx, ref) | 113 | return pusher.Push(ctx, ref) | ||
| 70 | } | 114 | } | ||
| 71 | 115 | ||||
| 116 | + | // isAuthError returns true if the error indicates an HTTP 401 authentication failure. | |||
| 117 | + | // Prefers typed HTTPError from the remote client; falls back to string matching for | |||
| 118 | + | // errors from registrypush which uses its own error format. | |||
| 119 | + | func isAuthError(err error) bool { | |||
| 120 | + | if err == nil { | |||
| 121 | + | return false | |||
| 122 | + | } | |||
| 123 | + | var httpErr *remote.HTTPError | |||
| 124 | + | if errors.As(err, &httpErr) { | |||
| 125 | + | return httpErr.StatusCode == http.StatusUnauthorized | |||
| 126 | + | } | |||
| 127 | + | msg := err.Error() | |||
| 128 | + | return strings.Contains(msg, "status 401") || strings.Contains(msg, "401 Unauthorized") | |||
| 129 | + | } | |||
| 130 | + | ||||
| 131 | + | // exchangeRegistryAuth exchanges the long-lived Gordon token for a short-lived | |||
| 132 | + | // registry access token. Returns the Authorization header value (e.g. "Bearer <token>"). | |||
| 133 | + | func (d *dockerImageOps) exchangeRegistryAuth(ctx context.Context) (string, error) { | |||
| 134 | + | // Step 1: Get subject from server (validates token is still active) | |||
| 135 | + | verifyResp, err := d.remoteClient.VerifyAuth(ctx) | |||
| 136 | + | if err != nil { | |||
| 137 | + | return "", fmt.Errorf("failed to verify token: %w", err) | |||
| 138 | + | } | |||
| 139 | + | if !verifyResp.Valid { | |||
| 140 | + | return "", fmt.Errorf("token is not valid") | |||
| 141 | + | } | |||
| 142 | + | if verifyResp.Subject == "" { | |||
| 143 | + | return "", fmt.Errorf("empty subject in verified token") | |||
| 144 | + | } | |||
| 145 | + | ||||
| 146 | + | // Step 2: Exchange long-lived token for short-lived registry token | |||
| 147 | + | shortToken, err := d.remoteClient.ExchangeRegistryToken(ctx, verifyResp.Subject) | |||
| 148 | + | if err != nil { | |||
| 149 | + | return "", fmt.Errorf("failed to exchange registry token: %w", err) | |||
| 150 | + | } | |||
| 151 | + | ||||
| 152 | + | return "Bearer " + shortToken, nil | |||
| 153 | + | } | |||
| 154 | + | ||||
| 72 | func (d *dockerImageOps) Exists(ctx context.Context, imageRef string) (bool, error) { | 155 | func (d *dockerImageOps) Exists(ctx context.Context, imageRef string) (bool, error) { | ||
| 73 | _, err := d.cli.ImageInspect(ctx, imageRef) | 156 | _, err := d.cli.ImageInspect(ctx, imageRef) | ||
| 74 | if err != nil { | 157 | if err != nil { | ||
A internal/adapters/in/cli/push_image_ops_test.go +114 -0
| @@ -0,0 +1,114 @@ | |||
| 1 | + | package cli | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "encoding/json" | |
| 6 | + | "net/http" | |
| 7 | + | "net/http/httptest" | |
| 8 | + | "testing" | |
| 9 | + | ||
| 10 | + | "github.com/stretchr/testify/assert" | |
| 11 | + | "github.com/stretchr/testify/require" | |
| 12 | + | ||
| 13 | + | "github.com/bnema/gordon/internal/adapters/dto" | |
| 14 | + | "github.com/bnema/gordon/internal/adapters/in/cli/remote" | |
| 15 | + | ) | |
| 16 | + | ||
| 17 | + | // TestExchangeRegistryAuth_Success verifies the happy path: | |
| 18 | + | // VerifyAuth is called, then ExchangeRegistryToken is called with the | |
| 19 | + | // subject from VerifyAuth, and the result is "Bearer <short-lived-token>". | |
| 20 | + | func TestExchangeRegistryAuth_Success(t *testing.T) { | |
| 21 | + | const subject = "ci-bot" | |
| 22 | + | const shortToken = "short-lived-token-abc123" | |
| 23 | + | ||
| 24 | + | verifyHandled := false | |
| 25 | + | tokenHandled := false | |
| 26 | + | ||
| 27 | + | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 28 | + | switch r.URL.Path { | |
| 29 | + | case "/admin/auth/verify": | |
| 30 | + | verifyHandled = true | |
| 31 | + | w.Header().Set("Content-Type", "application/json") | |
| 32 | + | resp := dto.AuthVerifyResponse{ | |
| 33 | + | Valid: true, | |
| 34 | + | Subject: subject, | |
| 35 | + | Scopes: []string{"push", "pull"}, | |
| 36 | + | } | |
| 37 | + | json.NewEncoder(w).Encode(resp) //nolint:errcheck | |
| 38 | + | case "/auth/token": | |
| 39 | + | tokenHandled = true | |
| 40 | + | // Verify basic auth was set with subject as username | |
| 41 | + | u, p, ok := r.BasicAuth() | |
| 42 | + | assert.True(t, ok, "expected basic auth") | |
| 43 | + | assert.Equal(t, subject, u, "expected subject as username") | |
| 44 | + | assert.NotEmpty(t, p, "expected token as password") | |
| 45 | + | ||
| 46 | + | w.Header().Set("Content-Type", "application/json") | |
| 47 | + | resp := dto.TokenResponse{Token: shortToken} | |
| 48 | + | json.NewEncoder(w).Encode(resp) //nolint:errcheck | |
| 49 | + | default: | |
| 50 | + | http.NotFound(w, r) | |
| 51 | + | } | |
| 52 | + | })) | |
| 53 | + | defer srv.Close() | |
| 54 | + | ||
| 55 | + | client := remote.NewClient(srv.URL, remote.WithToken("long-lived-token")) | |
| 56 | + | ops := &dockerImageOps{ | |
| 57 | + | remoteClient: client, | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | got, err := ops.exchangeRegistryAuth(context.Background()) | |
| 61 | + | ||
| 62 | + | require.NoError(t, err) | |
| 63 | + | assert.Equal(t, "Bearer "+shortToken, got) | |
| 64 | + | assert.True(t, verifyHandled, "expected /admin/auth/verify to be called") | |
| 65 | + | assert.True(t, tokenHandled, "expected /auth/token to be called") | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | // TestExchangeRegistryAuth_VerifyAuthFails verifies that when VerifyAuth fails, | |
| 69 | + | // exchangeRegistryAuth returns an error. | |
| 70 | + | func TestExchangeRegistryAuth_VerifyAuthFails(t *testing.T) { | |
| 71 | + | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 72 | + | if r.URL.Path == "/admin/auth/verify" { | |
| 73 | + | http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized) | |
| 74 | + | return | |
| 75 | + | } | |
| 76 | + | http.NotFound(w, r) | |
| 77 | + | })) | |
| 78 | + | defer srv.Close() | |
| 79 | + | ||
| 80 | + | client := remote.NewClient(srv.URL, remote.WithToken("bad-token")) | |
| 81 | + | ops := &dockerImageOps{ | |
| 82 | + | remoteClient: client, | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | _, err := ops.exchangeRegistryAuth(context.Background()) | |
| 86 | + | ||
| 87 | + | require.Error(t, err) | |
| 88 | + | assert.Contains(t, err.Error(), "failed to verify token") | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | // TestExchangeRegistryAuth_TokenNotValid verifies that when VerifyAuth returns | |
| 92 | + | // Valid=false, exchangeRegistryAuth returns an error. | |
| 93 | + | func TestExchangeRegistryAuth_TokenNotValid(t *testing.T) { | |
| 94 | + | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 95 | + | if r.URL.Path == "/admin/auth/verify" { | |
| 96 | + | w.Header().Set("Content-Type", "application/json") | |
| 97 | + | resp := dto.AuthVerifyResponse{Valid: false} | |
| 98 | + | json.NewEncoder(w).Encode(resp) //nolint:errcheck | |
| 99 | + | return | |
| 100 | + | } | |
| 101 | + | http.NotFound(w, r) | |
| 102 | + | })) | |
| 103 | + | defer srv.Close() | |
| 104 | + | ||
| 105 | + | client := remote.NewClient(srv.URL, remote.WithToken("expired-token")) | |
| 106 | + | ops := &dockerImageOps{ | |
| 107 | + | remoteClient: client, | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | _, err := ops.exchangeRegistryAuth(context.Background()) | |
| 111 | + | ||
| 112 | + | require.Error(t, err) | |
| 113 | + | assert.Contains(t, err.Error(), "token is not valid") | |
| 114 | + | } | |
| @@ -0,0 +1,114 @@ | |||||
| 1 | + | package cli | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "encoding/json" | |||
| 6 | + | "net/http" | |||
| 7 | + | "net/http/httptest" | |||
| 8 | + | "testing" | |||
| 9 | + | ||||
| 10 | + | "github.com/stretchr/testify/assert" | |||
| 11 | + | "github.com/stretchr/testify/require" | |||
| 12 | + | ||||
| 13 | + | "github.com/bnema/gordon/internal/adapters/dto" | |||
| 14 | + | "github.com/bnema/gordon/internal/adapters/in/cli/remote" | |||
| 15 | + | ) | |||
| 16 | + | ||||
| 17 | + | // TestExchangeRegistryAuth_Success verifies the happy path: | |||
| 18 | + | // VerifyAuth is called, then ExchangeRegistryToken is called with the | |||
| 19 | + | // subject from VerifyAuth, and the result is "Bearer <short-lived-token>". | |||
| 20 | + | func TestExchangeRegistryAuth_Success(t *testing.T) { | |||
| 21 | + | const subject = "ci-bot" | |||
| 22 | + | const shortToken = "short-lived-token-abc123" | |||
| 23 | + | ||||
| 24 | + | verifyHandled := false | |||
| 25 | + | tokenHandled := false | |||
| 26 | + | ||||
| 27 | + | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| 28 | + | switch r.URL.Path { | |||
| 29 | + | case "/admin/auth/verify": | |||
| 30 | + | verifyHandled = true | |||
| 31 | + | w.Header().Set("Content-Type", "application/json") | |||
| 32 | + | resp := dto.AuthVerifyResponse{ | |||
| 33 | + | Valid: true, | |||
| 34 | + | Subject: subject, | |||
| 35 | + | Scopes: []string{"push", "pull"}, | |||
| 36 | + | } | |||
| 37 | + | json.NewEncoder(w).Encode(resp) //nolint:errcheck | |||
| 38 | + | case "/auth/token": | |||
| 39 | + | tokenHandled = true | |||
| 40 | + | // Verify basic auth was set with subject as username | |||
| 41 | + | u, p, ok := r.BasicAuth() | |||
| 42 | + | assert.True(t, ok, "expected basic auth") | |||
| 43 | + | assert.Equal(t, subject, u, "expected subject as username") | |||
| 44 | + | assert.NotEmpty(t, p, "expected token as password") | |||
| 45 | + | ||||
| 46 | + | w.Header().Set("Content-Type", "application/json") | |||
| 47 | + | resp := dto.TokenResponse{Token: shortToken} | |||
| 48 | + | json.NewEncoder(w).Encode(resp) //nolint:errcheck | |||
| 49 | + | default: | |||
| 50 | + | http.NotFound(w, r) | |||
| 51 | + | } | |||
| 52 | + | })) | |||
| 53 | + | defer srv.Close() | |||
| 54 | + | ||||
| 55 | + | client := remote.NewClient(srv.URL, remote.WithToken("long-lived-token")) | |||
| 56 | + | ops := &dockerImageOps{ | |||
| 57 | + | remoteClient: client, | |||
| 58 | + | } | |||
| 59 | + | ||||
| 60 | + | got, err := ops.exchangeRegistryAuth(context.Background()) | |||
| 61 | + | ||||
| 62 | + | require.NoError(t, err) | |||
| 63 | + | assert.Equal(t, "Bearer "+shortToken, got) | |||
| 64 | + | assert.True(t, verifyHandled, "expected /admin/auth/verify to be called") | |||
| 65 | + | assert.True(t, tokenHandled, "expected /auth/token to be called") | |||
| 66 | + | } | |||
| 67 | + | ||||
| 68 | + | // TestExchangeRegistryAuth_VerifyAuthFails verifies that when VerifyAuth fails, | |||
| 69 | + | // exchangeRegistryAuth returns an error. | |||
| 70 | + | func TestExchangeRegistryAuth_VerifyAuthFails(t *testing.T) { | |||
| 71 | + | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| 72 | + | if r.URL.Path == "/admin/auth/verify" { | |||
| 73 | + | http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized) | |||
| 74 | + | return | |||
| 75 | + | } | |||
| 76 | + | http.NotFound(w, r) | |||
| 77 | + | })) | |||
| 78 | + | defer srv.Close() | |||
| 79 | + | ||||
| 80 | + | client := remote.NewClient(srv.URL, remote.WithToken("bad-token")) | |||
| 81 | + | ops := &dockerImageOps{ | |||
| 82 | + | remoteClient: client, | |||
| 83 | + | } | |||
| 84 | + | ||||
| 85 | + | _, err := ops.exchangeRegistryAuth(context.Background()) | |||
| 86 | + | ||||
| 87 | + | require.Error(t, err) | |||
| 88 | + | assert.Contains(t, err.Error(), "failed to verify token") | |||
| 89 | + | } | |||
| 90 | + | ||||
| 91 | + | // TestExchangeRegistryAuth_TokenNotValid verifies that when VerifyAuth returns | |||
| 92 | + | // Valid=false, exchangeRegistryAuth returns an error. | |||
| 93 | + | func TestExchangeRegistryAuth_TokenNotValid(t *testing.T) { | |||
| 94 | + | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| 95 | + | if r.URL.Path == "/admin/auth/verify" { | |||
| 96 | + | w.Header().Set("Content-Type", "application/json") | |||
| 97 | + | resp := dto.AuthVerifyResponse{Valid: false} | |||
| 98 | + | json.NewEncoder(w).Encode(resp) //nolint:errcheck | |||
| 99 | + | return | |||
| 100 | + | } | |||
| 101 | + | http.NotFound(w, r) | |||
| 102 | + | })) | |||
| 103 | + | defer srv.Close() | |||
| 104 | + | ||||
| 105 | + | client := remote.NewClient(srv.URL, remote.WithToken("expired-token")) | |||
| 106 | + | ops := &dockerImageOps{ | |||
| 107 | + | remoteClient: client, | |||
| 108 | + | } | |||
| 109 | + | ||||
| 110 | + | _, err := ops.exchangeRegistryAuth(context.Background()) | |||
| 111 | + | ||||
| 112 | + | require.Error(t, err) | |||
| 113 | + | assert.Contains(t, err.Error(), "token is not valid") | |||
| 114 | + | } | |||
M internal/adapters/in/cli/push_test.go +20 -0
| @@ -2,6 +2,7 @@ package cli | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | + | "fmt" | |
| 5 | 6 | "io" | |
| 6 | 7 | "os" | |
| 7 | 8 | "os/exec" | |
| @@ -603,3 +604,22 @@ func TestResolveFromImage_NoRouteSuggestsBootstrap(t *testing.T) { | |||
| 603 | 604 | assert.Contains(t, err.Error(), `no route configured for image "myapp"`) | |
| 604 | 605 | assert.Contains(t, err.Error(), "gordon bootstrap") | |
| 605 | 606 | } | |
| 607 | + | ||
| 608 | + | func TestIsInsufficientScope(t *testing.T) { | |
| 609 | + | tests := []struct { | |
| 610 | + | name string | |
| 611 | + | err error | |
| 612 | + | want bool | |
| 613 | + | }{ | |
| 614 | + | {"nil error", nil, false}, | |
| 615 | + | {"HTTPError 403", &remote.HTTPError{StatusCode: 403, Status: "403 Forbidden", Body: "insufficient scope"}, true}, | |
| 616 | + | {"wrapped HTTPError 403", fmt.Errorf("deploy intent: %w", &remote.HTTPError{StatusCode: 403, Status: "403 Forbidden", Body: "scope"}), true}, | |
| 617 | + | {"HTTPError 500", &remote.HTTPError{StatusCode: 500, Status: "500 Internal Server Error", Body: "broke"}, false}, | |
| 618 | + | {"plain error", fmt.Errorf("connection refused"), false}, | |
| 619 | + | } | |
| 620 | + | for _, tt := range tests { | |
| 621 | + | t.Run(tt.name, func(t *testing.T) { | |
| 622 | + | assert.Equal(t, tt.want, isInsufficientScope(tt.err)) | |
| 623 | + | }) | |
| 624 | + | } | |
| 625 | + | } | |
| @@ -2,6 +2,7 @@ package cli | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | + | "fmt" | |||
| 5 | "io" | 6 | "io" | ||
| 6 | "os" | 7 | "os" | ||
| 7 | "os/exec" | 8 | "os/exec" | ||
| @@ -603,3 +604,22 @@ func TestResolveFromImage_NoRouteSuggestsBootstrap(t *testing.T) { | |||||
| 603 | assert.Contains(t, err.Error(), `no route configured for image "myapp"`) | 604 | assert.Contains(t, err.Error(), `no route configured for image "myapp"`) | ||
| 604 | assert.Contains(t, err.Error(), "gordon bootstrap") | 605 | assert.Contains(t, err.Error(), "gordon bootstrap") | ||
| 605 | } | 606 | } | ||
| 607 | + | ||||
| 608 | + | func TestIsInsufficientScope(t *testing.T) { | |||
| 609 | + | tests := []struct { | |||
| 610 | + | name string | |||
| 611 | + | err error | |||
| 612 | + | want bool | |||
| 613 | + | }{ | |||
| 614 | + | {"nil error", nil, false}, | |||
| 615 | + | {"HTTPError 403", &remote.HTTPError{StatusCode: 403, Status: "403 Forbidden", Body: "insufficient scope"}, true}, | |||
| 616 | + | {"wrapped HTTPError 403", fmt.Errorf("deploy intent: %w", &remote.HTTPError{StatusCode: 403, Status: "403 Forbidden", Body: "scope"}), true}, | |||
| 617 | + | {"HTTPError 500", &remote.HTTPError{StatusCode: 500, Status: "500 Internal Server Error", Body: "broke"}, false}, | |||
| 618 | + | {"plain error", fmt.Errorf("connection refused"), false}, | |||
| 619 | + | } | |||
| 620 | + | for _, tt := range tests { | |||
| 621 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 622 | + | assert.Equal(t, tt.want, isInsufficientScope(tt.err)) | |||
| 623 | + | }) | |||
| 624 | + | } | |||
| 625 | + | } | |||
M internal/adapters/in/cli/remote/auth.go +53 -1
| @@ -8,6 +8,8 @@ import ( | |||
| 8 | 8 | "io" | |
| 9 | 9 | "net/http" | |
| 10 | 10 | "os" | |
| 11 | + | ||
| 12 | + | "github.com/bnema/gordon/internal/adapters/dto" | |
| 11 | 13 | ) | |
| 12 | 14 | ||
| 13 | 15 | // PasswordRequest represents the request body for POST /auth/password. | |
| @@ -53,7 +55,7 @@ func (c *Client) Authenticate(ctx context.Context, username, password string) (* | |||
| 53 | 55 | defer resp.Body.Close() | |
| 54 | 56 | ||
| 55 | 57 | if resp.StatusCode >= 400 { | |
| 56 | - | body, _ := io.ReadAll(resp.Body) | |
| 58 | + | body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024)) | |
| 57 | 59 | var errResp struct { | |
| 58 | 60 | Error string `json:"error"` | |
| 59 | 61 | } | |
| @@ -71,6 +73,56 @@ func (c *Client) Authenticate(ctx context.Context, username, password string) (* | |||
| 71 | 73 | return &result, nil | |
| 72 | 74 | } | |
| 73 | 75 | ||
| 76 | + | // ExchangeRegistryToken exchanges the client's long-lived Gordon token for a | |
| 77 | + | // short-lived registry access token via the Docker v2 token endpoint. | |
| 78 | + | // The subject must match the token's sub claim (use VerifyAuth to obtain it). | |
| 79 | + | // Returns the raw short-lived token string (without a "Bearer " prefix). | |
| 80 | + | func (c *Client) ExchangeRegistryToken(ctx context.Context, subject string) (string, error) { | |
| 81 | + | // Wildcard scope (repository:*:push,pull) grants push/pull to all repositories. | |
| 82 | + | // The server downsopes this to the parent token's actual scopes during exchange. | |
| 83 | + | url := c.baseURL + "/auth/token?scope=repository:*:push,pull&service=gordon-registry" | |
| 84 | + | ||
| 85 | + | req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) | |
| 86 | + | if err != nil { | |
| 87 | + | return "", fmt.Errorf("failed to create request: %w", err) | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | req.SetBasicAuth(subject, c.token) | |
| 91 | + | req.Header.Set("Accept", "application/json") | |
| 92 | + | ||
| 93 | + | resp, err := c.httpClient.Do(req) | |
| 94 | + | if err != nil { | |
| 95 | + | return "", fmt.Errorf("failed to connect: %w", err) | |
| 96 | + | } | |
| 97 | + | defer resp.Body.Close() | |
| 98 | + | ||
| 99 | + | if resp.StatusCode >= 400 { | |
| 100 | + | body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024)) | |
| 101 | + | var errResp struct { | |
| 102 | + | Error string `json:"error"` | |
| 103 | + | } | |
| 104 | + | if err := json.Unmarshal(body, &errResp); err == nil && errResp.Error != "" { | |
| 105 | + | return "", fmt.Errorf("%s: %s", resp.Status, errResp.Error) | |
| 106 | + | } | |
| 107 | + | return "", fmt.Errorf("%s: %s", resp.Status, string(body)) | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | var result dto.TokenResponse | |
| 111 | + | if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { | |
| 112 | + | return "", fmt.Errorf("failed to decode response: %w", err) | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | token := result.Token | |
| 116 | + | if token == "" { | |
| 117 | + | token = result.AccessToken | |
| 118 | + | } | |
| 119 | + | if token == "" { | |
| 120 | + | return "", fmt.Errorf("registry token exchange returned empty token") | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | return token, nil | |
| 124 | + | } | |
| 125 | + | ||
| 74 | 126 | // UpdateRemoteToken updates the token for a named remote. | |
| 75 | 127 | func UpdateRemoteToken(name, token string) error { | |
| 76 | 128 | // Try pass first | |
| @@ -8,6 +8,8 @@ import ( | |||||
| 8 | "io" | 8 | "io" | ||
| 9 | "net/http" | 9 | "net/http" | ||
| 10 | "os" | 10 | "os" | ||
| 11 | + | ||||
| 12 | + | "github.com/bnema/gordon/internal/adapters/dto" | |||
| 11 | ) | 13 | ) | ||
| 12 | 14 | ||||
| 13 | // PasswordRequest represents the request body for POST /auth/password. | 15 | // PasswordRequest represents the request body for POST /auth/password. | ||
| @@ -53,7 +55,7 @@ func (c *Client) Authenticate(ctx context.Context, username, password string) (* | |||||
| 53 | defer resp.Body.Close() | 55 | defer resp.Body.Close() | ||
| 54 | 56 | ||||
| 55 | if resp.StatusCode >= 400 { | 57 | if resp.StatusCode >= 400 { | ||
| 56 | - | body, _ := io.ReadAll(resp.Body) | 58 | + | body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024)) |
| 57 | var errResp struct { | 59 | var errResp struct { | ||
| 58 | Error string `json:"error"` | 60 | Error string `json:"error"` | ||
| 59 | } | 61 | } | ||
| @@ -71,6 +73,56 @@ func (c *Client) Authenticate(ctx context.Context, username, password string) (* | |||||
| 71 | return &result, nil | 73 | return &result, nil | ||
| 72 | } | 74 | } | ||
| 73 | 75 | ||||
| 76 | + | // ExchangeRegistryToken exchanges the client's long-lived Gordon token for a | |||
| 77 | + | // short-lived registry access token via the Docker v2 token endpoint. | |||
| 78 | + | // The subject must match the token's sub claim (use VerifyAuth to obtain it). | |||
| 79 | + | // Returns the raw short-lived token string (without a "Bearer " prefix). | |||
| 80 | + | func (c *Client) ExchangeRegistryToken(ctx context.Context, subject string) (string, error) { | |||
| 81 | + | // Wildcard scope (repository:*:push,pull) grants push/pull to all repositories. | |||
| 82 | + | // The server downsopes this to the parent token's actual scopes during exchange. | |||
| 83 | + | url := c.baseURL + "/auth/token?scope=repository:*:push,pull&service=gordon-registry" | |||
| 84 | + | ||||
| 85 | + | req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) | |||
| 86 | + | if err != nil { | |||
| 87 | + | return "", fmt.Errorf("failed to create request: %w", err) | |||
| 88 | + | } | |||
| 89 | + | ||||
| 90 | + | req.SetBasicAuth(subject, c.token) | |||
| 91 | + | req.Header.Set("Accept", "application/json") | |||
| 92 | + | ||||
| 93 | + | resp, err := c.httpClient.Do(req) | |||
| 94 | + | if err != nil { | |||
| 95 | + | return "", fmt.Errorf("failed to connect: %w", err) | |||
| 96 | + | } | |||
| 97 | + | defer resp.Body.Close() | |||
| 98 | + | ||||
| 99 | + | if resp.StatusCode >= 400 { | |||
| 100 | + | body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024)) | |||
| 101 | + | var errResp struct { | |||
| 102 | + | Error string `json:"error"` | |||
| 103 | + | } | |||
| 104 | + | if err := json.Unmarshal(body, &errResp); err == nil && errResp.Error != "" { | |||
| 105 | + | return "", fmt.Errorf("%s: %s", resp.Status, errResp.Error) | |||
| 106 | + | } | |||
| 107 | + | return "", fmt.Errorf("%s: %s", resp.Status, string(body)) | |||
| 108 | + | } | |||
| 109 | + | ||||
| 110 | + | var result dto.TokenResponse | |||
| 111 | + | if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { | |||
| 112 | + | return "", fmt.Errorf("failed to decode response: %w", err) | |||
| 113 | + | } | |||
| 114 | + | ||||
| 115 | + | token := result.Token | |||
| 116 | + | if token == "" { | |||
| 117 | + | token = result.AccessToken | |||
| 118 | + | } | |||
| 119 | + | if token == "" { | |||
| 120 | + | return "", fmt.Errorf("registry token exchange returned empty token") | |||
| 121 | + | } | |||
| 122 | + | ||||
| 123 | + | return token, nil | |||
| 124 | + | } | |||
| 125 | + | ||||
| 74 | // UpdateRemoteToken updates the token for a named remote. | 126 | // UpdateRemoteToken updates the token for a named remote. | ||
| 75 | func UpdateRemoteToken(name, token string) error { | 127 | func UpdateRemoteToken(name, token string) error { | ||
| 76 | // Try pass first | 128 | // Try pass first | ||
M internal/adapters/in/cli/remote/client.go +19 -2
| @@ -203,14 +203,31 @@ func parseResponse(resp *http.Response, target any) error { | |||
| 203 | 203 | return nil | |
| 204 | 204 | } | |
| 205 | 205 | ||
| 206 | + | // HTTPError represents an HTTP error response with a status code. | |
| 207 | + | // Use errors.As to check for specific status codes in error handling. | |
| 208 | + | type HTTPError struct { | |
| 209 | + | StatusCode int | |
| 210 | + | Status string | |
| 211 | + | Body string | |
| 212 | + | } | |
| 213 | + | ||
| 214 | + | func (e *HTTPError) Error() string { | |
| 215 | + | return e.Status + ": " + e.Body | |
| 216 | + | } | |
| 217 | + | ||
| 206 | 218 | func parseErrorResponse(resp *http.Response, body []byte) error { | |
| 219 | + | msg := string(body) | |
| 207 | 220 | var errResp struct { | |
| 208 | 221 | Error string `json:"error"` | |
| 209 | 222 | } | |
| 210 | 223 | if err := json.Unmarshal(body, &errResp); err == nil && errResp.Error != "" { | |
| 211 | - | return fmt.Errorf("%s: %s", resp.Status, errResp.Error) | |
| 224 | + | msg = errResp.Error | |
| 225 | + | } | |
| 226 | + | return &HTTPError{ | |
| 227 | + | StatusCode: resp.StatusCode, | |
| 228 | + | Status: resp.Status, | |
| 229 | + | Body: msg, | |
| 212 | 230 | } | |
| 213 | - | return fmt.Errorf("%s: %s", resp.Status, string(body)) | |
| 214 | 231 | } | |
| 215 | 232 | ||
| 216 | 233 | func isRetryableRequestError(err error) bool { | |
| @@ -203,14 +203,31 @@ func parseResponse(resp *http.Response, target any) error { | |||||
| 203 | return nil | 203 | return nil | ||
| 204 | } | 204 | } | ||
| 205 | 205 | ||||
| 206 | + | // HTTPError represents an HTTP error response with a status code. | |||
| 207 | + | // Use errors.As to check for specific status codes in error handling. | |||
| 208 | + | type HTTPError struct { | |||
| 209 | + | StatusCode int | |||
| 210 | + | Status string | |||
| 211 | + | Body string | |||
| 212 | + | } | |||
| 213 | + | ||||
| 214 | + | func (e *HTTPError) Error() string { | |||
| 215 | + | return e.Status + ": " + e.Body | |||
| 216 | + | } | |||
| 217 | + | ||||
| 206 | func parseErrorResponse(resp *http.Response, body []byte) error { | 218 | func parseErrorResponse(resp *http.Response, body []byte) error { | ||
| 219 | + | msg := string(body) | |||
| 207 | var errResp struct { | 220 | var errResp struct { | ||
| 208 | Error string `json:"error"` | 221 | Error string `json:"error"` | ||
| 209 | } | 222 | } | ||
| 210 | if err := json.Unmarshal(body, &errResp); err == nil && errResp.Error != "" { | 223 | if err := json.Unmarshal(body, &errResp); err == nil && errResp.Error != "" { | ||
| 211 | - | return fmt.Errorf("%s: %s", resp.Status, errResp.Error) | 224 | + | msg = errResp.Error |
| 225 | + | } | |||
| 226 | + | return &HTTPError{ | |||
| 227 | + | StatusCode: resp.StatusCode, | |||
| 228 | + | Status: resp.Status, | |||
| 229 | + | Body: msg, | |||
| 212 | } | 230 | } | ||
| 213 | - | return fmt.Errorf("%s: %s", resp.Status, string(body)) | |||
| 214 | } | 231 | } | ||
| 215 | 232 | ||||
| 216 | func isRetryableRequestError(err error) bool { | 233 | func isRetryableRequestError(err error) bool { | ||
M internal/adapters/in/cli/remote/client_test.go +114 -0
| @@ -3,6 +3,8 @@ package remote | |||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | 5 | "encoding/json" | |
| 6 | + | "errors" | |
| 7 | + | "fmt" | |
| 6 | 8 | "io" | |
| 7 | 9 | "net/http" | |
| 8 | 10 | "net/http/httptest" | |
| @@ -250,3 +252,115 @@ func TestRequestWithRetry_CapsErrorBodySize(t *testing.T) { | |||
| 250 | 252 | require.Error(t, err) | |
| 251 | 253 | assert.Less(t, len(err.Error()), 2*1024, "retry error body should be capped to ~1KB") | |
| 252 | 254 | } | |
| 255 | + | ||
| 256 | + | func TestExchangeRegistryToken_Success(t *testing.T) { | |
| 257 | + | const subject = "testuser" | |
| 258 | + | const gordonToken = "gordon-jwt-token" | |
| 259 | + | const shortToken = "short-lived-123" | |
| 260 | + | ||
| 261 | + | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 262 | + | require.Equal(t, "/auth/token", r.URL.Path) | |
| 263 | + | require.Equal(t, http.MethodGet, r.Method) | |
| 264 | + | require.Equal(t, "repository:*:push,pull", r.URL.Query().Get("scope")) | |
| 265 | + | require.Equal(t, "gordon-registry", r.URL.Query().Get("service")) | |
| 266 | + | ||
| 267 | + | u, p, ok := r.BasicAuth() | |
| 268 | + | require.True(t, ok, "expected Basic Auth") | |
| 269 | + | assert.Equal(t, subject, u) | |
| 270 | + | assert.Equal(t, gordonToken, p) | |
| 271 | + | ||
| 272 | + | w.Header().Set("Content-Type", "application/json") | |
| 273 | + | _, _ = w.Write([]byte(`{"token":"short-lived-123","expires_in":300}`)) | |
| 274 | + | })) | |
| 275 | + | defer srv.Close() | |
| 276 | + | ||
| 277 | + | client := NewClient(srv.URL, WithToken(gordonToken)) | |
| 278 | + | token, err := client.ExchangeRegistryToken(context.Background(), subject) | |
| 279 | + | require.NoError(t, err) | |
| 280 | + | assert.Equal(t, shortToken, token) | |
| 281 | + | } | |
| 282 | + | ||
| 283 | + | func TestExchangeRegistryToken_Unauthorized(t *testing.T) { | |
| 284 | + | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 285 | + | w.WriteHeader(http.StatusUnauthorized) | |
| 286 | + | _, _ = w.Write([]byte(`{"error":"invalid credentials"}`)) | |
| 287 | + | })) | |
| 288 | + | defer srv.Close() | |
| 289 | + | ||
| 290 | + | client := NewClient(srv.URL, WithToken("bad-token")) | |
| 291 | + | _, err := client.ExchangeRegistryToken(context.Background(), "user") | |
| 292 | + | require.Error(t, err) | |
| 293 | + | assert.Contains(t, err.Error(), "401") | |
| 294 | + | } | |
| 295 | + | ||
| 296 | + | func TestExchangeRegistryToken_EmptyToken(t *testing.T) { | |
| 297 | + | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 298 | + | w.Header().Set("Content-Type", "application/json") | |
| 299 | + | _, _ = w.Write([]byte(`{"token":"","expires_in":300}`)) | |
| 300 | + | })) | |
| 301 | + | defer srv.Close() | |
| 302 | + | ||
| 303 | + | client := NewClient(srv.URL, WithToken("some-token")) | |
| 304 | + | _, err := client.ExchangeRegistryToken(context.Background(), "user") | |
| 305 | + | require.Error(t, err) | |
| 306 | + | assert.Contains(t, err.Error(), "empty") | |
| 307 | + | } | |
| 308 | + | ||
| 309 | + | func TestHTTPError_ErrorString(t *testing.T) { | |
| 310 | + | err := &HTTPError{StatusCode: 403, Status: "403 Forbidden", Body: "insufficient scope"} | |
| 311 | + | assert.Equal(t, "403 Forbidden: insufficient scope", err.Error()) | |
| 312 | + | } | |
| 313 | + | ||
| 314 | + | func TestHTTPError_Unwrap(t *testing.T) { | |
| 315 | + | httpErr := &HTTPError{StatusCode: 403, Status: "403 Forbidden", Body: "insufficient scope"} | |
| 316 | + | wrapped := fmt.Errorf("deploy intent: %w", httpErr) | |
| 317 | + | ||
| 318 | + | var target *HTTPError | |
| 319 | + | assert.True(t, errors.As(wrapped, &target)) | |
| 320 | + | assert.Equal(t, 403, target.StatusCode) | |
| 321 | + | } | |
| 322 | + | ||
| 323 | + | func TestParseErrorResponse_ReturnsHTTPError(t *testing.T) { | |
| 324 | + | resp := &http.Response{ | |
| 325 | + | StatusCode: 403, | |
| 326 | + | Status: "403 Forbidden", | |
| 327 | + | Body: io.NopCloser(strings.NewReader("")), | |
| 328 | + | } | |
| 329 | + | err := parseErrorResponse(resp, []byte(`{"error":"insufficient scope"}`)) | |
| 330 | + | ||
| 331 | + | var httpErr *HTTPError | |
| 332 | + | require.True(t, errors.As(err, &httpErr)) | |
| 333 | + | assert.Equal(t, 403, httpErr.StatusCode) | |
| 334 | + | assert.Equal(t, "insufficient scope", httpErr.Body) | |
| 335 | + | } | |
| 336 | + | ||
| 337 | + | func TestParseErrorResponse_NonJSON(t *testing.T) { | |
| 338 | + | resp := &http.Response{ | |
| 339 | + | StatusCode: 500, | |
| 340 | + | Status: "500 Internal Server Error", | |
| 341 | + | Body: io.NopCloser(strings.NewReader("")), | |
| 342 | + | } | |
| 343 | + | err := parseErrorResponse(resp, []byte("plain text error")) | |
| 344 | + | ||
| 345 | + | var httpErr *HTTPError | |
| 346 | + | require.True(t, errors.As(err, &httpErr)) | |
| 347 | + | assert.Equal(t, 500, httpErr.StatusCode) | |
| 348 | + | assert.Equal(t, "plain text error", httpErr.Body) | |
| 349 | + | } | |
| 350 | + | ||
| 351 | + | func TestExchangeRegistryToken_AccessTokenFallback(t *testing.T) { | |
| 352 | + | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 353 | + | w.Header().Set("Content-Type", "application/json") | |
| 354 | + | // Token field empty, but access_token has a value | |
| 355 | + | json.NewEncoder(w).Encode(map[string]any{ | |
| 356 | + | "access_token": "fallback-token-456", | |
| 357 | + | "expires_in": 300, | |
| 358 | + | }) | |
| 359 | + | })) | |
| 360 | + | defer srv.Close() | |
| 361 | + | ||
| 362 | + | client := NewClient(srv.URL, WithToken("long-lived")) | |
| 363 | + | token, err := client.ExchangeRegistryToken(context.Background(), "ci-bot") | |
| 364 | + | require.NoError(t, err) | |
| 365 | + | assert.Equal(t, "fallback-token-456", token) | |
| 366 | + | } | |
| @@ -3,6 +3,8 @@ package remote | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | "encoding/json" | 5 | "encoding/json" | ||
| 6 | + | "errors" | |||
| 7 | + | "fmt" | |||
| 6 | "io" | 8 | "io" | ||
| 7 | "net/http" | 9 | "net/http" | ||
| 8 | "net/http/httptest" | 10 | "net/http/httptest" | ||
| @@ -250,3 +252,115 @@ func TestRequestWithRetry_CapsErrorBodySize(t *testing.T) { | |||||
| 250 | require.Error(t, err) | 252 | require.Error(t, err) | ||
| 251 | assert.Less(t, len(err.Error()), 2*1024, "retry error body should be capped to ~1KB") | 253 | assert.Less(t, len(err.Error()), 2*1024, "retry error body should be capped to ~1KB") | ||
| 252 | } | 254 | } | ||
| 255 | + | ||||
| 256 | + | func TestExchangeRegistryToken_Success(t *testing.T) { | |||
| 257 | + | const subject = "testuser" | |||
| 258 | + | const gordonToken = "gordon-jwt-token" | |||
| 259 | + | const shortToken = "short-lived-123" | |||
| 260 | + | ||||
| 261 | + | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| 262 | + | require.Equal(t, "/auth/token", r.URL.Path) | |||
| 263 | + | require.Equal(t, http.MethodGet, r.Method) | |||
| 264 | + | require.Equal(t, "repository:*:push,pull", r.URL.Query().Get("scope")) | |||
| 265 | + | require.Equal(t, "gordon-registry", r.URL.Query().Get("service")) | |||
| 266 | + | ||||
| 267 | + | u, p, ok := r.BasicAuth() | |||
| 268 | + | require.True(t, ok, "expected Basic Auth") | |||
| 269 | + | assert.Equal(t, subject, u) | |||
| 270 | + | assert.Equal(t, gordonToken, p) | |||
| 271 | + | ||||
| 272 | + | w.Header().Set("Content-Type", "application/json") | |||
| 273 | + | _, _ = w.Write([]byte(`{"token":"short-lived-123","expires_in":300}`)) | |||
| 274 | + | })) | |||
| 275 | + | defer srv.Close() | |||
| 276 | + | ||||
| 277 | + | client := NewClient(srv.URL, WithToken(gordonToken)) | |||
| 278 | + | token, err := client.ExchangeRegistryToken(context.Background(), subject) | |||
| 279 | + | require.NoError(t, err) | |||
| 280 | + | assert.Equal(t, shortToken, token) | |||
| 281 | + | } | |||
| 282 | + | ||||
| 283 | + | func TestExchangeRegistryToken_Unauthorized(t *testing.T) { | |||
| 284 | + | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| 285 | + | w.WriteHeader(http.StatusUnauthorized) | |||
| 286 | + | _, _ = w.Write([]byte(`{"error":"invalid credentials"}`)) | |||
| 287 | + | })) | |||
| 288 | + | defer srv.Close() | |||
| 289 | + | ||||
| 290 | + | client := NewClient(srv.URL, WithToken("bad-token")) | |||
| 291 | + | _, err := client.ExchangeRegistryToken(context.Background(), "user") | |||
| 292 | + | require.Error(t, err) | |||
| 293 | + | assert.Contains(t, err.Error(), "401") | |||
| 294 | + | } | |||
| 295 | + | ||||
| 296 | + | func TestExchangeRegistryToken_EmptyToken(t *testing.T) { | |||
| 297 | + | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| 298 | + | w.Header().Set("Content-Type", "application/json") | |||
| 299 | + | _, _ = w.Write([]byte(`{"token":"","expires_in":300}`)) | |||
| 300 | + | })) | |||
| 301 | + | defer srv.Close() | |||
| 302 | + | ||||
| 303 | + | client := NewClient(srv.URL, WithToken("some-token")) | |||
| 304 | + | _, err := client.ExchangeRegistryToken(context.Background(), "user") | |||
| 305 | + | require.Error(t, err) | |||
| 306 | + | assert.Contains(t, err.Error(), "empty") | |||
| 307 | + | } | |||
| 308 | + | ||||
| 309 | + | func TestHTTPError_ErrorString(t *testing.T) { | |||
| 310 | + | err := &HTTPError{StatusCode: 403, Status: "403 Forbidden", Body: "insufficient scope"} | |||
| 311 | + | assert.Equal(t, "403 Forbidden: insufficient scope", err.Error()) | |||
| 312 | + | } | |||
| 313 | + | ||||
| 314 | + | func TestHTTPError_Unwrap(t *testing.T) { | |||
| 315 | + | httpErr := &HTTPError{StatusCode: 403, Status: "403 Forbidden", Body: "insufficient scope"} | |||
| 316 | + | wrapped := fmt.Errorf("deploy intent: %w", httpErr) | |||
| 317 | + | ||||
| 318 | + | var target *HTTPError | |||
| 319 | + | assert.True(t, errors.As(wrapped, &target)) | |||
| 320 | + | assert.Equal(t, 403, target.StatusCode) | |||
| 321 | + | } | |||
| 322 | + | ||||
| 323 | + | func TestParseErrorResponse_ReturnsHTTPError(t *testing.T) { | |||
| 324 | + | resp := &http.Response{ | |||
| 325 | + | StatusCode: 403, | |||
| 326 | + | Status: "403 Forbidden", | |||
| 327 | + | Body: io.NopCloser(strings.NewReader("")), | |||
| 328 | + | } | |||
| 329 | + | err := parseErrorResponse(resp, []byte(`{"error":"insufficient scope"}`)) | |||
| 330 | + | ||||
| 331 | + | var httpErr *HTTPError | |||
| 332 | + | require.True(t, errors.As(err, &httpErr)) | |||
| 333 | + | assert.Equal(t, 403, httpErr.StatusCode) | |||
| 334 | + | assert.Equal(t, "insufficient scope", httpErr.Body) | |||
| 335 | + | } | |||
| 336 | + | ||||
| 337 | + | func TestParseErrorResponse_NonJSON(t *testing.T) { | |||
| 338 | + | resp := &http.Response{ | |||
| 339 | + | StatusCode: 500, | |||
| 340 | + | Status: "500 Internal Server Error", | |||
| 341 | + | Body: io.NopCloser(strings.NewReader("")), | |||
| 342 | + | } | |||
| 343 | + | err := parseErrorResponse(resp, []byte("plain text error")) | |||
| 344 | + | ||||
| 345 | + | var httpErr *HTTPError | |||
| 346 | + | require.True(t, errors.As(err, &httpErr)) | |||
| 347 | + | assert.Equal(t, 500, httpErr.StatusCode) | |||
| 348 | + | assert.Equal(t, "plain text error", httpErr.Body) | |||
| 349 | + | } | |||
| 350 | + | ||||
| 351 | + | func TestExchangeRegistryToken_AccessTokenFallback(t *testing.T) { | |||
| 352 | + | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| 353 | + | w.Header().Set("Content-Type", "application/json") | |||
| 354 | + | // Token field empty, but access_token has a value | |||
| 355 | + | json.NewEncoder(w).Encode(map[string]any{ | |||
| 356 | + | "access_token": "fallback-token-456", | |||
| 357 | + | "expires_in": 300, | |||
| 358 | + | }) | |||
| 359 | + | })) | |||
| 360 | + | defer srv.Close() | |||
| 361 | + | ||||
| 362 | + | client := NewClient(srv.URL, WithToken("long-lived")) | |||
| 363 | + | token, err := client.ExchangeRegistryToken(context.Background(), "ci-bot") | |||
| 364 | + | require.NoError(t, err) | |||
| 365 | + | assert.Equal(t, "fallback-token-456", token) | |||
| 366 | + | } | |||
M pkg/registrypush/push.go +18 -3
| @@ -42,6 +42,7 @@ type Pusher struct { | |||
| 42 | 42 | imageSource func(ctx context.Context, ref string) (v1.Image, func(), error) | |
| 43 | 43 | cleanupFn func() | |
| 44 | 44 | progress io.Writer | |
| 45 | + | authHeader string | |
| 45 | 46 | } | |
| 46 | 47 | ||
| 47 | 48 | // Option configures a Pusher. | |
| @@ -89,6 +90,14 @@ func WithProgress(w io.Writer) Option { | |||
| 89 | 90 | return func(p *Pusher) { p.progress = w } | |
| 90 | 91 | } | |
| 91 | 92 | ||
| 93 | + | // WithAuth sets an explicit Authorization header value (e.g. "Bearer <token>") | |
| 94 | + | // to use for all registry requests. When set, the Docker credential keychain | |
| 95 | + | // is not consulted. This is useful when a pre-computed token is available | |
| 96 | + | // without requiring a local docker login. | |
| 97 | + | func WithAuth(header string) Option { | |
| 98 | + | return func(p *Pusher) { p.authHeader = header } | |
| 99 | + | } | |
| 100 | + | ||
| 92 | 101 | // New creates a Pusher with the given options. | |
| 93 | 102 | func New(opts ...Option) *Pusher { | |
| 94 | 103 | p := &Pusher{chunkSize: DefaultChunkSize, timeout: defaultHTTPTimeout} | |
| @@ -125,9 +134,15 @@ func (p *Pusher) Push(ctx context.Context, ref string) error { | |||
| 125 | 134 | return fmt.Errorf("image ref %s must include a tag", ref) | |
| 126 | 135 | } | |
| 127 | 136 | ||
| 128 | - | authHeader, err := resolveAuthHeader(parsedRef) | |
| 129 | - | if err != nil { | |
| 130 | - | return err | |
| 137 | + | var authHeader string | |
| 138 | + | if p.authHeader != "" { | |
| 139 | + | authHeader = p.authHeader | |
| 140 | + | } else { | |
| 141 | + | var resolveErr error | |
| 142 | + | authHeader, resolveErr = resolveAuthHeader(parsedRef) | |
| 143 | + | if resolveErr != nil { | |
| 144 | + | return resolveErr | |
| 145 | + | } | |
| 131 | 146 | } | |
| 132 | 147 | ||
| 133 | 148 | img, sourceCleanup, err := p.imageSource(ctx, ref) | |
| @@ -42,6 +42,7 @@ type Pusher struct { | |||||
| 42 | imageSource func(ctx context.Context, ref string) (v1.Image, func(), error) | 42 | imageSource func(ctx context.Context, ref string) (v1.Image, func(), error) | ||
| 43 | cleanupFn func() | 43 | cleanupFn func() | ||
| 44 | progress io.Writer | 44 | progress io.Writer | ||
| 45 | + | authHeader string | |||
| 45 | } | 46 | } | ||
| 46 | 47 | ||||
| 47 | // Option configures a Pusher. | 48 | // Option configures a Pusher. | ||
| @@ -89,6 +90,14 @@ func WithProgress(w io.Writer) Option { | |||||
| 89 | return func(p *Pusher) { p.progress = w } | 90 | return func(p *Pusher) { p.progress = w } | ||
| 90 | } | 91 | } | ||
| 91 | 92 | ||||
| 93 | + | // WithAuth sets an explicit Authorization header value (e.g. "Bearer <token>") | |||
| 94 | + | // to use for all registry requests. When set, the Docker credential keychain | |||
| 95 | + | // is not consulted. This is useful when a pre-computed token is available | |||
| 96 | + | // without requiring a local docker login. | |||
| 97 | + | func WithAuth(header string) Option { | |||
| 98 | + | return func(p *Pusher) { p.authHeader = header } | |||
| 99 | + | } | |||
| 100 | + | ||||
| 92 | // New creates a Pusher with the given options. | 101 | // New creates a Pusher with the given options. | ||
| 93 | func New(opts ...Option) *Pusher { | 102 | func New(opts ...Option) *Pusher { | ||
| 94 | p := &Pusher{chunkSize: DefaultChunkSize, timeout: defaultHTTPTimeout} | 103 | p := &Pusher{chunkSize: DefaultChunkSize, timeout: defaultHTTPTimeout} | ||
| @@ -125,9 +134,15 @@ func (p *Pusher) Push(ctx context.Context, ref string) error { | |||||
| 125 | return fmt.Errorf("image ref %s must include a tag", ref) | 134 | return fmt.Errorf("image ref %s must include a tag", ref) | ||
| 126 | } | 135 | } | ||
| 127 | 136 | ||||
| 128 | - | authHeader, err := resolveAuthHeader(parsedRef) | 137 | + | var authHeader string |
| 129 | - | if err != nil { | 138 | + | if p.authHeader != "" { |
| 130 | - | return err | 139 | + | authHeader = p.authHeader |
| 140 | + | } else { | |||
| 141 | + | var resolveErr error | |||
| 142 | + | authHeader, resolveErr = resolveAuthHeader(parsedRef) | |||
| 143 | + | if resolveErr != nil { | |||
| 144 | + | return resolveErr | |||
| 145 | + | } | |||
| 131 | } | 146 | } | ||
| 132 | 147 | ||||
| 133 | img, sourceCleanup, err := p.imageSource(ctx, ref) | 148 | img, sourceCleanup, err := p.imageSource(ctx, ref) | ||
M pkg/registrypush/push_test.go +75 -0
| @@ -660,3 +660,78 @@ func pathSegmentBetween(value, start, end string) string { | |||
| 660 | 660 | func typesOCIManifestSchema1() string { | |
| 661 | 661 | return "application/vnd.oci.image.manifest.v1+json" | |
| 662 | 662 | } | |
| 663 | + | ||
| 664 | + | // TestPusher_WithAuth verifies that when WithAuth is provided, its header is | |
| 665 | + | // used for all registry requests instead of the Docker keychain, and that | |
| 666 | + | // omitting WithAuth leaves existing behavior unchanged. | |
| 667 | + | func TestPusher_WithAuth(t *testing.T) { | |
| 668 | + | t.Run("WithAuth header is sent on every registry request", func(t *testing.T) { | |
| 669 | + | img, err := random.Image(1024, 1) | |
| 670 | + | require.NoError(t, err) | |
| 671 | + | ||
| 672 | + | const explicitToken = "Bearer my-token" | |
| 673 | + | ||
| 674 | + | // authAssertingRegistry fails the test if any request lacks the expected | |
| 675 | + | // Authorization header. | |
| 676 | + | var requestCount int | |
| 677 | + | registry := httptest.NewServer(newFakePushRegistry(t, img, map[string]bool{}).handlerWithAuthCheck(t, explicitToken, &requestCount)) | |
| 678 | + | defer registry.Close() | |
| 679 | + | ||
| 680 | + | serverURL, err := url.Parse(registry.URL) | |
| 681 | + | require.NoError(t, err) | |
| 682 | + | ref := serverURL.Host + "/demo/app:v1" | |
| 683 | + | ||
| 684 | + | p := registrypush.New( | |
| 685 | + | registrypush.WithTransport(registry.Client().Transport), | |
| 686 | + | registrypush.WithImageSource(func(context.Context, string) (v1.Image, error) { | |
| 687 | + | return img, nil | |
| 688 | + | }), | |
| 689 | + | registrypush.WithAuth(explicitToken), | |
| 690 | + | ) | |
| 691 | + | ||
| 692 | + | err = p.Push(context.Background(), ref) | |
| 693 | + | require.NoError(t, err) | |
| 694 | + | assert.Positive(t, requestCount, "at least one request should have been made") | |
| 695 | + | }) | |
| 696 | + | ||
| 697 | + | t.Run("without WithAuth, existing push behavior is unchanged", func(t *testing.T) { | |
| 698 | + | img, err := random.Image(1024, 1) | |
| 699 | + | require.NoError(t, err) | |
| 700 | + | ||
| 701 | + | serverState := newFakePushRegistry(t, img, map[string]bool{}) | |
| 702 | + | registry := httptest.NewServer(serverState.handler()) | |
| 703 | + | defer registry.Close() | |
| 704 | + | ||
| 705 | + | serverURL, err := url.Parse(registry.URL) | |
| 706 | + | require.NoError(t, err) | |
| 707 | + | ref := serverURL.Host + "/demo/app:v1" | |
| 708 | + | ||
| 709 | + | // No WithAuth — relies on Docker keychain (returns empty string for localhost). | |
| 710 | + | p := registrypush.New( | |
| 711 | + | registrypush.WithTransport(registry.Client().Transport), | |
| 712 | + | registrypush.WithImageSource(func(context.Context, string) (v1.Image, error) { | |
| 713 | + | return img, nil | |
| 714 | + | }), | |
| 715 | + | ) | |
| 716 | + | ||
| 717 | + | err = p.Push(context.Background(), ref) | |
| 718 | + | require.NoError(t, err) | |
| 719 | + | ||
| 720 | + | // Manifest should have been pushed — verifies the push completed normally. | |
| 721 | + | _, ok := serverState.manifestPuts[serverState.expectedRepo+":"+serverState.expectedTag] | |
| 722 | + | assert.True(t, ok, "manifest should have been uploaded") | |
| 723 | + | }) | |
| 724 | + | } | |
| 725 | + | ||
| 726 | + | // handlerWithAuthCheck wraps the fakePushRegistry handler and asserts that | |
| 727 | + | // every request carries the expected Authorization header. | |
| 728 | + | func (f *fakePushRegistry) handlerWithAuthCheck(t *testing.T, expectedAuth string, requestCount *int) http.Handler { | |
| 729 | + | t.Helper() | |
| 730 | + | inner := f.handler() | |
| 731 | + | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 732 | + | *requestCount++ | |
| 733 | + | assert.Equal(t, expectedAuth, r.Header.Get("Authorization"), | |
| 734 | + | "expected Authorization header on %s %s", r.Method, r.URL.Path) | |
| 735 | + | inner.ServeHTTP(w, r) | |
| 736 | + | }) | |
| 737 | + | } | |
| @@ -660,3 +660,78 @@ func pathSegmentBetween(value, start, end string) string { | |||||
| 660 | func typesOCIManifestSchema1() string { | 660 | func typesOCIManifestSchema1() string { | ||
| 661 | return "application/vnd.oci.image.manifest.v1+json" | 661 | return "application/vnd.oci.image.manifest.v1+json" | ||
| 662 | } | 662 | } | ||
| 663 | + | ||||
| 664 | + | // TestPusher_WithAuth verifies that when WithAuth is provided, its header is | |||
| 665 | + | // used for all registry requests instead of the Docker keychain, and that | |||
| 666 | + | // omitting WithAuth leaves existing behavior unchanged. | |||
| 667 | + | func TestPusher_WithAuth(t *testing.T) { | |||
| 668 | + | t.Run("WithAuth header is sent on every registry request", func(t *testing.T) { | |||
| 669 | + | img, err := random.Image(1024, 1) | |||
| 670 | + | require.NoError(t, err) | |||
| 671 | + | ||||
| 672 | + | const explicitToken = "Bearer my-token" | |||
| 673 | + | ||||
| 674 | + | // authAssertingRegistry fails the test if any request lacks the expected | |||
| 675 | + | // Authorization header. | |||
| 676 | + | var requestCount int | |||
| 677 | + | registry := httptest.NewServer(newFakePushRegistry(t, img, map[string]bool{}).handlerWithAuthCheck(t, explicitToken, &requestCount)) | |||
| 678 | + | defer registry.Close() | |||
| 679 | + | ||||
| 680 | + | serverURL, err := url.Parse(registry.URL) | |||
| 681 | + | require.NoError(t, err) | |||
| 682 | + | ref := serverURL.Host + "/demo/app:v1" | |||
| 683 | + | ||||
| 684 | + | p := registrypush.New( | |||
| 685 | + | registrypush.WithTransport(registry.Client().Transport), | |||
| 686 | + | registrypush.WithImageSource(func(context.Context, string) (v1.Image, error) { | |||
| 687 | + | return img, nil | |||
| 688 | + | }), | |||
| 689 | + | registrypush.WithAuth(explicitToken), | |||
| 690 | + | ) | |||
| 691 | + | ||||
| 692 | + | err = p.Push(context.Background(), ref) | |||
| 693 | + | require.NoError(t, err) | |||
| 694 | + | assert.Positive(t, requestCount, "at least one request should have been made") | |||
| 695 | + | }) | |||
| 696 | + | ||||
| 697 | + | t.Run("without WithAuth, existing push behavior is unchanged", func(t *testing.T) { | |||
| 698 | + | img, err := random.Image(1024, 1) | |||
| 699 | + | require.NoError(t, err) | |||
| 700 | + | ||||
| 701 | + | serverState := newFakePushRegistry(t, img, map[string]bool{}) | |||
| 702 | + | registry := httptest.NewServer(serverState.handler()) | |||
| 703 | + | defer registry.Close() | |||
| 704 | + | ||||
| 705 | + | serverURL, err := url.Parse(registry.URL) | |||
| 706 | + | require.NoError(t, err) | |||
| 707 | + | ref := serverURL.Host + "/demo/app:v1" | |||
| 708 | + | ||||
| 709 | + | // No WithAuth — relies on Docker keychain (returns empty string for localhost). | |||
| 710 | + | p := registrypush.New( | |||
| 711 | + | registrypush.WithTransport(registry.Client().Transport), | |||
| 712 | + | registrypush.WithImageSource(func(context.Context, string) (v1.Image, error) { | |||
| 713 | + | return img, nil | |||
| 714 | + | }), | |||
| 715 | + | ) | |||
| 716 | + | ||||
| 717 | + | err = p.Push(context.Background(), ref) | |||
| 718 | + | require.NoError(t, err) | |||
| 719 | + | ||||
| 720 | + | // Manifest should have been pushed — verifies the push completed normally. | |||
| 721 | + | _, ok := serverState.manifestPuts[serverState.expectedRepo+":"+serverState.expectedTag] | |||
| 722 | + | assert.True(t, ok, "manifest should have been uploaded") | |||
| 723 | + | }) | |||
| 724 | + | } | |||
| 725 | + | ||||
| 726 | + | // handlerWithAuthCheck wraps the fakePushRegistry handler and asserts that | |||
| 727 | + | // every request carries the expected Authorization header. | |||
| 728 | + | func (f *fakePushRegistry) handlerWithAuthCheck(t *testing.T, expectedAuth string, requestCount *int) http.Handler { | |||
| 729 | + | t.Helper() | |||
| 730 | + | inner := f.handler() | |||
| 731 | + | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| 732 | + | *requestCount++ | |||
| 733 | + | assert.Equal(t, expectedAuth, r.Header.Get("Authorization"), | |||
| 734 | + | "expected Authorization header on %s %s", r.Method, r.URL.Path) | |||
| 735 | + | inner.ServeHTTP(w, r) | |||
| 736 | + | }) | |||
| 737 | + | } | |||