Merge pull request #226 from bnema/security/scan-9bfec05a
fix(security): remediate codex security scan findings
38 files · +1199 -297
M .github/actions/deploy/README.md +20 -23
| @@ -27,10 +27,12 @@ jobs: | |||
| 27 | 27 | deploy: | |
| 28 | 28 | runs-on: ubuntu-latest | |
| 29 | 29 | steps: | |
| 30 | - | - uses: actions/checkout@v6 | |
| 30 | + | - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 | |
| 31 | + | with: | |
| 32 | + | persist-credentials: false | |
| 31 | 33 | ||
| 32 | 34 | - name: Deploy to Gordon | |
| 33 | - | uses: bnema/gordon/.github/actions/deploy@main | |
| 35 | + | uses: bnema/gordon/.github/actions/deploy@271c36a4cc32fabd91b20f93cd006bdd2a01b181 | |
| 34 | 36 | with: | |
| 35 | 37 | registry: registry.mydomain.com | |
| 36 | 38 | username: ${{ secrets.GORDON_USERNAME }} | |
| @@ -52,11 +54,13 @@ jobs: | |||
| 52 | 54 | deploy: | |
| 53 | 55 | runs-on: ubuntu-latest | |
| 54 | 56 | steps: | |
| 55 | - | - uses: actions/checkout@v6 | |
| 57 | + | - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 | |
| 58 | + | with: | |
| 59 | + | persist-credentials: false | |
| 56 | 60 | ||
| 57 | 61 | - name: Deploy to Gordon | |
| 58 | 62 | id: deploy | |
| 59 | - | uses: bnema/gordon/.github/actions/deploy@main | |
| 63 | + | uses: bnema/gordon/.github/actions/deploy@271c36a4cc32fabd91b20f93cd006bdd2a01b181 | |
| 60 | 64 | with: | |
| 61 | 65 | registry: registry.mydomain.com | |
| 62 | 66 | username: ${{ secrets.GORDON_USERNAME }} | |
| @@ -64,9 +68,6 @@ jobs: | |||
| 64 | 68 | image: myapp # Custom image name | |
| 65 | 69 | dockerfile: ./docker/Dockerfile # Custom Dockerfile path | |
| 66 | 70 | context: . # Build context | |
| 67 | - | build-args: | # Build arguments | |
| 68 | - | NODE_ENV=production | |
| 69 | - | API_URL=https://api.example.com | |
| 70 | 71 | platforms: linux/amd64,linux/arm64 # Multi-platform | |
| 71 | 72 | push-latest: 'true' # Also push :latest | |
| 72 | 73 | ||
| @@ -88,7 +89,6 @@ jobs: | |||
| 88 | 89 | | `tag` | Override image tag | No | Git tag or short SHA | | |
| 89 | 90 | | `dockerfile` | Path to Dockerfile | No | `./Dockerfile` | | |
| 90 | 91 | | `context` | Build context path | No | `.` | | |
| 91 | - | | `build-args` | Build arguments (one per line) | No | - | | |
| 92 | 92 | | `platforms` | Target platforms | No | - | | |
| 93 | 93 | | `push-latest` | Also push with `:latest` tag | No | `true` | | |
| 94 | 94 | | `cache-from` | Cache source | No | `type=gha` | | |
| @@ -152,11 +152,12 @@ jobs: | |||
| 152 | 152 | deploy: | |
| 153 | 153 | runs-on: ubuntu-latest | |
| 154 | 154 | steps: | |
| 155 | - | - uses: actions/checkout@v6 | |
| 155 | + | - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 | |
| 156 | 156 | with: | |
| 157 | 157 | ref: ${{ github.event.inputs.tag || github.ref }} | |
| 158 | + | persist-credentials: false | |
| 158 | 159 | ||
| 159 | - | - uses: bnema/gordon/.github/actions/deploy@main | |
| 160 | + | - uses: bnema/gordon/.github/actions/deploy@271c36a4cc32fabd91b20f93cd006bdd2a01b181 | |
| 160 | 161 | with: | |
| 161 | 162 | registry: registry.mydomain.com | |
| 162 | 163 | username: ${{ secrets.GORDON_USERNAME }} | |
| @@ -178,8 +179,10 @@ jobs: | |||
| 178 | 179 | deploy-api: | |
| 179 | 180 | runs-on: ubuntu-latest | |
| 180 | 181 | steps: | |
| 181 | - | - uses: actions/checkout@v6 | |
| 182 | - | - uses: bnema/gordon/.github/actions/deploy@main | |
| 182 | + | - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 | |
| 183 | + | with: | |
| 184 | + | persist-credentials: false | |
| 185 | + | - uses: bnema/gordon/.github/actions/deploy@271c36a4cc32fabd91b20f93cd006bdd2a01b181 | |
| 183 | 186 | with: | |
| 184 | 187 | registry: registry.mydomain.com | |
| 185 | 188 | username: ${{ secrets.GORDON_USERNAME }} | |
| @@ -191,8 +194,10 @@ jobs: | |||
| 191 | 194 | deploy-web: | |
| 192 | 195 | runs-on: ubuntu-latest | |
| 193 | 196 | steps: | |
| 194 | - | - uses: actions/checkout@v6 | |
| 195 | - | - uses: bnema/gordon/.github/actions/deploy@main | |
| 197 | + | - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 | |
| 198 | + | with: | |
| 199 | + | persist-credentials: false | |
| 200 | + | - uses: bnema/gordon/.github/actions/deploy@271c36a4cc32fabd91b20f93cd006bdd2a01b181 | |
| 196 | 201 | with: | |
| 197 | 202 | registry: registry.mydomain.com | |
| 198 | 203 | username: ${{ secrets.GORDON_USERNAME }} | |
| @@ -204,15 +209,7 @@ jobs: | |||
| 204 | 209 | ||
| 205 | 210 | ### Build with Secrets (BuildKit) | |
| 206 | 211 | ||
| 207 | - | ```yaml | |
| 208 | - | - uses: bnema/gordon/.github/actions/deploy@main | |
| 209 | - | with: | |
| 210 | - | registry: registry.mydomain.com | |
| 211 | - | username: ${{ secrets.GORDON_USERNAME }} | |
| 212 | - | password: ${{ secrets.GORDON_TOKEN }} | |
| 213 | - | build-args: | | |
| 214 | - | NPM_TOKEN=${{ secrets.NPM_TOKEN }} | |
| 215 | - | ``` | |
| 212 | + | The deploy action intentionally does not accept build arguments because CI secrets passed as build arguments can remain in image metadata or layers. For secret-bearing builds, use `docker/build-push-action` with its BuildKit `secrets` input, reference the secret with `RUN --mount=type=secret` in the Dockerfile, and pin every action to a reviewed commit SHA. | |
| 216 | 213 | ||
| 217 | 214 | ## Troubleshooting | |
| 218 | 215 | ||
| @@ -27,10 +27,12 @@ jobs: | |||||
| 27 | deploy: | 27 | deploy: | ||
| 28 | runs-on: ubuntu-latest | 28 | runs-on: ubuntu-latest | ||
| 29 | steps: | 29 | steps: | ||
| 30 | - | - uses: actions/checkout@v6 | 30 | + | - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 |
| 31 | + | with: | |||
| 32 | + | persist-credentials: false | |||
| 31 | 33 | ||||
| 32 | - name: Deploy to Gordon | 34 | - name: Deploy to Gordon | ||
| 33 | - | uses: bnema/gordon/.github/actions/deploy@main | 35 | + | uses: bnema/gordon/.github/actions/deploy@271c36a4cc32fabd91b20f93cd006bdd2a01b181 |
| 34 | with: | 36 | with: | ||
| 35 | registry: registry.mydomain.com | 37 | registry: registry.mydomain.com | ||
| 36 | username: ${{ secrets.GORDON_USERNAME }} | 38 | username: ${{ secrets.GORDON_USERNAME }} | ||
| @@ -52,11 +54,13 @@ jobs: | |||||
| 52 | deploy: | 54 | deploy: | ||
| 53 | runs-on: ubuntu-latest | 55 | runs-on: ubuntu-latest | ||
| 54 | steps: | 56 | steps: | ||
| 55 | - | - uses: actions/checkout@v6 | 57 | + | - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 |
| 58 | + | with: | |||
| 59 | + | persist-credentials: false | |||
| 56 | 60 | ||||
| 57 | - name: Deploy to Gordon | 61 | - name: Deploy to Gordon | ||
| 58 | id: deploy | 62 | id: deploy | ||
| 59 | - | uses: bnema/gordon/.github/actions/deploy@main | 63 | + | uses: bnema/gordon/.github/actions/deploy@271c36a4cc32fabd91b20f93cd006bdd2a01b181 |
| 60 | with: | 64 | with: | ||
| 61 | registry: registry.mydomain.com | 65 | registry: registry.mydomain.com | ||
| 62 | username: ${{ secrets.GORDON_USERNAME }} | 66 | username: ${{ secrets.GORDON_USERNAME }} | ||
| @@ -64,9 +68,6 @@ jobs: | |||||
| 64 | image: myapp # Custom image name | 68 | image: myapp # Custom image name | ||
| 65 | dockerfile: ./docker/Dockerfile # Custom Dockerfile path | 69 | dockerfile: ./docker/Dockerfile # Custom Dockerfile path | ||
| 66 | context: . # Build context | 70 | context: . # Build context | ||
| 67 | - | build-args: | # Build arguments | |||
| 68 | - | NODE_ENV=production | |||
| 69 | - | API_URL=https://api.example.com | |||
| 70 | platforms: linux/amd64,linux/arm64 # Multi-platform | 71 | platforms: linux/amd64,linux/arm64 # Multi-platform | ||
| 71 | push-latest: 'true' # Also push :latest | 72 | push-latest: 'true' # Also push :latest | ||
| 72 | 73 | ||||
| @@ -88,7 +89,6 @@ jobs: | |||||
| 88 | | `tag` | Override image tag | No | Git tag or short SHA | | 89 | | `tag` | Override image tag | No | Git tag or short SHA | | ||
| 89 | | `dockerfile` | Path to Dockerfile | No | `./Dockerfile` | | 90 | | `dockerfile` | Path to Dockerfile | No | `./Dockerfile` | | ||
| 90 | | `context` | Build context path | No | `.` | | 91 | | `context` | Build context path | No | `.` | | ||
| 91 | - | | `build-args` | Build arguments (one per line) | No | - | | |||
| 92 | | `platforms` | Target platforms | No | - | | 92 | | `platforms` | Target platforms | No | - | | ||
| 93 | | `push-latest` | Also push with `:latest` tag | No | `true` | | 93 | | `push-latest` | Also push with `:latest` tag | No | `true` | | ||
| 94 | | `cache-from` | Cache source | No | `type=gha` | | 94 | | `cache-from` | Cache source | No | `type=gha` | | ||
| @@ -152,11 +152,12 @@ jobs: | |||||
| 152 | deploy: | 152 | deploy: | ||
| 153 | runs-on: ubuntu-latest | 153 | runs-on: ubuntu-latest | ||
| 154 | steps: | 154 | steps: | ||
| 155 | - | - uses: actions/checkout@v6 | 155 | + | - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 |
| 156 | with: | 156 | with: | ||
| 157 | ref: ${{ github.event.inputs.tag || github.ref }} | 157 | ref: ${{ github.event.inputs.tag || github.ref }} | ||
| 158 | + | persist-credentials: false | |||
| 158 | 159 | ||||
| 159 | - | - uses: bnema/gordon/.github/actions/deploy@main | 160 | + | - uses: bnema/gordon/.github/actions/deploy@271c36a4cc32fabd91b20f93cd006bdd2a01b181 |
| 160 | with: | 161 | with: | ||
| 161 | registry: registry.mydomain.com | 162 | registry: registry.mydomain.com | ||
| 162 | username: ${{ secrets.GORDON_USERNAME }} | 163 | username: ${{ secrets.GORDON_USERNAME }} | ||
| @@ -178,8 +179,10 @@ jobs: | |||||
| 178 | deploy-api: | 179 | deploy-api: | ||
| 179 | runs-on: ubuntu-latest | 180 | runs-on: ubuntu-latest | ||
| 180 | steps: | 181 | steps: | ||
| 181 | - | - uses: actions/checkout@v6 | 182 | + | - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 |
| 182 | - | - uses: bnema/gordon/.github/actions/deploy@main | 183 | + | with: |
| 184 | + | persist-credentials: false | |||
| 185 | + | - uses: bnema/gordon/.github/actions/deploy@271c36a4cc32fabd91b20f93cd006bdd2a01b181 | |||
| 183 | with: | 186 | with: | ||
| 184 | registry: registry.mydomain.com | 187 | registry: registry.mydomain.com | ||
| 185 | username: ${{ secrets.GORDON_USERNAME }} | 188 | username: ${{ secrets.GORDON_USERNAME }} | ||
| @@ -191,8 +194,10 @@ jobs: | |||||
| 191 | deploy-web: | 194 | deploy-web: | ||
| 192 | runs-on: ubuntu-latest | 195 | runs-on: ubuntu-latest | ||
| 193 | steps: | 196 | steps: | ||
| 194 | - | - uses: actions/checkout@v6 | 197 | + | - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 |
| 195 | - | - uses: bnema/gordon/.github/actions/deploy@main | 198 | + | with: |
| 199 | + | persist-credentials: false | |||
| 200 | + | - uses: bnema/gordon/.github/actions/deploy@271c36a4cc32fabd91b20f93cd006bdd2a01b181 | |||
| 196 | with: | 201 | with: | ||
| 197 | registry: registry.mydomain.com | 202 | registry: registry.mydomain.com | ||
| 198 | username: ${{ secrets.GORDON_USERNAME }} | 203 | username: ${{ secrets.GORDON_USERNAME }} | ||
| @@ -204,15 +209,7 @@ jobs: | |||||
| 204 | 209 | ||||
| 205 | ### Build with Secrets (BuildKit) | 210 | ### Build with Secrets (BuildKit) | ||
| 206 | 211 | ||||
| 207 | - | ```yaml | 212 | + | The deploy action intentionally does not accept build arguments because CI secrets passed as build arguments can remain in image metadata or layers. For secret-bearing builds, use `docker/build-push-action` with its BuildKit `secrets` input, reference the secret with `RUN --mount=type=secret` in the Dockerfile, and pin every action to a reviewed commit SHA. |
| 208 | - | - uses: bnema/gordon/.github/actions/deploy@main | |||
| 209 | - | with: | |||
| 210 | - | registry: registry.mydomain.com | |||
| 211 | - | username: ${{ secrets.GORDON_USERNAME }} | |||
| 212 | - | password: ${{ secrets.GORDON_TOKEN }} | |||
| 213 | - | build-args: | | |||
| 214 | - | NPM_TOKEN=${{ secrets.NPM_TOKEN }} | |||
| 215 | - | ``` | |||
| 216 | 213 | ||||
| 217 | ## Troubleshooting | 214 | ## Troubleshooting | ||
| 218 | 215 | ||||
M .github/actions/deploy/action.yml +69 -34
| @@ -32,10 +32,6 @@ inputs: | |||
| 32 | 32 | description: 'Build context path (defaults to .)' | |
| 33 | 33 | required: false | |
| 34 | 34 | default: '.' | |
| 35 | - | build-args: | |
| 36 | - | description: 'Build arguments (one per line, KEY=value format)' | |
| 37 | - | required: false | |
| 38 | - | default: '' | |
| 39 | 35 | platforms: | |
| 40 | 36 | description: 'Target platforms (e.g., linux/amd64,linux/arm64)' | |
| 41 | 37 | required: false | |
| @@ -69,49 +65,76 @@ runs: | |||
| 69 | 65 | steps: | |
| 70 | 66 | - name: Validate inputs | |
| 71 | 67 | shell: bash | |
| 68 | + | env: | |
| 69 | + | INPUT_DOCKERFILE: ${{ inputs.dockerfile }} | |
| 72 | 70 | run: | | |
| 73 | - | if [[ ! -f "${{ inputs.dockerfile }}" ]]; then | |
| 74 | - | echo "::error::Dockerfile not found at ${{ inputs.dockerfile }}" | |
| 71 | + | if [[ ! -f "${INPUT_DOCKERFILE}" ]]; then | |
| 72 | + | echo "::error::Dockerfile not found at ${INPUT_DOCKERFILE}" | |
| 75 | 73 | exit 1 | |
| 76 | 74 | fi | |
| 77 | 75 | ||
| 78 | 76 | - name: Extract metadata | |
| 79 | 77 | id: meta | |
| 80 | 78 | shell: bash | |
| 79 | + | env: | |
| 80 | + | INPUT_IMAGE: ${{ inputs.image }} | |
| 81 | + | INPUT_TAG: ${{ inputs.tag }} | |
| 82 | + | INPUT_REGISTRY: ${{ inputs.registry }} | |
| 81 | 83 | run: | | |
| 82 | - | # Determine image name | |
| 83 | - | IMAGE_NAME="${{ inputs.image }}" | |
| 84 | - | if [[ -z "$IMAGE_NAME" ]]; then | |
| 85 | - | IMAGE_NAME="${GITHUB_REPOSITORY#*/}" | |
| 86 | - | fi | |
| 84 | + | reject_line_breaks() { | |
| 85 | + | [[ "$2" != *$'\n'* && "$2" != *$'\r'* ]] || { | |
| 86 | + | echo "::error::$1 must not contain line breaks" | |
| 87 | + | exit 1 | |
| 88 | + | } | |
| 89 | + | } | |
| 90 | + | reject_line_breaks "registry" "$INPUT_REGISTRY" | |
| 91 | + | reject_line_breaks "image" "$INPUT_IMAGE" | |
| 92 | + | reject_line_breaks "tag" "$INPUT_TAG" | |
| 87 | 93 | ||
| 88 | - | # Extract tag from ref (or override) | |
| 89 | - | if [[ -n "${{ inputs.tag }}" ]]; then | |
| 90 | - | TAG="${{ inputs.tag }}" | |
| 94 | + | IMAGE_NAME="${INPUT_IMAGE:-${GITHUB_REPOSITORY#*/}}" | |
| 95 | + | if [[ -n "${INPUT_TAG}" ]]; then | |
| 96 | + | TAG="${INPUT_TAG}" | |
| 91 | 97 | elif [[ "$GITHUB_REF" == refs/tags/* ]]; then | |
| 92 | 98 | TAG="${GITHUB_REF#refs/tags/}" | |
| 93 | 99 | else | |
| 94 | - | TAG="$(echo "$GITHUB_SHA" | head -c 7)" | |
| 100 | + | TAG="${GITHUB_SHA:0:7}" | |
| 101 | + | fi | |
| 102 | + | ||
| 103 | + | [[ "$INPUT_REGISTRY" =~ ^[a-zA-Z0-9.-]+(:[0-9]{1,5})?$ ]] || { | |
| 104 | + | echo "::error::registry must be a hostname with an optional port" | |
| 105 | + | exit 1 | |
| 106 | + | } | |
| 107 | + | if [[ "$INPUT_REGISTRY" =~ :([0-9]+)$ ]]; then | |
| 108 | + | PORT="${BASH_REMATCH[1]}" | |
| 109 | + | [[ "$PORT" != 0* && $((10#$PORT)) -ge 1 && $((10#$PORT)) -le 65535 ]] || { | |
| 110 | + | echo "::error::registry port must be between 1 and 65535 without leading zeros" | |
| 111 | + | exit 1 | |
| 112 | + | } | |
| 95 | 113 | fi | |
| 114 | + | [[ "$IMAGE_NAME" =~ ^[a-z0-9]+([._-][a-z0-9]+)*(/[a-z0-9]+([._-][a-z0-9]+)*)*$ ]] || { | |
| 115 | + | echo "::error::image contains invalid Docker repository components" | |
| 116 | + | exit 1 | |
| 117 | + | } | |
| 118 | + | [[ "$TAG" =~ ^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$ ]] || { | |
| 119 | + | echo "::error::tag is not a valid Docker tag" | |
| 120 | + | exit 1 | |
| 121 | + | } | |
| 96 | 122 | ||
| 97 | - | # Full image reference | |
| 98 | - | FULL_IMAGE="${{ inputs.registry }}/${IMAGE_NAME}:${TAG}" | |
| 123 | + | FULL_IMAGE="${INPUT_REGISTRY}/${IMAGE_NAME}:${TAG}" | |
| 99 | 124 | ||
| 100 | - | echo "image=${FULL_IMAGE}" >> $GITHUB_OUTPUT | |
| 101 | - | echo "image_name=${IMAGE_NAME}" >> $GITHUB_OUTPUT | |
| 102 | - | echo "tag=${TAG}" >> $GITHUB_OUTPUT | |
| 103 | - | echo "registry=${{ inputs.registry }}" >> $GITHUB_OUTPUT | |
| 125 | + | printf 'image=%s\nimage_name=%s\ntag=%s\nregistry=%s\n' \ | |
| 126 | + | "${FULL_IMAGE}" "${IMAGE_NAME}" "${TAG}" "${INPUT_REGISTRY}" >> "${GITHUB_OUTPUT}" | |
| 104 | 127 | ||
| 105 | 128 | echo "Building: ${FULL_IMAGE}" | |
| 106 | 129 | ||
| 107 | 130 | - name: Set up QEMU | |
| 108 | - | uses: docker/setup-qemu-action@v3 | |
| 131 | + | uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3 | |
| 109 | 132 | ||
| 110 | 133 | - name: Set up Docker Buildx | |
| 111 | - | uses: docker/setup-buildx-action@v3 | |
| 134 | + | uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 | |
| 112 | 135 | ||
| 113 | 136 | - name: Log in to Gordon registry | |
| 114 | - | uses: docker/login-action@v3 | |
| 137 | + | uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3 | |
| 115 | 138 | with: | |
| 116 | 139 | registry: ${{ inputs.registry }} | |
| 117 | 140 | username: ${{ inputs.username }} | |
| @@ -120,16 +143,25 @@ runs: | |||
| 120 | 143 | - name: Build tags | |
| 121 | 144 | id: tags | |
| 122 | 145 | shell: bash | |
| 146 | + | env: | |
| 147 | + | META_IMAGE: ${{ steps.meta.outputs.image }} | |
| 148 | + | META_IMAGE_NAME: ${{ steps.meta.outputs.image_name }} | |
| 149 | + | INPUT_REGISTRY: ${{ inputs.registry }} | |
| 150 | + | INPUT_PUSH_LATEST: ${{ inputs.push-latest }} | |
| 123 | 151 | run: | | |
| 124 | - | TAGS="${{ steps.meta.outputs.image }}" | |
| 125 | - | if [[ "${{ inputs.push-latest }}" == "true" ]]; then | |
| 126 | - | TAGS="${TAGS}"$'\n'"${{ inputs.registry }}/${{ steps.meta.outputs.image_name }}:latest" | |
| 152 | + | TAGS="${META_IMAGE}" | |
| 153 | + | if [[ "${INPUT_PUSH_LATEST}" == "true" ]]; then | |
| 154 | + | TAGS="${TAGS}"$'\n'"${INPUT_REGISTRY}/${META_IMAGE_NAME}:latest" | |
| 127 | 155 | fi | |
| 128 | - | printf "tags<<EOF\n%s\nEOF\n" "$TAGS" >> "$GITHUB_OUTPUT" | |
| 156 | + | DELIMITER="gordon_$(openssl rand -hex 16)" | |
| 157 | + | while grep -Fqx "$DELIMITER" <<< "$TAGS"; do | |
| 158 | + | DELIMITER="gordon_$(openssl rand -hex 16)" | |
| 159 | + | done | |
| 160 | + | printf 'tags<<%s\n%s\n%s\n' "$DELIMITER" "$TAGS" "$DELIMITER" >> "$GITHUB_OUTPUT" | |
| 129 | 161 | ||
| 130 | 162 | - name: Build and push | |
| 131 | 163 | id: push | |
| 132 | - | uses: docker/build-push-action@v6 | |
| 164 | + | uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6 | |
| 133 | 165 | env: | |
| 134 | 166 | DOCKER_BUILD_SUMMARY: false | |
| 135 | 167 | with: | |
| @@ -137,7 +169,6 @@ runs: | |||
| 137 | 169 | file: ${{ inputs.dockerfile }} | |
| 138 | 170 | push: true | |
| 139 | 171 | tags: ${{ steps.tags.outputs.tags }} | |
| 140 | - | build-args: ${{ inputs.build-args }} | |
| 141 | 172 | platforms: ${{ inputs.platforms }} | |
| 142 | 173 | cache-from: ${{ inputs.cache-from }} | |
| 143 | 174 | cache-to: ${{ inputs.cache-to }} | |
| @@ -145,13 +176,17 @@ runs: | |||
| 145 | 176 | ||
| 146 | 177 | - name: Summary | |
| 147 | 178 | shell: bash | |
| 179 | + | env: | |
| 180 | + | META_IMAGE: ${{ steps.meta.outputs.image }} | |
| 181 | + | META_TAG: ${{ steps.meta.outputs.tag }} | |
| 182 | + | PUSH_DIGEST: ${{ steps.push.outputs.digest }} | |
| 148 | 183 | run: | | |
| 149 | - | echo "## Gordon Deploy Summary" >> $GITHUB_STEP_SUMMARY | |
| 184 | + | echo "## Gordon Deploy Summary" >> "$GITHUB_STEP_SUMMARY" | |
| 150 | 185 | echo "" >> $GITHUB_STEP_SUMMARY | |
| 151 | 186 | echo "| Field | Value |" >> $GITHUB_STEP_SUMMARY | |
| 152 | 187 | echo "|-------|-------|" >> $GITHUB_STEP_SUMMARY | |
| 153 | - | echo "| Image | \`${{ steps.meta.outputs.image }}\` |" >> $GITHUB_STEP_SUMMARY | |
| 154 | - | echo "| Tag | \`${{ steps.meta.outputs.tag }}\` |" >> $GITHUB_STEP_SUMMARY | |
| 155 | - | echo "| Digest | \`${{ steps.push.outputs.digest }}\` |" >> $GITHUB_STEP_SUMMARY | |
| 188 | + | printf '| Image | `%s` |\n' "$META_IMAGE" >> "$GITHUB_STEP_SUMMARY" | |
| 189 | + | printf '| Tag | `%s` |\n' "$META_TAG" >> "$GITHUB_STEP_SUMMARY" | |
| 190 | + | printf '| Digest | `%s` |\n' "$PUSH_DIGEST" >> "$GITHUB_STEP_SUMMARY" | |
| 156 | 191 | echo "" >> $GITHUB_STEP_SUMMARY | |
| 157 | 192 | echo "Container deployed to Gordon registry and will be automatically deployed." >> $GITHUB_STEP_SUMMARY | |
| @@ -32,10 +32,6 @@ inputs: | |||||
| 32 | description: 'Build context path (defaults to .)' | 32 | description: 'Build context path (defaults to .)' | ||
| 33 | required: false | 33 | required: false | ||
| 34 | default: '.' | 34 | default: '.' | ||
| 35 | - | build-args: | |||
| 36 | - | description: 'Build arguments (one per line, KEY=value format)' | |||
| 37 | - | required: false | |||
| 38 | - | default: '' | |||
| 39 | platforms: | 35 | platforms: | ||
| 40 | description: 'Target platforms (e.g., linux/amd64,linux/arm64)' | 36 | description: 'Target platforms (e.g., linux/amd64,linux/arm64)' | ||
| 41 | required: false | 37 | required: false | ||
| @@ -69,49 +65,76 @@ runs: | |||||
| 69 | steps: | 65 | steps: | ||
| 70 | - name: Validate inputs | 66 | - name: Validate inputs | ||
| 71 | shell: bash | 67 | shell: bash | ||
| 68 | + | env: | |||
| 69 | + | INPUT_DOCKERFILE: ${{ inputs.dockerfile }} | |||
| 72 | run: | | 70 | run: | | ||
| 73 | - | if [[ ! -f "${{ inputs.dockerfile }}" ]]; then | 71 | + | if [[ ! -f "${INPUT_DOCKERFILE}" ]]; then |
| 74 | - | echo "::error::Dockerfile not found at ${{ inputs.dockerfile }}" | 72 | + | echo "::error::Dockerfile not found at ${INPUT_DOCKERFILE}" |
| 75 | exit 1 | 73 | exit 1 | ||
| 76 | fi | 74 | fi | ||
| 77 | 75 | ||||
| 78 | - name: Extract metadata | 76 | - name: Extract metadata | ||
| 79 | id: meta | 77 | id: meta | ||
| 80 | shell: bash | 78 | shell: bash | ||
| 79 | + | env: | |||
| 80 | + | INPUT_IMAGE: ${{ inputs.image }} | |||
| 81 | + | INPUT_TAG: ${{ inputs.tag }} | |||
| 82 | + | INPUT_REGISTRY: ${{ inputs.registry }} | |||
| 81 | run: | | 83 | run: | | ||
| 82 | - | # Determine image name | 84 | + | reject_line_breaks() { |
| 83 | - | IMAGE_NAME="${{ inputs.image }}" | 85 | + | [[ "$2" != *$'\n'* && "$2" != *$'\r'* ]] || { |
| 84 | - | if [[ -z "$IMAGE_NAME" ]]; then | 86 | + | echo "::error::$1 must not contain line breaks" |
| 85 | - | IMAGE_NAME="${GITHUB_REPOSITORY#*/}" | 87 | + | exit 1 |
| 86 | - | fi | 88 | + | } |
| 89 | + | } | |||
| 90 | + | reject_line_breaks "registry" "$INPUT_REGISTRY" | |||
| 91 | + | reject_line_breaks "image" "$INPUT_IMAGE" | |||
| 92 | + | reject_line_breaks "tag" "$INPUT_TAG" | |||
| 87 | 93 | ||||
| 88 | - | # Extract tag from ref (or override) | 94 | + | IMAGE_NAME="${INPUT_IMAGE:-${GITHUB_REPOSITORY#*/}}" |
| 89 | - | if [[ -n "${{ inputs.tag }}" ]]; then | 95 | + | if [[ -n "${INPUT_TAG}" ]]; then |
| 90 | - | TAG="${{ inputs.tag }}" | 96 | + | TAG="${INPUT_TAG}" |
| 91 | elif [[ "$GITHUB_REF" == refs/tags/* ]]; then | 97 | elif [[ "$GITHUB_REF" == refs/tags/* ]]; then | ||
| 92 | TAG="${GITHUB_REF#refs/tags/}" | 98 | TAG="${GITHUB_REF#refs/tags/}" | ||
| 93 | else | 99 | else | ||
| 94 | - | TAG="$(echo "$GITHUB_SHA" | head -c 7)" | 100 | + | TAG="${GITHUB_SHA:0:7}" |
| 101 | + | fi | |||
| 102 | + | ||||
| 103 | + | [[ "$INPUT_REGISTRY" =~ ^[a-zA-Z0-9.-]+(:[0-9]{1,5})?$ ]] || { | |||
| 104 | + | echo "::error::registry must be a hostname with an optional port" | |||
| 105 | + | exit 1 | |||
| 106 | + | } | |||
| 107 | + | if [[ "$INPUT_REGISTRY" =~ :([0-9]+)$ ]]; then | |||
| 108 | + | PORT="${BASH_REMATCH[1]}" | |||
| 109 | + | [[ "$PORT" != 0* && $((10#$PORT)) -ge 1 && $((10#$PORT)) -le 65535 ]] || { | |||
| 110 | + | echo "::error::registry port must be between 1 and 65535 without leading zeros" | |||
| 111 | + | exit 1 | |||
| 112 | + | } | |||
| 95 | fi | 113 | fi | ||
| 114 | + | [[ "$IMAGE_NAME" =~ ^[a-z0-9]+([._-][a-z0-9]+)*(/[a-z0-9]+([._-][a-z0-9]+)*)*$ ]] || { | |||
| 115 | + | echo "::error::image contains invalid Docker repository components" | |||
| 116 | + | exit 1 | |||
| 117 | + | } | |||
| 118 | + | [[ "$TAG" =~ ^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$ ]] || { | |||
| 119 | + | echo "::error::tag is not a valid Docker tag" | |||
| 120 | + | exit 1 | |||
| 121 | + | } | |||
| 96 | 122 | ||||
| 97 | - | # Full image reference | 123 | + | FULL_IMAGE="${INPUT_REGISTRY}/${IMAGE_NAME}:${TAG}" |
| 98 | - | FULL_IMAGE="${{ inputs.registry }}/${IMAGE_NAME}:${TAG}" | |||
| 99 | 124 | ||||
| 100 | - | echo "image=${FULL_IMAGE}" >> $GITHUB_OUTPUT | 125 | + | printf 'image=%s\nimage_name=%s\ntag=%s\nregistry=%s\n' \ |
| 101 | - | echo "image_name=${IMAGE_NAME}" >> $GITHUB_OUTPUT | 126 | + | "${FULL_IMAGE}" "${IMAGE_NAME}" "${TAG}" "${INPUT_REGISTRY}" >> "${GITHUB_OUTPUT}" |
| 102 | - | echo "tag=${TAG}" >> $GITHUB_OUTPUT | |||
| 103 | - | echo "registry=${{ inputs.registry }}" >> $GITHUB_OUTPUT | |||
| 104 | 127 | ||||
| 105 | echo "Building: ${FULL_IMAGE}" | 128 | echo "Building: ${FULL_IMAGE}" | ||
| 106 | 129 | ||||
| 107 | - name: Set up QEMU | 130 | - name: Set up QEMU | ||
| 108 | - | uses: docker/setup-qemu-action@v3 | 131 | + | uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3 |
| 109 | 132 | ||||
| 110 | - name: Set up Docker Buildx | 133 | - name: Set up Docker Buildx | ||
| 111 | - | uses: docker/setup-buildx-action@v3 | 134 | + | uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 |
| 112 | 135 | ||||
| 113 | - name: Log in to Gordon registry | 136 | - name: Log in to Gordon registry | ||
| 114 | - | uses: docker/login-action@v3 | 137 | + | uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3 |
| 115 | with: | 138 | with: | ||
| 116 | registry: ${{ inputs.registry }} | 139 | registry: ${{ inputs.registry }} | ||
| 117 | username: ${{ inputs.username }} | 140 | username: ${{ inputs.username }} | ||
| @@ -120,16 +143,25 @@ runs: | |||||
| 120 | - name: Build tags | 143 | - name: Build tags | ||
| 121 | id: tags | 144 | id: tags | ||
| 122 | shell: bash | 145 | shell: bash | ||
| 146 | + | env: | |||
| 147 | + | META_IMAGE: ${{ steps.meta.outputs.image }} | |||
| 148 | + | META_IMAGE_NAME: ${{ steps.meta.outputs.image_name }} | |||
| 149 | + | INPUT_REGISTRY: ${{ inputs.registry }} | |||
| 150 | + | INPUT_PUSH_LATEST: ${{ inputs.push-latest }} | |||
| 123 | run: | | 151 | run: | | ||
| 124 | - | TAGS="${{ steps.meta.outputs.image }}" | 152 | + | TAGS="${META_IMAGE}" |
| 125 | - | if [[ "${{ inputs.push-latest }}" == "true" ]]; then | 153 | + | if [[ "${INPUT_PUSH_LATEST}" == "true" ]]; then |
| 126 | - | TAGS="${TAGS}"$'\n'"${{ inputs.registry }}/${{ steps.meta.outputs.image_name }}:latest" | 154 | + | TAGS="${TAGS}"$'\n'"${INPUT_REGISTRY}/${META_IMAGE_NAME}:latest" |
| 127 | fi | 155 | fi | ||
| 128 | - | printf "tags<<EOF\n%s\nEOF\n" "$TAGS" >> "$GITHUB_OUTPUT" | 156 | + | DELIMITER="gordon_$(openssl rand -hex 16)" |
| 157 | + | while grep -Fqx "$DELIMITER" <<< "$TAGS"; do | |||
| 158 | + | DELIMITER="gordon_$(openssl rand -hex 16)" | |||
| 159 | + | done | |||
| 160 | + | printf 'tags<<%s\n%s\n%s\n' "$DELIMITER" "$TAGS" "$DELIMITER" >> "$GITHUB_OUTPUT" | |||
| 129 | 161 | ||||
| 130 | - name: Build and push | 162 | - name: Build and push | ||
| 131 | id: push | 163 | id: push | ||
| 132 | - | uses: docker/build-push-action@v6 | 164 | + | uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6 |
| 133 | env: | 165 | env: | ||
| 134 | DOCKER_BUILD_SUMMARY: false | 166 | DOCKER_BUILD_SUMMARY: false | ||
| 135 | with: | 167 | with: | ||
| @@ -137,7 +169,6 @@ runs: | |||||
| 137 | file: ${{ inputs.dockerfile }} | 169 | file: ${{ inputs.dockerfile }} | ||
| 138 | push: true | 170 | push: true | ||
| 139 | tags: ${{ steps.tags.outputs.tags }} | 171 | tags: ${{ steps.tags.outputs.tags }} | ||
| 140 | - | build-args: ${{ inputs.build-args }} | |||
| 141 | platforms: ${{ inputs.platforms }} | 172 | platforms: ${{ inputs.platforms }} | ||
| 142 | cache-from: ${{ inputs.cache-from }} | 173 | cache-from: ${{ inputs.cache-from }} | ||
| 143 | cache-to: ${{ inputs.cache-to }} | 174 | cache-to: ${{ inputs.cache-to }} | ||
| @@ -145,13 +176,17 @@ runs: | |||||
| 145 | 176 | ||||
| 146 | - name: Summary | 177 | - name: Summary | ||
| 147 | shell: bash | 178 | shell: bash | ||
| 179 | + | env: | |||
| 180 | + | META_IMAGE: ${{ steps.meta.outputs.image }} | |||
| 181 | + | META_TAG: ${{ steps.meta.outputs.tag }} | |||
| 182 | + | PUSH_DIGEST: ${{ steps.push.outputs.digest }} | |||
| 148 | run: | | 183 | run: | | ||
| 149 | - | echo "## Gordon Deploy Summary" >> $GITHUB_STEP_SUMMARY | 184 | + | echo "## Gordon Deploy Summary" >> "$GITHUB_STEP_SUMMARY" |
| 150 | echo "" >> $GITHUB_STEP_SUMMARY | 185 | echo "" >> $GITHUB_STEP_SUMMARY | ||
| 151 | echo "| Field | Value |" >> $GITHUB_STEP_SUMMARY | 186 | echo "| Field | Value |" >> $GITHUB_STEP_SUMMARY | ||
| 152 | echo "|-------|-------|" >> $GITHUB_STEP_SUMMARY | 187 | echo "|-------|-------|" >> $GITHUB_STEP_SUMMARY | ||
| 153 | - | echo "| Image | \`${{ steps.meta.outputs.image }}\` |" >> $GITHUB_STEP_SUMMARY | 188 | + | printf '| Image | `%s` |\n' "$META_IMAGE" >> "$GITHUB_STEP_SUMMARY" |
| 154 | - | echo "| Tag | \`${{ steps.meta.outputs.tag }}\` |" >> $GITHUB_STEP_SUMMARY | 189 | + | printf '| Tag | `%s` |\n' "$META_TAG" >> "$GITHUB_STEP_SUMMARY" |
| 155 | - | echo "| Digest | \`${{ steps.push.outputs.digest }}\` |" >> $GITHUB_STEP_SUMMARY | 190 | + | printf '| Digest | `%s` |\n' "$PUSH_DIGEST" >> "$GITHUB_STEP_SUMMARY" |
| 156 | echo "" >> $GITHUB_STEP_SUMMARY | 191 | echo "" >> $GITHUB_STEP_SUMMARY | ||
| 157 | echo "Container deployed to Gordon registry and will be automatically deployed." >> $GITHUB_STEP_SUMMARY | 192 | echo "Container deployed to Gordon registry and will be automatically deployed." >> $GITHUB_STEP_SUMMARY | ||
M .github/workflows/release.yml +4 -3
| @@ -13,16 +13,17 @@ jobs: | |||
| 13 | 13 | name: Release | |
| 14 | 14 | runs-on: ubuntu-latest | |
| 15 | 15 | steps: | |
| 16 | - | - uses: actions/checkout@v6 | |
| 16 | + | - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 | |
| 17 | 17 | with: | |
| 18 | 18 | fetch-depth: 0 | |
| 19 | + | persist-credentials: false | |
| 19 | 20 | ||
| 20 | - | - uses: actions/setup-go@v5 | |
| 21 | + | - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5 | |
| 21 | 22 | with: | |
| 22 | 23 | go-version: stable | |
| 23 | 24 | ||
| 24 | 25 | - name: Run GoReleaser | |
| 25 | - | uses: goreleaser/goreleaser-action@v6 | |
| 26 | + | uses: goreleaser/goreleaser-action@e435ccd777264be153ace6237001ef4d979d3a7a # v6 | |
| 26 | 27 | with: | |
| 27 | 28 | distribution: goreleaser | |
| 28 | 29 | version: "~> v2" | |
| @@ -13,16 +13,17 @@ jobs: | |||||
| 13 | name: Release | 13 | name: Release | ||
| 14 | runs-on: ubuntu-latest | 14 | runs-on: ubuntu-latest | ||
| 15 | steps: | 15 | steps: | ||
| 16 | - | - uses: actions/checkout@v6 | 16 | + | - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 |
| 17 | with: | 17 | with: | ||
| 18 | fetch-depth: 0 | 18 | fetch-depth: 0 | ||
| 19 | + | persist-credentials: false | |||
| 19 | 20 | ||||
| 20 | - | - uses: actions/setup-go@v5 | 21 | + | - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5 |
| 21 | with: | 22 | with: | ||
| 22 | go-version: stable | 23 | go-version: stable | ||
| 23 | 24 | ||||
| 24 | - name: Run GoReleaser | 25 | - name: Run GoReleaser | ||
| 25 | - | uses: goreleaser/goreleaser-action@v6 | 26 | + | uses: goreleaser/goreleaser-action@e435ccd777264be153ace6237001ef4d979d3a7a # v6 |
| 26 | with: | 27 | with: | ||
| 27 | distribution: goreleaser | 28 | distribution: goreleaser | ||
| 28 | version: "~> v2" | 29 | version: "~> v2" | ||
M docs/deployment/github-actions.md +8 -4
| @@ -138,11 +138,15 @@ jobs: | |||
| 138 | 138 | - name: Build and Deploy | |
| 139 | 139 | env: | |
| 140 | 140 | GORDON_TOKEN: ${{ secrets.GORDON_TOKEN }} | |
| 141 | + | GORDON_REMOTE: ${{ secrets.GORDON_REMOTE }} | |
| 142 | + | DEPLOY_TAG: ${{ inputs.tag }} | |
| 141 | 143 | 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 | |
| 144 | + | args=(push --build --remote "$GORDON_REMOTE" --no-confirm) | |
| 145 | + | if [[ -n "$DEPLOY_TAG" ]]; then | |
| 146 | + | [[ "$DEPLOY_TAG" =~ ^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$ ]] || exit 1 | |
| 147 | + | args+=(--tag "$DEPLOY_TAG") | |
| 148 | + | fi | |
| 149 | + | gordon "${args[@]}" | |
| 146 | 150 | ``` | |
| 147 | 151 | ||
| 148 | 152 | #### Monorepo | |
| @@ -138,11 +138,15 @@ jobs: | |||||
| 138 | - name: Build and Deploy | 138 | - name: Build and Deploy | ||
| 139 | env: | 139 | env: | ||
| 140 | GORDON_TOKEN: ${{ secrets.GORDON_TOKEN }} | 140 | GORDON_TOKEN: ${{ secrets.GORDON_TOKEN }} | ||
| 141 | + | GORDON_REMOTE: ${{ secrets.GORDON_REMOTE }} | |||
| 142 | + | DEPLOY_TAG: ${{ inputs.tag }} | |||
| 141 | run: | | 143 | run: | | ||
| 142 | - | gordon push --build \ | 144 | + | args=(push --build --remote "$GORDON_REMOTE" --no-confirm) |
| 143 | - | --remote ${{ secrets.GORDON_REMOTE }} \ | 145 | + | if [[ -n "$DEPLOY_TAG" ]]; then |
| 144 | - | ${{ github.event.inputs.tag && format('--tag {0}', github.event.inputs.tag) || '' }} \ | 146 | + | [[ "$DEPLOY_TAG" =~ ^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$ ]] || exit 1 |
| 145 | - | --no-confirm | 147 | + | args+=(--tag "$DEPLOY_TAG") |
| 148 | + | fi | |||
| 149 | + | gordon "${args[@]}" | |||
| 146 | ``` | 150 | ``` | ||
| 147 | 151 | ||||
| 148 | #### Monorepo | 152 | #### Monorepo | ||
M docs/deployment/rollback.md +24 -9
| @@ -147,21 +147,36 @@ jobs: | |||
| 147 | 147 | runs-on: ubuntu-latest | |
| 148 | 148 | steps: | |
| 149 | 149 | - name: Login to Registry | |
| 150 | - | run: | | |
| 151 | - | echo "${{ secrets.GORDON_TOKEN }}" | \ | |
| 152 | - | docker login -u ${{ secrets.GORDON_USERNAME }} --password-stdin ${{ secrets.GORDON_REGISTRY }} | |
| 150 | + | env: | |
| 151 | + | GORDON_TOKEN: ${{ secrets.GORDON_TOKEN }} | |
| 152 | + | GORDON_USERNAME: ${{ secrets.GORDON_USERNAME }} | |
| 153 | + | GORDON_REGISTRY: ${{ secrets.GORDON_REGISTRY }} | |
| 154 | + | run: printf '%s' "$GORDON_TOKEN" | docker login -u "$GORDON_USERNAME" --password-stdin "$GORDON_REGISTRY" | |
| 153 | 155 | ||
| 154 | 156 | - name: Rollback | |
| 157 | + | env: | |
| 158 | + | GORDON_REGISTRY: ${{ secrets.GORDON_REGISTRY }} | |
| 159 | + | VERSION: ${{ inputs.version }} | |
| 155 | 160 | run: | | |
| 156 | - | docker pull ${{ secrets.GORDON_REGISTRY }}/myapp:${{ github.event.inputs.version }} | |
| 157 | - | docker tag ${{ secrets.GORDON_REGISTRY }}/myapp:${{ github.event.inputs.version }} \ | |
| 158 | - | ${{ secrets.GORDON_REGISTRY }}/myapp:latest | |
| 159 | - | docker push ${{ secrets.GORDON_REGISTRY }}/myapp:latest | |
| 161 | + | [[ "$VERSION" =~ ^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$ ]] || exit 1 | |
| 162 | + | REPOSITORY="$GORDON_REGISTRY/myapp" | |
| 163 | + | SOURCE_IMAGE="$REPOSITORY:$VERSION" | |
| 164 | + | LATEST_IMAGE="$REPOSITORY:latest" | |
| 165 | + | SOURCE_DIGEST="$(docker buildx imagetools inspect "$SOURCE_IMAGE" --format '{{json .Manifest}}' | jq -er '.digest')" | |
| 166 | + | IMMUTABLE_IMAGE="$REPOSITORY@$SOURCE_DIGEST" | |
| 167 | + | docker buildx imagetools create --tag "$LATEST_IMAGE" "$IMMUTABLE_IMAGE" | |
| 168 | + | LATEST_DIGEST="$(docker buildx imagetools inspect "$LATEST_IMAGE" --format '{{json .Manifest}}' | jq -er '.digest')" | |
| 169 | + | [[ "$LATEST_DIGEST" == "$SOURCE_DIGEST" ]] || { | |
| 170 | + | echo "rollback verification failed: latest resolved to $LATEST_DIGEST, expected $SOURCE_DIGEST" >&2 | |
| 171 | + | exit 1 | |
| 172 | + | } | |
| 160 | 173 | ||
| 161 | 174 | - name: Summary | |
| 175 | + | env: | |
| 176 | + | VERSION: ${{ inputs.version }} | |
| 162 | 177 | run: | | |
| 163 | - | echo "## Rollback Complete" >> $GITHUB_STEP_SUMMARY | |
| 164 | - | echo "Rolled back to version: ${{ github.event.inputs.version }}" >> $GITHUB_STEP_SUMMARY | |
| 178 | + | echo "## Rollback Complete" >> "$GITHUB_STEP_SUMMARY" | |
| 179 | + | printf 'Rolled back to version: %s\n' "$VERSION" >> "$GITHUB_STEP_SUMMARY" | |
| 165 | 180 | ``` | |
| 166 | 181 | ||
| 167 | 182 | ### Rollback Script | |
| @@ -147,21 +147,36 @@ jobs: | |||||
| 147 | runs-on: ubuntu-latest | 147 | runs-on: ubuntu-latest | ||
| 148 | steps: | 148 | steps: | ||
| 149 | - name: Login to Registry | 149 | - name: Login to Registry | ||
| 150 | - | run: | | 150 | + | env: |
| 151 | - | echo "${{ secrets.GORDON_TOKEN }}" | \ | 151 | + | GORDON_TOKEN: ${{ secrets.GORDON_TOKEN }} |
| 152 | - | docker login -u ${{ secrets.GORDON_USERNAME }} --password-stdin ${{ secrets.GORDON_REGISTRY }} | 152 | + | GORDON_USERNAME: ${{ secrets.GORDON_USERNAME }} |
| 153 | + | GORDON_REGISTRY: ${{ secrets.GORDON_REGISTRY }} | |||
| 154 | + | run: printf '%s' "$GORDON_TOKEN" | docker login -u "$GORDON_USERNAME" --password-stdin "$GORDON_REGISTRY" | |||
| 153 | 155 | ||||
| 154 | - name: Rollback | 156 | - name: Rollback | ||
| 157 | + | env: | |||
| 158 | + | GORDON_REGISTRY: ${{ secrets.GORDON_REGISTRY }} | |||
| 159 | + | VERSION: ${{ inputs.version }} | |||
| 155 | run: | | 160 | run: | | ||
| 156 | - | docker pull ${{ secrets.GORDON_REGISTRY }}/myapp:${{ github.event.inputs.version }} | 161 | + | [[ "$VERSION" =~ ^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$ ]] || exit 1 |
| 157 | - | docker tag ${{ secrets.GORDON_REGISTRY }}/myapp:${{ github.event.inputs.version }} \ | 162 | + | REPOSITORY="$GORDON_REGISTRY/myapp" |
| 158 | - | ${{ secrets.GORDON_REGISTRY }}/myapp:latest | 163 | + | SOURCE_IMAGE="$REPOSITORY:$VERSION" |
| 159 | - | docker push ${{ secrets.GORDON_REGISTRY }}/myapp:latest | 164 | + | LATEST_IMAGE="$REPOSITORY:latest" |
| 165 | + | SOURCE_DIGEST="$(docker buildx imagetools inspect "$SOURCE_IMAGE" --format '{{json .Manifest}}' | jq -er '.digest')" | |||
| 166 | + | IMMUTABLE_IMAGE="$REPOSITORY@$SOURCE_DIGEST" | |||
| 167 | + | docker buildx imagetools create --tag "$LATEST_IMAGE" "$IMMUTABLE_IMAGE" | |||
| 168 | + | LATEST_DIGEST="$(docker buildx imagetools inspect "$LATEST_IMAGE" --format '{{json .Manifest}}' | jq -er '.digest')" | |||
| 169 | + | [[ "$LATEST_DIGEST" == "$SOURCE_DIGEST" ]] || { | |||
| 170 | + | echo "rollback verification failed: latest resolved to $LATEST_DIGEST, expected $SOURCE_DIGEST" >&2 | |||
| 171 | + | exit 1 | |||
| 172 | + | } | |||
| 160 | 173 | ||||
| 161 | - name: Summary | 174 | - name: Summary | ||
| 175 | + | env: | |||
| 176 | + | VERSION: ${{ inputs.version }} | |||
| 162 | run: | | 177 | run: | | ||
| 163 | - | echo "## Rollback Complete" >> $GITHUB_STEP_SUMMARY | 178 | + | echo "## Rollback Complete" >> "$GITHUB_STEP_SUMMARY" |
| 164 | - | echo "Rolled back to version: ${{ github.event.inputs.version }}" >> $GITHUB_STEP_SUMMARY | 179 | + | printf 'Rolled back to version: %s\n' "$VERSION" >> "$GITHUB_STEP_SUMMARY" |
| 165 | ``` | 180 | ``` | ||
| 166 | 181 | ||||
| 167 | ### Rollback Script | 182 | ### Rollback Script | ||
M docs/installation.md +31 -8
| @@ -15,46 +15,68 @@ Detailed installation guide for production environments. | |||
| 15 | 15 | ### Quick Install (Recommended) | |
| 16 | 16 | ||
| 17 | 17 | ```bash | |
| 18 | - | curl -fsSL https://gordon.bnema.dev/install | bash | |
| 18 | + | ( | |
| 19 | + | set -euo pipefail | |
| 20 | + | installer="$(mktemp)" | |
| 21 | + | trap 'rm -f "$installer"' EXIT | |
| 22 | + | curl -fsSL --output "$installer" https://gordon.bnema.dev/install | |
| 23 | + | if command -v less >/dev/null 2>&1; then | |
| 24 | + | less "$installer" | |
| 25 | + | else | |
| 26 | + | cat "$installer" | |
| 27 | + | fi | |
| 28 | + | bash "$installer" | |
| 29 | + | ) | |
| 19 | 30 | ``` | |
| 20 | 31 | ||
| 21 | - | This script automatically detects your OS (Linux/macOS) and architecture (amd64/arm64), downloads the appropriate binary from GitHub releases, and installs it to `/usr/local/bin`. | |
| 32 | + | Download and inspect the installer before executing it. The installer verifies the release archive checksum before installation and automatically detects your OS (Linux/macOS) and architecture (amd64/arm64), downloads the appropriate binary from GitHub releases, and installs it to `/usr/local/bin`. | |
| 22 | 33 | ||
| 23 | 34 | ### Manual Installation | |
| 24 | 35 | ||
| 36 | + | Manual archive installation requires downloading the matching `checksums.txt` release asset and verifying the archive's SHA-256 checksum before extraction. The checksum-verifying installer above is recommended. | |
| 37 | + | ||
| 25 | 38 | **Linux (x86_64)** | |
| 39 | + | ||
| 26 | 40 | ```bash | |
| 27 | - | wget https://github.com/bnema/gordon/releases/latest/download/gordon_linux_amd64.tar.gz | |
| 41 | + | wget https://github.com/bnema/gordon/releases/latest/download/{gordon_linux_amd64.tar.gz,checksums.txt} | |
| 42 | + | grep -E '^[[:xdigit:]]{64} ([* ]?)gordon_linux_amd64\.tar\.gz$' checksums.txt | sha256sum --check --strict | |
| 28 | 43 | tar -xzf gordon_linux_amd64.tar.gz | |
| 29 | 44 | chmod +x gordon | |
| 30 | 45 | sudo mv gordon /usr/local/bin/ | |
| 31 | 46 | ``` | |
| 32 | 47 | ||
| 33 | 48 | **Linux (ARM64)** - for Raspberry Pi 4, AWS Graviton, Oracle Ampere, etc. | |
| 49 | + | ||
| 34 | 50 | ```bash | |
| 35 | - | wget https://github.com/bnema/gordon/releases/latest/download/gordon_linux_arm64.tar.gz | |
| 51 | + | wget https://github.com/bnema/gordon/releases/latest/download/{gordon_linux_arm64.tar.gz,checksums.txt} | |
| 52 | + | grep -E '^[[:xdigit:]]{64} ([* ]?)gordon_linux_arm64\.tar\.gz$' checksums.txt | sha256sum --check --strict | |
| 36 | 53 | tar -xzf gordon_linux_arm64.tar.gz | |
| 37 | 54 | chmod +x gordon | |
| 38 | 55 | sudo mv gordon /usr/local/bin/ | |
| 39 | 56 | ``` | |
| 40 | 57 | ||
| 41 | 58 | **macOS (Apple Silicon)** | |
| 59 | + | ||
| 42 | 60 | ```bash | |
| 43 | - | curl -LO https://github.com/bnema/gordon/releases/latest/download/gordon_darwin_arm64.tar.gz | |
| 61 | + | curl -LO https://github.com/bnema/gordon/releases/latest/download/{gordon_darwin_arm64.tar.gz,checksums.txt} | |
| 62 | + | grep -E '^[[:xdigit:]]{64} ([* ]?)gordon_darwin_arm64\.tar\.gz$' checksums.txt | shasum -a 256 --check | |
| 44 | 63 | tar -xzf gordon_darwin_arm64.tar.gz | |
| 45 | 64 | chmod +x gordon | |
| 46 | 65 | sudo mv gordon /usr/local/bin/ | |
| 47 | 66 | ``` | |
| 48 | 67 | ||
| 49 | 68 | **macOS (Intel)** | |
| 69 | + | ||
| 50 | 70 | ```bash | |
| 51 | - | curl -LO https://github.com/bnema/gordon/releases/latest/download/gordon_darwin_amd64.tar.gz | |
| 71 | + | curl -LO https://github.com/bnema/gordon/releases/latest/download/{gordon_darwin_amd64.tar.gz,checksums.txt} | |
| 72 | + | grep -E '^[[:xdigit:]]{64} ([* ]?)gordon_darwin_amd64\.tar\.gz$' checksums.txt | shasum -a 256 --check | |
| 52 | 73 | tar -xzf gordon_darwin_amd64.tar.gz | |
| 53 | 74 | chmod +x gordon | |
| 54 | 75 | sudo mv gordon /usr/local/bin/ | |
| 55 | 76 | ``` | |
| 56 | 77 | ||
| 57 | 78 | Verify installation: | |
| 79 | + | ||
| 58 | 80 | ```bash | |
| 59 | 81 | gordon version | |
| 60 | 82 | ``` | |
| @@ -74,8 +96,9 @@ sudo mv gordon /usr/local/bin/ | |||
| 74 | 96 | ### Docker | |
| 75 | 97 | ||
| 76 | 98 | ```bash | |
| 77 | - | # Install Docker | |
| 78 | - | curl -fsSL https://get.docker.com | sh | |
| 99 | + | # Install Docker from your distribution's signed package repository. | |
| 100 | + | sudo apt update | |
| 101 | + | sudo apt install docker.io | |
| 79 | 102 | ||
| 80 | 103 | # Add user to docker group | |
| 81 | 104 | sudo usermod -aG docker $USER | |
| @@ -15,46 +15,68 @@ Detailed installation guide for production environments. | |||||
| 15 | ### Quick Install (Recommended) | 15 | ### Quick Install (Recommended) | ||
| 16 | 16 | ||||
| 17 | ```bash | 17 | ```bash | ||
| 18 | - | curl -fsSL https://gordon.bnema.dev/install | bash | 18 | + | ( |
| 19 | + | set -euo pipefail | |||
| 20 | + | installer="$(mktemp)" | |||
| 21 | + | trap 'rm -f "$installer"' EXIT | |||
| 22 | + | curl -fsSL --output "$installer" https://gordon.bnema.dev/install | |||
| 23 | + | if command -v less >/dev/null 2>&1; then | |||
| 24 | + | less "$installer" | |||
| 25 | + | else | |||
| 26 | + | cat "$installer" | |||
| 27 | + | fi | |||
| 28 | + | bash "$installer" | |||
| 29 | + | ) | |||
| 19 | ``` | 30 | ``` | ||
| 20 | 31 | ||||
| 21 | - | This script automatically detects your OS (Linux/macOS) and architecture (amd64/arm64), downloads the appropriate binary from GitHub releases, and installs it to `/usr/local/bin`. | 32 | + | Download and inspect the installer before executing it. The installer verifies the release archive checksum before installation and automatically detects your OS (Linux/macOS) and architecture (amd64/arm64), downloads the appropriate binary from GitHub releases, and installs it to `/usr/local/bin`. |
| 22 | 33 | ||||
| 23 | ### Manual Installation | 34 | ### Manual Installation | ||
| 24 | 35 | ||||
| 36 | + | Manual archive installation requires downloading the matching `checksums.txt` release asset and verifying the archive's SHA-256 checksum before extraction. The checksum-verifying installer above is recommended. | |||
| 37 | + | ||||
| 25 | **Linux (x86_64)** | 38 | **Linux (x86_64)** | ||
| 39 | + | ||||
| 26 | ```bash | 40 | ```bash | ||
| 27 | - | wget https://github.com/bnema/gordon/releases/latest/download/gordon_linux_amd64.tar.gz | 41 | + | wget https://github.com/bnema/gordon/releases/latest/download/{gordon_linux_amd64.tar.gz,checksums.txt} |
| 42 | + | grep -E '^[[:xdigit:]]{64} ([* ]?)gordon_linux_amd64\.tar\.gz$' checksums.txt | sha256sum --check --strict | |||
| 28 | tar -xzf gordon_linux_amd64.tar.gz | 43 | tar -xzf gordon_linux_amd64.tar.gz | ||
| 29 | chmod +x gordon | 44 | chmod +x gordon | ||
| 30 | sudo mv gordon /usr/local/bin/ | 45 | sudo mv gordon /usr/local/bin/ | ||
| 31 | ``` | 46 | ``` | ||
| 32 | 47 | ||||
| 33 | **Linux (ARM64)** - for Raspberry Pi 4, AWS Graviton, Oracle Ampere, etc. | 48 | **Linux (ARM64)** - for Raspberry Pi 4, AWS Graviton, Oracle Ampere, etc. | ||
| 49 | + | ||||
| 34 | ```bash | 50 | ```bash | ||
| 35 | - | wget https://github.com/bnema/gordon/releases/latest/download/gordon_linux_arm64.tar.gz | 51 | + | wget https://github.com/bnema/gordon/releases/latest/download/{gordon_linux_arm64.tar.gz,checksums.txt} |
| 52 | + | grep -E '^[[:xdigit:]]{64} ([* ]?)gordon_linux_arm64\.tar\.gz$' checksums.txt | sha256sum --check --strict | |||
| 36 | tar -xzf gordon_linux_arm64.tar.gz | 53 | tar -xzf gordon_linux_arm64.tar.gz | ||
| 37 | chmod +x gordon | 54 | chmod +x gordon | ||
| 38 | sudo mv gordon /usr/local/bin/ | 55 | sudo mv gordon /usr/local/bin/ | ||
| 39 | ``` | 56 | ``` | ||
| 40 | 57 | ||||
| 41 | **macOS (Apple Silicon)** | 58 | **macOS (Apple Silicon)** | ||
| 59 | + | ||||
| 42 | ```bash | 60 | ```bash | ||
| 43 | - | curl -LO https://github.com/bnema/gordon/releases/latest/download/gordon_darwin_arm64.tar.gz | 61 | + | curl -LO https://github.com/bnema/gordon/releases/latest/download/{gordon_darwin_arm64.tar.gz,checksums.txt} |
| 62 | + | grep -E '^[[:xdigit:]]{64} ([* ]?)gordon_darwin_arm64\.tar\.gz$' checksums.txt | shasum -a 256 --check | |||
| 44 | tar -xzf gordon_darwin_arm64.tar.gz | 63 | tar -xzf gordon_darwin_arm64.tar.gz | ||
| 45 | chmod +x gordon | 64 | chmod +x gordon | ||
| 46 | sudo mv gordon /usr/local/bin/ | 65 | sudo mv gordon /usr/local/bin/ | ||
| 47 | ``` | 66 | ``` | ||
| 48 | 67 | ||||
| 49 | **macOS (Intel)** | 68 | **macOS (Intel)** | ||
| 69 | + | ||||
| 50 | ```bash | 70 | ```bash | ||
| 51 | - | curl -LO https://github.com/bnema/gordon/releases/latest/download/gordon_darwin_amd64.tar.gz | 71 | + | curl -LO https://github.com/bnema/gordon/releases/latest/download/{gordon_darwin_amd64.tar.gz,checksums.txt} |
| 72 | + | grep -E '^[[:xdigit:]]{64} ([* ]?)gordon_darwin_amd64\.tar\.gz$' checksums.txt | shasum -a 256 --check | |||
| 52 | tar -xzf gordon_darwin_amd64.tar.gz | 73 | tar -xzf gordon_darwin_amd64.tar.gz | ||
| 53 | chmod +x gordon | 74 | chmod +x gordon | ||
| 54 | sudo mv gordon /usr/local/bin/ | 75 | sudo mv gordon /usr/local/bin/ | ||
| 55 | ``` | 76 | ``` | ||
| 56 | 77 | ||||
| 57 | Verify installation: | 78 | Verify installation: | ||
| 79 | + | ||||
| 58 | ```bash | 80 | ```bash | ||
| 59 | gordon version | 81 | gordon version | ||
| 60 | ``` | 82 | ``` | ||
| @@ -74,8 +96,9 @@ sudo mv gordon /usr/local/bin/ | |||||
| 74 | ### Docker | 96 | ### Docker | ||
| 75 | 97 | ||||
| 76 | ```bash | 98 | ```bash | ||
| 77 | - | # Install Docker | 99 | + | # Install Docker from your distribution's signed package repository. |
| 78 | - | curl -fsSL https://get.docker.com | sh | 100 | + | sudo apt update |
| 101 | + | sudo apt install docker.io | |||
| 79 | 102 | ||||
| 80 | # Add user to docker group | 103 | # Add user to docker group | ||
| 81 | sudo usermod -aG docker $USER | 104 | sudo usermod -aG docker $USER | ||
M internal/adapters/in/cli/auth.go +32 -17
| @@ -206,31 +206,37 @@ available while Gordon is running.`, | |||
| 206 | 206 | ||
| 207 | 207 | // newAuthLoginCmd creates the login command for remote authentication. | |
| 208 | 208 | func newAuthLoginCmd() *cobra.Command { | |
| 209 | - | var token string | |
| 209 | + | var tokenFile string | |
| 210 | + | var jsonOut bool | |
| 210 | 211 | ||
| 211 | 212 | cmd := &cobra.Command{ | |
| 212 | 213 | Use: "login", | |
| 213 | 214 | Short: "Authenticate with a Gordon server", | |
| 214 | 215 | Long: `Store or verify a token for a Gordon server remote. | |
| 215 | 216 | ||
| 216 | - | With --token: stores the token and verifies it. | |
| 217 | - | Without --token: verifies the existing stored token still works. | |
| 217 | + | With --token-file: reads, stores, and verifies a token without exposing it in argv. | |
| 218 | + | Without --token-file: verifies the existing stored token still works. | |
| 218 | 219 | ||
| 219 | 220 | Generate a token on the server with: gordon auth token generate | |
| 220 | 221 | ||
| 221 | 222 | Examples: | |
| 222 | - | gordon auth login --token <token> Store token for active remote | |
| 223 | - | gordon auth login --remote prod --token <tok> Store token for specific remote | |
| 223 | + | gordon auth login --token-file ./token Store token for active remote | |
| 224 | + | gordon auth login --remote prod --token-file ./token | |
| 224 | 225 | gordon auth login Verify existing token`, | |
| 225 | 226 | RunE: func(cmd *cobra.Command, args []string) error { | |
| 226 | - | if token != "" { | |
| 227 | - | return runAuthLoginWithToken(cmd.Context(), token, cmd.OutOrStdout()) | |
| 227 | + | if tokenFile != "" { | |
| 228 | + | token, err := readProtectedSecretFile(tokenFile) | |
| 229 | + | if err != nil { | |
| 230 | + | return fmt.Errorf("read token: %w", err) | |
| 231 | + | } | |
| 232 | + | return runAuthLoginWithToken(cmd.Context(), token, cmd.OutOrStdout(), jsonOut) | |
| 228 | 233 | } | |
| 229 | - | return runAuthLoginVerify(cmd.Context(), cmd.OutOrStdout()) | |
| 234 | + | return runAuthLoginVerify(cmd.Context(), cmd.OutOrStdout(), jsonOut) | |
| 230 | 235 | }, | |
| 231 | 236 | } | |
| 232 | 237 | ||
| 233 | - | cmd.Flags().StringVarP(&token, "token", "t", "", "Authentication token to store for the remote") | |
| 238 | + | cmd.Flags().StringVar(&tokenFile, "token-file", "", "Read authentication token from a mode 0600 file") | |
| 239 | + | cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON") | |
| 234 | 240 | ||
| 235 | 241 | return cmd | |
| 236 | 242 | } | |
| @@ -247,7 +253,7 @@ func resolveRequiredRemote(flagToken string) (*remote.ResolvedRemote, error) { | |||
| 247 | 253 | return resolved, nil | |
| 248 | 254 | } | |
| 249 | 255 | ||
| 250 | - | func runAuthLoginWithToken(ctx context.Context, token string, out io.Writer) error { | |
| 256 | + | func runAuthLoginWithToken(ctx context.Context, token string, out io.Writer, jsonOut bool) error { | |
| 251 | 257 | token = strings.TrimSpace(token) | |
| 252 | 258 | if token == "" { | |
| 253 | 259 | return fmt.Errorf("token cannot be empty") | |
| @@ -264,19 +270,26 @@ func runAuthLoginWithToken(ctx context.Context, token string, out io.Writer) err | |||
| 264 | 270 | defer cancel() | |
| 265 | 271 | verified := true | |
| 266 | 272 | if _, err := client.GetStatus(verifyCtx); err != nil { | |
| 267 | - | _ = cliWriteLine(out, styles.RenderWarning(fmt.Sprintf("Token verification failed: %v", err))) | |
| 273 | + | if !jsonOut { | |
| 274 | + | _ = cliWriteLine(out, styles.RenderWarning(fmt.Sprintf("Token verification failed: %v", err))) | |
| 275 | + | } | |
| 268 | 276 | verified = false | |
| 269 | 277 | } | |
| 270 | 278 | ||
| 271 | 279 | // Store token — requires a named remote. | |
| 272 | 280 | if resolved.Name == "" { | |
| 273 | - | _ = cliWriteLine(out, styles.RenderWarning("Ad-hoc URL: token verified but cannot be stored. Use 'gordon remotes add' to save this remote.")) | |
| 274 | - | return nil | |
| 281 | + | if jsonOut { | |
| 282 | + | return writeJSON(out, map[string]any{"remote": resolved.DisplayName(), "stored": false, "verified": verified}) | |
| 283 | + | } | |
| 284 | + | return cliWriteLine(out, styles.RenderWarning("Ad-hoc URL: token verified but cannot be stored. Use 'gordon remotes add' to save this remote.")) | |
| 275 | 285 | } | |
| 276 | 286 | if err := remote.UpdateRemoteToken(resolved.Name, token); err != nil { | |
| 277 | 287 | return fmt.Errorf("failed to save token: %w", err) | |
| 278 | 288 | } | |
| 279 | 289 | ||
| 290 | + | if jsonOut { | |
| 291 | + | return writeJSON(out, map[string]any{"remote": resolved.Name, "stored": true, "verified": verified}) | |
| 292 | + | } | |
| 280 | 293 | _ = cliWriteLine(out, "") | |
| 281 | 294 | _ = cliWriteLine(out, styles.RenderSuccess(fmt.Sprintf("Token stored for remote '%s'", resolved.Name))) | |
| 282 | 295 | if verified { | |
| @@ -285,13 +298,13 @@ func runAuthLoginWithToken(ctx context.Context, token string, out io.Writer) err | |||
| 285 | 298 | return nil | |
| 286 | 299 | } | |
| 287 | 300 | ||
| 288 | - | func runAuthLoginVerify(ctx context.Context, out io.Writer) error { | |
| 301 | + | func runAuthLoginVerify(ctx context.Context, out io.Writer, jsonOut bool) error { | |
| 289 | 302 | resolved, err := resolveRequiredRemote(tokenFlag) | |
| 290 | 303 | if err != nil { | |
| 291 | 304 | return err | |
| 292 | 305 | } | |
| 293 | 306 | if resolved.Token == "" { | |
| 294 | - | return fmt.Errorf("no token stored for remote '%s'; use: gordon auth login --token <token>", resolved.DisplayName()) | |
| 307 | + | return fmt.Errorf("no token stored for remote '%s'; use: gordon auth login --token-file <path>", resolved.DisplayName()) | |
| 295 | 308 | } | |
| 296 | 309 | ||
| 297 | 310 | client := remote.NewClient(resolved.URL, remoteClientOptions(resolved.Token, resolved.InsecureTLS)...) | |
| @@ -303,8 +316,10 @@ func runAuthLoginVerify(ctx context.Context, out io.Writer) error { | |||
| 303 | 316 | return fmt.Errorf("authentication failed") | |
| 304 | 317 | } | |
| 305 | 318 | ||
| 306 | - | _ = cliWriteLine(out, styles.RenderSuccess(fmt.Sprintf("Authenticated to '%s'", resolved.DisplayName()))) | |
| 307 | - | return nil | |
| 319 | + | if jsonOut { | |
| 320 | + | return writeJSON(out, map[string]any{"remote": resolved.DisplayName(), "authenticated": true}) | |
| 321 | + | } | |
| 322 | + | return cliWriteLine(out, styles.RenderSuccess(fmt.Sprintf("Authenticated to '%s'", resolved.DisplayName()))) | |
| 308 | 323 | } | |
| 309 | 324 | ||
| 310 | 325 | // newAuthShowTokenCmd creates the show-token command. | |
| @@ -206,31 +206,37 @@ available while Gordon is running.`, | |||||
| 206 | 206 | ||||
| 207 | // newAuthLoginCmd creates the login command for remote authentication. | 207 | // newAuthLoginCmd creates the login command for remote authentication. | ||
| 208 | func newAuthLoginCmd() *cobra.Command { | 208 | func newAuthLoginCmd() *cobra.Command { | ||
| 209 | - | var token string | 209 | + | var tokenFile string |
| 210 | + | var jsonOut bool | |||
| 210 | 211 | ||||
| 211 | cmd := &cobra.Command{ | 212 | cmd := &cobra.Command{ | ||
| 212 | Use: "login", | 213 | Use: "login", | ||
| 213 | Short: "Authenticate with a Gordon server", | 214 | Short: "Authenticate with a Gordon server", | ||
| 214 | Long: `Store or verify a token for a Gordon server remote. | 215 | Long: `Store or verify a token for a Gordon server remote. | ||
| 215 | 216 | ||||
| 216 | - | With --token: stores the token and verifies it. | 217 | + | With --token-file: reads, stores, and verifies a token without exposing it in argv. |
| 217 | - | Without --token: verifies the existing stored token still works. | 218 | + | Without --token-file: verifies the existing stored token still works. |
| 218 | 219 | ||||
| 219 | Generate a token on the server with: gordon auth token generate | 220 | Generate a token on the server with: gordon auth token generate | ||
| 220 | 221 | ||||
| 221 | Examples: | 222 | Examples: | ||
| 222 | - | gordon auth login --token <token> Store token for active remote | 223 | + | gordon auth login --token-file ./token Store token for active remote |
| 223 | - | gordon auth login --remote prod --token <tok> Store token for specific remote | 224 | + | gordon auth login --remote prod --token-file ./token |
| 224 | gordon auth login Verify existing token`, | 225 | gordon auth login Verify existing token`, | ||
| 225 | RunE: func(cmd *cobra.Command, args []string) error { | 226 | RunE: func(cmd *cobra.Command, args []string) error { | ||
| 226 | - | if token != "" { | 227 | + | if tokenFile != "" { |
| 227 | - | return runAuthLoginWithToken(cmd.Context(), token, cmd.OutOrStdout()) | 228 | + | token, err := readProtectedSecretFile(tokenFile) |
| 229 | + | if err != nil { | |||
| 230 | + | return fmt.Errorf("read token: %w", err) | |||
| 231 | + | } | |||
| 232 | + | return runAuthLoginWithToken(cmd.Context(), token, cmd.OutOrStdout(), jsonOut) | |||
| 228 | } | 233 | } | ||
| 229 | - | return runAuthLoginVerify(cmd.Context(), cmd.OutOrStdout()) | 234 | + | return runAuthLoginVerify(cmd.Context(), cmd.OutOrStdout(), jsonOut) |
| 230 | }, | 235 | }, | ||
| 231 | } | 236 | } | ||
| 232 | 237 | ||||
| 233 | - | cmd.Flags().StringVarP(&token, "token", "t", "", "Authentication token to store for the remote") | 238 | + | cmd.Flags().StringVar(&tokenFile, "token-file", "", "Read authentication token from a mode 0600 file") |
| 239 | + | cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON") | |||
| 234 | 240 | ||||
| 235 | return cmd | 241 | return cmd | ||
| 236 | } | 242 | } | ||
| @@ -247,7 +253,7 @@ func resolveRequiredRemote(flagToken string) (*remote.ResolvedRemote, error) { | |||||
| 247 | return resolved, nil | 253 | return resolved, nil | ||
| 248 | } | 254 | } | ||
| 249 | 255 | ||||
| 250 | - | func runAuthLoginWithToken(ctx context.Context, token string, out io.Writer) error { | 256 | + | func runAuthLoginWithToken(ctx context.Context, token string, out io.Writer, jsonOut bool) error { |
| 251 | token = strings.TrimSpace(token) | 257 | token = strings.TrimSpace(token) | ||
| 252 | if token == "" { | 258 | if token == "" { | ||
| 253 | return fmt.Errorf("token cannot be empty") | 259 | return fmt.Errorf("token cannot be empty") | ||
| @@ -264,19 +270,26 @@ func runAuthLoginWithToken(ctx context.Context, token string, out io.Writer) err | |||||
| 264 | defer cancel() | 270 | defer cancel() | ||
| 265 | verified := true | 271 | verified := true | ||
| 266 | if _, err := client.GetStatus(verifyCtx); err != nil { | 272 | if _, err := client.GetStatus(verifyCtx); err != nil { | ||
| 267 | - | _ = cliWriteLine(out, styles.RenderWarning(fmt.Sprintf("Token verification failed: %v", err))) | 273 | + | if !jsonOut { |
| 274 | + | _ = cliWriteLine(out, styles.RenderWarning(fmt.Sprintf("Token verification failed: %v", err))) | |||
| 275 | + | } | |||
| 268 | verified = false | 276 | verified = false | ||
| 269 | } | 277 | } | ||
| 270 | 278 | ||||
| 271 | // Store token — requires a named remote. | 279 | // Store token — requires a named remote. | ||
| 272 | if resolved.Name == "" { | 280 | if resolved.Name == "" { | ||
| 273 | - | _ = cliWriteLine(out, styles.RenderWarning("Ad-hoc URL: token verified but cannot be stored. Use 'gordon remotes add' to save this remote.")) | 281 | + | if jsonOut { |
| 274 | - | return nil | 282 | + | return writeJSON(out, map[string]any{"remote": resolved.DisplayName(), "stored": false, "verified": verified}) |
| 283 | + | } | |||
| 284 | + | return cliWriteLine(out, styles.RenderWarning("Ad-hoc URL: token verified but cannot be stored. Use 'gordon remotes add' to save this remote.")) | |||
| 275 | } | 285 | } | ||
| 276 | if err := remote.UpdateRemoteToken(resolved.Name, token); err != nil { | 286 | if err := remote.UpdateRemoteToken(resolved.Name, token); err != nil { | ||
| 277 | return fmt.Errorf("failed to save token: %w", err) | 287 | return fmt.Errorf("failed to save token: %w", err) | ||
| 278 | } | 288 | } | ||
| 279 | 289 | ||||
| 290 | + | if jsonOut { | |||
| 291 | + | return writeJSON(out, map[string]any{"remote": resolved.Name, "stored": true, "verified": verified}) | |||
| 292 | + | } | |||
| 280 | _ = cliWriteLine(out, "") | 293 | _ = cliWriteLine(out, "") | ||
| 281 | _ = cliWriteLine(out, styles.RenderSuccess(fmt.Sprintf("Token stored for remote '%s'", resolved.Name))) | 294 | _ = cliWriteLine(out, styles.RenderSuccess(fmt.Sprintf("Token stored for remote '%s'", resolved.Name))) | ||
| 282 | if verified { | 295 | if verified { | ||
| @@ -285,13 +298,13 @@ func runAuthLoginWithToken(ctx context.Context, token string, out io.Writer) err | |||||
| 285 | return nil | 298 | return nil | ||
| 286 | } | 299 | } | ||
| 287 | 300 | ||||
| 288 | - | func runAuthLoginVerify(ctx context.Context, out io.Writer) error { | 301 | + | func runAuthLoginVerify(ctx context.Context, out io.Writer, jsonOut bool) error { |
| 289 | resolved, err := resolveRequiredRemote(tokenFlag) | 302 | resolved, err := resolveRequiredRemote(tokenFlag) | ||
| 290 | if err != nil { | 303 | if err != nil { | ||
| 291 | return err | 304 | return err | ||
| 292 | } | 305 | } | ||
| 293 | if resolved.Token == "" { | 306 | if resolved.Token == "" { | ||
| 294 | - | return fmt.Errorf("no token stored for remote '%s'; use: gordon auth login --token <token>", resolved.DisplayName()) | 307 | + | return fmt.Errorf("no token stored for remote '%s'; use: gordon auth login --token-file <path>", resolved.DisplayName()) |
| 295 | } | 308 | } | ||
| 296 | 309 | ||||
| 297 | client := remote.NewClient(resolved.URL, remoteClientOptions(resolved.Token, resolved.InsecureTLS)...) | 310 | client := remote.NewClient(resolved.URL, remoteClientOptions(resolved.Token, resolved.InsecureTLS)...) | ||
| @@ -303,8 +316,10 @@ func runAuthLoginVerify(ctx context.Context, out io.Writer) error { | |||||
| 303 | return fmt.Errorf("authentication failed") | 316 | return fmt.Errorf("authentication failed") | ||
| 304 | } | 317 | } | ||
| 305 | 318 | ||||
| 306 | - | _ = cliWriteLine(out, styles.RenderSuccess(fmt.Sprintf("Authenticated to '%s'", resolved.DisplayName()))) | 319 | + | if jsonOut { |
| 307 | - | return nil | 320 | + | return writeJSON(out, map[string]any{"remote": resolved.DisplayName(), "authenticated": true}) |
| 321 | + | } | |||
| 322 | + | return cliWriteLine(out, styles.RenderSuccess(fmt.Sprintf("Authenticated to '%s'", resolved.DisplayName()))) | |||
| 308 | } | 323 | } | ||
| 309 | 324 | ||||
| 310 | // newAuthShowTokenCmd creates the show-token command. | 325 | // newAuthShowTokenCmd creates the show-token command. | ||
M internal/adapters/in/cli/auth_test.go +2 -1
| @@ -39,8 +39,9 @@ func TestRunAuthLoginWithToken_StoresToken(t *testing.T) { | |||
| 39 | 39 | t.Cleanup(func() { remoteFlag = origRemote }) | |
| 40 | 40 | ||
| 41 | 41 | var out bytes.Buffer | |
| 42 | - | err := runAuthLoginWithToken(context.Background(), "token123", &out) | |
| 42 | + | err := runAuthLoginWithToken(context.Background(), "token123", &out, true) | |
| 43 | 43 | assert.NoError(t, err) | |
| 44 | + | assert.JSONEq(t, `{"remote":"prod","stored":true,"verified":false}`, out.String()) | |
| 44 | 45 | ||
| 45 | 46 | loaded, err := remote.LoadRemotes("") | |
| 46 | 47 | assert.NoError(t, err) | |
| @@ -39,8 +39,9 @@ func TestRunAuthLoginWithToken_StoresToken(t *testing.T) { | |||||
| 39 | t.Cleanup(func() { remoteFlag = origRemote }) | 39 | t.Cleanup(func() { remoteFlag = origRemote }) | ||
| 40 | 40 | ||||
| 41 | var out bytes.Buffer | 41 | var out bytes.Buffer | ||
| 42 | - | err := runAuthLoginWithToken(context.Background(), "token123", &out) | 42 | + | err := runAuthLoginWithToken(context.Background(), "token123", &out, true) |
| 43 | assert.NoError(t, err) | 43 | assert.NoError(t, err) | ||
| 44 | + | assert.JSONEq(t, `{"remote":"prod","stored":true,"verified":false}`, out.String()) | |||
| 44 | 45 | ||||
| 45 | loaded, err := remote.LoadRemotes("") | 46 | loaded, err := remote.LoadRemotes("") | ||
| 46 | assert.NoError(t, err) | 47 | assert.NoError(t, err) | ||
M internal/adapters/in/cli/remotes.go +43 -20
| @@ -154,9 +154,10 @@ func remotesListJSON(out io.Writer, remotes map[string]remote.RemoteEntry, names | |||
| 154 | 154 | ||
| 155 | 155 | // newRemotesAddCmd creates the remotes add command. | |
| 156 | 156 | func newRemotesAddCmd() *cobra.Command { | |
| 157 | - | var token string | |
| 157 | + | var tokenFile string | |
| 158 | 158 | var tokenEnv string | |
| 159 | 159 | var insecureTLS bool | |
| 160 | + | var jsonOut bool | |
| 160 | 161 | ||
| 161 | 162 | cmd := &cobra.Command{ | |
| 162 | 163 | Use: "add <name> <url>", | |
| @@ -165,7 +166,7 @@ func newRemotesAddCmd() *cobra.Command { | |||
| 165 | 166 | ||
| 166 | 167 | Examples: | |
| 167 | 168 | gordon remotes add prod https://gordon.mydomain.com | |
| 168 | - | gordon remotes add prod https://gordon.mydomain.com --token eyJ... | |
| 169 | + | gordon remotes add prod https://gordon.mydomain.com --token-file ./token | |
| 169 | 170 | gordon remotes add staging https://staging.mydomain.com --token-env STAGING_TOKEN | |
| 170 | 171 | gordon remotes add dev https://dev.internal --insecure`, | |
| 171 | 172 | Args: cobra.ExactArgs(2), | |
| @@ -173,8 +174,14 @@ Examples: | |||
| 173 | 174 | name := args[0] | |
| 174 | 175 | url := normalizeURL(args[1]) | |
| 175 | 176 | ||
| 176 | - | // If token-env is provided, use that instead of token | |
| 177 | - | finalToken := token | |
| 177 | + | var finalToken string | |
| 178 | + | if tokenFile != "" { | |
| 179 | + | var err error | |
| 180 | + | finalToken, err = readProtectedSecretFile(tokenFile) | |
| 181 | + | if err != nil { | |
| 182 | + | return fmt.Errorf("read token: %w", err) | |
| 183 | + | } | |
| 184 | + | } | |
| 178 | 185 | if tokenEnv != "" { | |
| 179 | 186 | // Store token_env reference instead of actual token | |
| 180 | 187 | if err := addRemoteWithEnv(name, url, tokenEnv, insecureTLS); err != nil { | |
| @@ -186,19 +193,23 @@ Examples: | |||
| 186 | 193 | } | |
| 187 | 194 | } | |
| 188 | 195 | ||
| 189 | - | fmt.Println(styles.RenderSuccess(fmt.Sprintf("Remote added: %s -> %s", name, url))) | |
| 190 | - | ||
| 191 | - | if token == "" && tokenEnv == "" { | |
| 192 | - | fmt.Println(styles.Theme.Muted.Render("Tip: Add a token with --token or --token-env for authenticated access")) | |
| 196 | + | if jsonOut { | |
| 197 | + | return writeJSON(cmd.OutOrStdout(), map[string]any{"name": name, "url": url, "added": true}) | |
| 198 | + | } | |
| 199 | + | if err := cliWriteLine(cmd.OutOrStdout(), styles.RenderSuccess(fmt.Sprintf("Remote added: %s -> %s", name, url))); err != nil { | |
| 200 | + | return err | |
| 201 | + | } | |
| 202 | + | if tokenFile == "" && tokenEnv == "" { | |
| 203 | + | return cliWriteLine(cmd.OutOrStdout(), styles.Theme.Muted.Render("Tip: Add a token with --token-file or --token-env for authenticated access")) | |
| 193 | 204 | } | |
| 194 | - | ||
| 195 | 205 | return nil | |
| 196 | 206 | }, | |
| 197 | 207 | } | |
| 198 | 208 | ||
| 199 | - | cmd.Flags().StringVar(&token, "token", "", "Authentication token") | |
| 209 | + | cmd.Flags().StringVar(&tokenFile, "token-file", "", "Read authentication token from a mode 0600 file") | |
| 200 | 210 | cmd.Flags().StringVar(&tokenEnv, "token-env", "", "Environment variable containing token") | |
| 201 | 211 | cmd.Flags().BoolVar(&insecureTLS, "insecure", false, "Skip TLS certificate verification") | |
| 212 | + | cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON") | |
| 202 | 213 | ||
| 203 | 214 | return cmd | |
| 204 | 215 | } | |
| @@ -313,8 +324,10 @@ Examples: | |||
| 313 | 324 | ||
| 314 | 325 | // newRemotesSetTokenCmd creates the remotes set-token command. | |
| 315 | 326 | func newRemotesSetTokenCmd() *cobra.Command { | |
| 316 | - | return &cobra.Command{ | |
| 317 | - | Use: "set-token <name> <token>", | |
| 327 | + | var tokenFile string | |
| 328 | + | var jsonOut bool | |
| 329 | + | cmd := &cobra.Command{ | |
| 330 | + | Use: "set-token <name> --token-file <path>", | |
| 318 | 331 | Short: "Set the token for a remote", | |
| 319 | 332 | Long: `Set or update the authentication token for a saved remote. | |
| 320 | 333 | ||
| @@ -326,12 +339,14 @@ This is useful when: | |||
| 326 | 339 | For servers with password authentication, use 'gordon auth login' instead. | |
| 327 | 340 | ||
| 328 | 341 | Examples: | |
| 329 | - | gordon remotes set-token prod eyJhbGciOiJIUzI1NiIs... | |
| 330 | - | gordon remotes set-token staging $(cat token.txt)`, | |
| 331 | - | Args: cobra.ExactArgs(2), | |
| 342 | + | gordon remotes set-token prod --token-file ./token`, | |
| 343 | + | Args: cobra.ExactArgs(1), | |
| 332 | 344 | RunE: func(cmd *cobra.Command, args []string) error { | |
| 333 | 345 | name := args[0] | |
| 334 | - | token := args[1] | |
| 346 | + | token, err := readProtectedSecretFile(tokenFile) | |
| 347 | + | if err != nil { | |
| 348 | + | return fmt.Errorf("read token: %w", err) | |
| 349 | + | } | |
| 335 | 350 | ||
| 336 | 351 | // Verify remote exists | |
| 337 | 352 | remotes, _, err := remote.ListRemotes() | |
| @@ -340,18 +355,26 @@ Examples: | |||
| 340 | 355 | } | |
| 341 | 356 | ||
| 342 | 357 | if _, exists := remotes[name]; !exists { | |
| 343 | - | fmt.Println(styles.RenderError(fmt.Sprintf("Remote '%s' not found", name))) | |
| 344 | - | return nil | |
| 358 | + | if jsonOut { | |
| 359 | + | return writeJSON(cmd.OutOrStdout(), map[string]any{"name": name, "updated": false, "error": "remote not found"}) | |
| 360 | + | } | |
| 361 | + | return cliWriteLine(cmd.OutOrStdout(), styles.RenderError(fmt.Sprintf("Remote '%s' not found", name))) | |
| 345 | 362 | } | |
| 346 | 363 | ||
| 347 | 364 | if err := remote.UpdateRemoteToken(name, token); err != nil { | |
| 348 | 365 | return fmt.Errorf("failed to update token: %w", err) | |
| 349 | 366 | } | |
| 350 | 367 | ||
| 351 | - | fmt.Println(styles.RenderSuccess(fmt.Sprintf("Token updated for remote '%s'", name))) | |
| 352 | - | return nil | |
| 368 | + | if jsonOut { | |
| 369 | + | return writeJSON(cmd.OutOrStdout(), map[string]any{"name": name, "updated": true}) | |
| 370 | + | } | |
| 371 | + | return cliWriteLine(cmd.OutOrStdout(), styles.RenderSuccess(fmt.Sprintf("Token updated for remote '%s'", name))) | |
| 353 | 372 | }, | |
| 354 | 373 | } | |
| 374 | + | cmd.Flags().StringVar(&tokenFile, "token-file", "", "Read authentication token from a mode 0600 file") | |
| 375 | + | cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON") | |
| 376 | + | _ = cmd.MarkFlagRequired("token-file") | |
| 377 | + | return cmd | |
| 355 | 378 | } | |
| 356 | 379 | ||
| 357 | 380 | // normalizeURL ensures the URL has a protocol scheme, defaulting to https. | |
| @@ -154,9 +154,10 @@ func remotesListJSON(out io.Writer, remotes map[string]remote.RemoteEntry, names | |||||
| 154 | 154 | ||||
| 155 | // newRemotesAddCmd creates the remotes add command. | 155 | // newRemotesAddCmd creates the remotes add command. | ||
| 156 | func newRemotesAddCmd() *cobra.Command { | 156 | func newRemotesAddCmd() *cobra.Command { | ||
| 157 | - | var token string | 157 | + | var tokenFile string |
| 158 | var tokenEnv string | 158 | var tokenEnv string | ||
| 159 | var insecureTLS bool | 159 | var insecureTLS bool | ||
| 160 | + | var jsonOut bool | |||
| 160 | 161 | ||||
| 161 | cmd := &cobra.Command{ | 162 | cmd := &cobra.Command{ | ||
| 162 | Use: "add <name> <url>", | 163 | Use: "add <name> <url>", | ||
| @@ -165,7 +166,7 @@ func newRemotesAddCmd() *cobra.Command { | |||||
| 165 | 166 | ||||
| 166 | Examples: | 167 | Examples: | ||
| 167 | gordon remotes add prod https://gordon.mydomain.com | 168 | gordon remotes add prod https://gordon.mydomain.com | ||
| 168 | - | gordon remotes add prod https://gordon.mydomain.com --token eyJ... | 169 | + | gordon remotes add prod https://gordon.mydomain.com --token-file ./token |
| 169 | gordon remotes add staging https://staging.mydomain.com --token-env STAGING_TOKEN | 170 | gordon remotes add staging https://staging.mydomain.com --token-env STAGING_TOKEN | ||
| 170 | gordon remotes add dev https://dev.internal --insecure`, | 171 | gordon remotes add dev https://dev.internal --insecure`, | ||
| 171 | Args: cobra.ExactArgs(2), | 172 | Args: cobra.ExactArgs(2), | ||
| @@ -173,8 +174,14 @@ Examples: | |||||
| 173 | name := args[0] | 174 | name := args[0] | ||
| 174 | url := normalizeURL(args[1]) | 175 | url := normalizeURL(args[1]) | ||
| 175 | 176 | ||||
| 176 | - | // If token-env is provided, use that instead of token | 177 | + | var finalToken string |
| 177 | - | finalToken := token | 178 | + | if tokenFile != "" { |
| 179 | + | var err error | |||
| 180 | + | finalToken, err = readProtectedSecretFile(tokenFile) | |||
| 181 | + | if err != nil { | |||
| 182 | + | return fmt.Errorf("read token: %w", err) | |||
| 183 | + | } | |||
| 184 | + | } | |||
| 178 | if tokenEnv != "" { | 185 | if tokenEnv != "" { | ||
| 179 | // Store token_env reference instead of actual token | 186 | // Store token_env reference instead of actual token | ||
| 180 | if err := addRemoteWithEnv(name, url, tokenEnv, insecureTLS); err != nil { | 187 | if err := addRemoteWithEnv(name, url, tokenEnv, insecureTLS); err != nil { | ||
| @@ -186,19 +193,23 @@ Examples: | |||||
| 186 | } | 193 | } | ||
| 187 | } | 194 | } | ||
| 188 | 195 | ||||
| 189 | - | fmt.Println(styles.RenderSuccess(fmt.Sprintf("Remote added: %s -> %s", name, url))) | 196 | + | if jsonOut { |
| 190 | - | 197 | + | return writeJSON(cmd.OutOrStdout(), map[string]any{"name": name, "url": url, "added": true}) | |
| 191 | - | if token == "" && tokenEnv == "" { | 198 | + | } |
| 192 | - | fmt.Println(styles.Theme.Muted.Render("Tip: Add a token with --token or --token-env for authenticated access")) | 199 | + | if err := cliWriteLine(cmd.OutOrStdout(), styles.RenderSuccess(fmt.Sprintf("Remote added: %s -> %s", name, url))); err != nil { |
| 200 | + | return err | |||
| 201 | + | } | |||
| 202 | + | if tokenFile == "" && tokenEnv == "" { | |||
| 203 | + | return cliWriteLine(cmd.OutOrStdout(), styles.Theme.Muted.Render("Tip: Add a token with --token-file or --token-env for authenticated access")) | |||
| 193 | } | 204 | } | ||
| 194 | - | ||||
| 195 | return nil | 205 | return nil | ||
| 196 | }, | 206 | }, | ||
| 197 | } | 207 | } | ||
| 198 | 208 | ||||
| 199 | - | cmd.Flags().StringVar(&token, "token", "", "Authentication token") | 209 | + | cmd.Flags().StringVar(&tokenFile, "token-file", "", "Read authentication token from a mode 0600 file") |
| 200 | cmd.Flags().StringVar(&tokenEnv, "token-env", "", "Environment variable containing token") | 210 | cmd.Flags().StringVar(&tokenEnv, "token-env", "", "Environment variable containing token") | ||
| 201 | cmd.Flags().BoolVar(&insecureTLS, "insecure", false, "Skip TLS certificate verification") | 211 | cmd.Flags().BoolVar(&insecureTLS, "insecure", false, "Skip TLS certificate verification") | ||
| 212 | + | cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON") | |||
| 202 | 213 | ||||
| 203 | return cmd | 214 | return cmd | ||
| 204 | } | 215 | } | ||
| @@ -313,8 +324,10 @@ Examples: | |||||
| 313 | 324 | ||||
| 314 | // newRemotesSetTokenCmd creates the remotes set-token command. | 325 | // newRemotesSetTokenCmd creates the remotes set-token command. | ||
| 315 | func newRemotesSetTokenCmd() *cobra.Command { | 326 | func newRemotesSetTokenCmd() *cobra.Command { | ||
| 316 | - | return &cobra.Command{ | 327 | + | var tokenFile string |
| 317 | - | Use: "set-token <name> <token>", | 328 | + | var jsonOut bool |
| 329 | + | cmd := &cobra.Command{ | |||
| 330 | + | Use: "set-token <name> --token-file <path>", | |||
| 318 | Short: "Set the token for a remote", | 331 | Short: "Set the token for a remote", | ||
| 319 | Long: `Set or update the authentication token for a saved remote. | 332 | Long: `Set or update the authentication token for a saved remote. | ||
| 320 | 333 | ||||
| @@ -326,12 +339,14 @@ This is useful when: | |||||
| 326 | For servers with password authentication, use 'gordon auth login' instead. | 339 | For servers with password authentication, use 'gordon auth login' instead. | ||
| 327 | 340 | ||||
| 328 | Examples: | 341 | Examples: | ||
| 329 | - | gordon remotes set-token prod eyJhbGciOiJIUzI1NiIs... | 342 | + | gordon remotes set-token prod --token-file ./token`, |
| 330 | - | gordon remotes set-token staging $(cat token.txt)`, | 343 | + | Args: cobra.ExactArgs(1), |
| 331 | - | Args: cobra.ExactArgs(2), | |||
| 332 | RunE: func(cmd *cobra.Command, args []string) error { | 344 | RunE: func(cmd *cobra.Command, args []string) error { | ||
| 333 | name := args[0] | 345 | name := args[0] | ||
| 334 | - | token := args[1] | 346 | + | token, err := readProtectedSecretFile(tokenFile) |
| 347 | + | if err != nil { | |||
| 348 | + | return fmt.Errorf("read token: %w", err) | |||
| 349 | + | } | |||
| 335 | 350 | ||||
| 336 | // Verify remote exists | 351 | // Verify remote exists | ||
| 337 | remotes, _, err := remote.ListRemotes() | 352 | remotes, _, err := remote.ListRemotes() | ||
| @@ -340,18 +355,26 @@ Examples: | |||||
| 340 | } | 355 | } | ||
| 341 | 356 | ||||
| 342 | if _, exists := remotes[name]; !exists { | 357 | if _, exists := remotes[name]; !exists { | ||
| 343 | - | fmt.Println(styles.RenderError(fmt.Sprintf("Remote '%s' not found", name))) | 358 | + | if jsonOut { |
| 344 | - | return nil | 359 | + | return writeJSON(cmd.OutOrStdout(), map[string]any{"name": name, "updated": false, "error": "remote not found"}) |
| 360 | + | } | |||
| 361 | + | return cliWriteLine(cmd.OutOrStdout(), styles.RenderError(fmt.Sprintf("Remote '%s' not found", name))) | |||
| 345 | } | 362 | } | ||
| 346 | 363 | ||||
| 347 | if err := remote.UpdateRemoteToken(name, token); err != nil { | 364 | if err := remote.UpdateRemoteToken(name, token); err != nil { | ||
| 348 | return fmt.Errorf("failed to update token: %w", err) | 365 | return fmt.Errorf("failed to update token: %w", err) | ||
| 349 | } | 366 | } | ||
| 350 | 367 | ||||
| 351 | - | fmt.Println(styles.RenderSuccess(fmt.Sprintf("Token updated for remote '%s'", name))) | 368 | + | if jsonOut { |
| 352 | - | return nil | 369 | + | return writeJSON(cmd.OutOrStdout(), map[string]any{"name": name, "updated": true}) |
| 370 | + | } | |||
| 371 | + | return cliWriteLine(cmd.OutOrStdout(), styles.RenderSuccess(fmt.Sprintf("Token updated for remote '%s'", name))) | |||
| 353 | }, | 372 | }, | ||
| 354 | } | 373 | } | ||
| 374 | + | cmd.Flags().StringVar(&tokenFile, "token-file", "", "Read authentication token from a mode 0600 file") | |||
| 375 | + | cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON") | |||
| 376 | + | _ = cmd.MarkFlagRequired("token-file") | |||
| 377 | + | return cmd | |||
| 355 | } | 378 | } | ||
| 356 | 379 | ||||
| 357 | // normalizeURL ensures the URL has a protocol scheme, defaulting to https. | 380 | // normalizeURL ensures the URL has a protocol scheme, defaulting to https. | ||
M internal/adapters/in/cli/root.go +13 -2
| @@ -38,7 +38,18 @@ const ( | |||
| 38 | 38 | // NewRootCmd creates the root command for Gordon CLI. | |
| 39 | 39 | func NewRootCmd() *cobra.Command { | |
| 40 | 40 | rootCmd := &cobra.Command{ | |
| 41 | - | Use: "gordon", | |
| 41 | + | Use: "gordon", | |
| 42 | + | PersistentPreRunE: func(cmd *cobra.Command, args []string) error { | |
| 43 | + | if tokenFlag == "" { | |
| 44 | + | return nil | |
| 45 | + | } | |
| 46 | + | token, err := readProtectedSecretFile(tokenFlag) | |
| 47 | + | if err != nil { | |
| 48 | + | return fmt.Errorf("read remote token: %w", err) | |
| 49 | + | } | |
| 50 | + | tokenFlag = token | |
| 51 | + | return nil | |
| 52 | + | }, | |
| 42 | 53 | Short: "Gordon - A lightweight container deployment platform", | |
| 43 | 54 | Long: `Gordon is a self-contained container deployment platform that combines | |
| 44 | 55 | a Docker registry with automatic container deployment capabilities. | |
| @@ -61,7 +72,7 @@ Commands are organized by where they run: | |||
| 61 | 72 | ||
| 62 | 73 | // Add persistent flags for remote targeting | |
| 63 | 74 | rootCmd.PersistentFlags().StringVarP(&remoteFlag, "remote", "r", "", "Remote name or URL (e.g., prod, https://gordon.mydomain.com)") | |
| 64 | - | rootCmd.PersistentFlags().StringVar(&tokenFlag, "token", "", "Authentication token for remote") | |
| 75 | + | rootCmd.PersistentFlags().StringVar(&tokenFlag, "token-file", "", "Read remote authentication token from a mode 0600 file") | |
| 65 | 76 | rootCmd.PersistentFlags().BoolVar(&insecureTLSFlag, "insecure", false, "Skip TLS certificate verification for remote HTTPS endpoints") | |
| 66 | 77 | ||
| 67 | 78 | // Server-only commands (must run on the Gordon host) | |
| @@ -38,7 +38,18 @@ const ( | |||||
| 38 | // NewRootCmd creates the root command for Gordon CLI. | 38 | // NewRootCmd creates the root command for Gordon CLI. | ||
| 39 | func NewRootCmd() *cobra.Command { | 39 | func NewRootCmd() *cobra.Command { | ||
| 40 | rootCmd := &cobra.Command{ | 40 | rootCmd := &cobra.Command{ | ||
| 41 | - | Use: "gordon", | 41 | + | Use: "gordon", |
| 42 | + | PersistentPreRunE: func(cmd *cobra.Command, args []string) error { | |||
| 43 | + | if tokenFlag == "" { | |||
| 44 | + | return nil | |||
| 45 | + | } | |||
| 46 | + | token, err := readProtectedSecretFile(tokenFlag) | |||
| 47 | + | if err != nil { | |||
| 48 | + | return fmt.Errorf("read remote token: %w", err) | |||
| 49 | + | } | |||
| 50 | + | tokenFlag = token | |||
| 51 | + | return nil | |||
| 52 | + | }, | |||
| 42 | Short: "Gordon - A lightweight container deployment platform", | 53 | Short: "Gordon - A lightweight container deployment platform", | ||
| 43 | Long: `Gordon is a self-contained container deployment platform that combines | 54 | Long: `Gordon is a self-contained container deployment platform that combines | ||
| 44 | a Docker registry with automatic container deployment capabilities. | 55 | a Docker registry with automatic container deployment capabilities. | ||
| @@ -61,7 +72,7 @@ Commands are organized by where they run: | |||||
| 61 | 72 | ||||
| 62 | // Add persistent flags for remote targeting | 73 | // Add persistent flags for remote targeting | ||
| 63 | rootCmd.PersistentFlags().StringVarP(&remoteFlag, "remote", "r", "", "Remote name or URL (e.g., prod, https://gordon.mydomain.com)") | 74 | rootCmd.PersistentFlags().StringVarP(&remoteFlag, "remote", "r", "", "Remote name or URL (e.g., prod, https://gordon.mydomain.com)") | ||
| 64 | - | rootCmd.PersistentFlags().StringVar(&tokenFlag, "token", "", "Authentication token for remote") | 75 | + | rootCmd.PersistentFlags().StringVar(&tokenFlag, "token-file", "", "Read remote authentication token from a mode 0600 file") |
| 65 | rootCmd.PersistentFlags().BoolVar(&insecureTLSFlag, "insecure", false, "Skip TLS certificate verification for remote HTTPS endpoints") | 76 | rootCmd.PersistentFlags().BoolVar(&insecureTLSFlag, "insecure", false, "Skip TLS certificate verification for remote HTTPS endpoints") | ||
| 66 | 77 | ||||
| 67 | // Server-only commands (must run on the Gordon host) | 78 | // Server-only commands (must run on the Gordon host) | ||
A internal/adapters/in/cli/secret_input.go +47 -0
| @@ -0,0 +1,47 @@ | |||
| 1 | + | package cli | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "fmt" | |
| 5 | + | "io" | |
| 6 | + | "os" | |
| 7 | + | "strings" | |
| 8 | + | ||
| 9 | + | "golang.org/x/sys/unix" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | func readProtectedSecretFile(path string) (string, error) { | |
| 13 | + | fd, err := unix.Open(path, unix.O_RDONLY|unix.O_CLOEXEC|unix.O_NOFOLLOW, 0) | |
| 14 | + | if err != nil { | |
| 15 | + | return "", fmt.Errorf("open secret file %s: %w", path, err) | |
| 16 | + | } | |
| 17 | + | if fd < 0 { | |
| 18 | + | return "", fmt.Errorf("open secret file %s: invalid file descriptor %d", path, fd) | |
| 19 | + | } | |
| 20 | + | //nolint:gosec // G115: fd is validated non-negative and originates from unix.Open. | |
| 21 | + | file := os.NewFile(uintptr(fd), path) | |
| 22 | + | if file == nil { | |
| 23 | + | _ = unix.Close(fd) | |
| 24 | + | return "", fmt.Errorf("open secret file %s: invalid file descriptor", path) | |
| 25 | + | } | |
| 26 | + | defer file.Close() | |
| 27 | + | ||
| 28 | + | info, err := file.Stat() | |
| 29 | + | if err != nil { | |
| 30 | + | return "", fmt.Errorf("stat secret file %s: %w", path, err) | |
| 31 | + | } | |
| 32 | + | if !info.Mode().IsRegular() { | |
| 33 | + | return "", fmt.Errorf("%s is not a regular file", path) | |
| 34 | + | } | |
| 35 | + | if info.Mode().Perm()&0o077 != 0 { | |
| 36 | + | return "", fmt.Errorf("%s must not be accessible by group or others (use chmod 600)", path) | |
| 37 | + | } | |
| 38 | + | data, err := io.ReadAll(file) | |
| 39 | + | if err != nil { | |
| 40 | + | return "", fmt.Errorf("read secret file %s: %w", path, err) | |
| 41 | + | } | |
| 42 | + | value := strings.TrimSpace(string(data)) | |
| 43 | + | if value == "" { | |
| 44 | + | return "", fmt.Errorf("%s is empty", path) | |
| 45 | + | } | |
| 46 | + | return value, nil | |
| 47 | + | } | |
| @@ -0,0 +1,47 @@ | |||||
| 1 | + | package cli | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "fmt" | |||
| 5 | + | "io" | |||
| 6 | + | "os" | |||
| 7 | + | "strings" | |||
| 8 | + | ||||
| 9 | + | "golang.org/x/sys/unix" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | func readProtectedSecretFile(path string) (string, error) { | |||
| 13 | + | fd, err := unix.Open(path, unix.O_RDONLY|unix.O_CLOEXEC|unix.O_NOFOLLOW, 0) | |||
| 14 | + | if err != nil { | |||
| 15 | + | return "", fmt.Errorf("open secret file %s: %w", path, err) | |||
| 16 | + | } | |||
| 17 | + | if fd < 0 { | |||
| 18 | + | return "", fmt.Errorf("open secret file %s: invalid file descriptor %d", path, fd) | |||
| 19 | + | } | |||
| 20 | + | //nolint:gosec // G115: fd is validated non-negative and originates from unix.Open. | |||
| 21 | + | file := os.NewFile(uintptr(fd), path) | |||
| 22 | + | if file == nil { | |||
| 23 | + | _ = unix.Close(fd) | |||
| 24 | + | return "", fmt.Errorf("open secret file %s: invalid file descriptor", path) | |||
| 25 | + | } | |||
| 26 | + | defer file.Close() | |||
| 27 | + | ||||
| 28 | + | info, err := file.Stat() | |||
| 29 | + | if err != nil { | |||
| 30 | + | return "", fmt.Errorf("stat secret file %s: %w", path, err) | |||
| 31 | + | } | |||
| 32 | + | if !info.Mode().IsRegular() { | |||
| 33 | + | return "", fmt.Errorf("%s is not a regular file", path) | |||
| 34 | + | } | |||
| 35 | + | if info.Mode().Perm()&0o077 != 0 { | |||
| 36 | + | return "", fmt.Errorf("%s must not be accessible by group or others (use chmod 600)", path) | |||
| 37 | + | } | |||
| 38 | + | data, err := io.ReadAll(file) | |||
| 39 | + | if err != nil { | |||
| 40 | + | return "", fmt.Errorf("read secret file %s: %w", path, err) | |||
| 41 | + | } | |||
| 42 | + | value := strings.TrimSpace(string(data)) | |||
| 43 | + | if value == "" { | |||
| 44 | + | return "", fmt.Errorf("%s is empty", path) | |||
| 45 | + | } | |||
| 46 | + | return value, nil | |||
| 47 | + | } | |||
M internal/adapters/in/cli/secrets.go +22 -16
| @@ -222,32 +222,33 @@ func getKeyPrefix(isLastAttachment, isLastKey bool) string { | |||
| 222 | 222 | ||
| 223 | 223 | // newSecretsSetCmd creates the secrets set command. | |
| 224 | 224 | func newSecretsSetCmd() *cobra.Command { | |
| 225 | - | var attachment string | |
| 225 | + | var attachment, fromFile string | |
| 226 | + | var jsonOut bool | |
| 226 | 227 | ||
| 227 | 228 | cmd := &cobra.Command{ | |
| 228 | - | Use: "set <domain> <KEY=value>...", | |
| 229 | + | Use: "set <domain> --from-file <path>", | |
| 229 | 230 | Short: "Set secrets for a domain or attachment", | |
| 230 | 231 | Long: `Set one or more secrets for a domain or an attachment container. | |
| 231 | 232 | ||
| 232 | - | Secrets are specified as KEY=value pairs. Multiple secrets can be set at once. | |
| 233 | + | Secrets are read from a mode 0600 file containing one KEY=value pair per line. | |
| 233 | 234 | ||
| 234 | 235 | Use --attachment to target an attachment service (e.g., postgres, redis) instead | |
| 235 | 236 | of the main domain container. | |
| 236 | 237 | ||
| 237 | 238 | Examples: | |
| 238 | - | gordon secrets set app.mydomain.com DATABASE_URL=postgres://localhost/db | |
| 239 | - | gordon secrets set app.mydomain.com API_KEY=secret123 DEBUG=false | |
| 240 | - | gordon secrets set app.mydomain.com --attachment postgres POSTGRES_PASSWORD=secret | |
| 241 | - | gordon secrets set app.mydomain.com --attachment redis REDIS_PASSWORD=secret`, | |
| 242 | - | Args: cobra.MinimumNArgs(2), | |
| 239 | + | gordon secrets set app.mydomain.com --from-file ./app.env | |
| 240 | + | gordon secrets set app.mydomain.com --attachment postgres --from-file ./postgres.env`, | |
| 241 | + | Args: cobra.ExactArgs(1), | |
| 243 | 242 | RunE: func(cmd *cobra.Command, args []string) error { | |
| 244 | 243 | ctx := cmd.Context() | |
| 245 | 244 | secretDomain := args[0] | |
| 246 | - | pairs := args[1:] | |
| 245 | + | content, err := readProtectedSecretFile(fromFile) | |
| 246 | + | if err != nil { | |
| 247 | + | return fmt.Errorf("read secrets: %w", err) | |
| 248 | + | } | |
| 247 | 249 | ||
| 248 | - | // Parse KEY=value pairs | |
| 249 | 250 | secrets := make(map[string]string) | |
| 250 | - | for _, pair := range pairs { | |
| 251 | + | for _, pair := range strings.Split(content, "\n") { | |
| 251 | 252 | parts := strings.SplitN(pair, "=", 2) | |
| 252 | 253 | if len(parts) != 2 { | |
| 253 | 254 | return fmt.Errorf("invalid format: %s (expected KEY=value)", pair) | |
| @@ -275,19 +276,24 @@ Examples: | |||
| 275 | 276 | target = fmt.Sprintf("%s [%s]", secretDomain, attachment) | |
| 276 | 277 | } | |
| 277 | 278 | ||
| 279 | + | if jsonOut { | |
| 280 | + | return writeJSON(cmd.OutOrStdout(), map[string]any{ | |
| 281 | + | "domain": secretDomain, "attachment": attachment, "count": len(secrets), "updated": true, | |
| 282 | + | }) | |
| 283 | + | } | |
| 278 | 284 | if len(secrets) == 1 { | |
| 279 | 285 | for key := range secrets { | |
| 280 | - | fmt.Println(styles.RenderSuccess(fmt.Sprintf("Secret set: %s on %s", key, target))) | |
| 286 | + | return cliWriteLine(cmd.OutOrStdout(), styles.RenderSuccess(fmt.Sprintf("Secret set: %s on %s", key, target))) | |
| 281 | 287 | } | |
| 282 | - | } else { | |
| 283 | - | fmt.Println(styles.RenderSuccess(fmt.Sprintf("Set %d secrets for %s", len(secrets), target))) | |
| 284 | 288 | } | |
| 285 | - | ||
| 286 | - | return nil | |
| 289 | + | return cliWriteLine(cmd.OutOrStdout(), styles.RenderSuccess(fmt.Sprintf("Set %d secrets for %s", len(secrets), target))) | |
| 287 | 290 | }, | |
| 288 | 291 | } | |
| 289 | 292 | ||
| 290 | 293 | cmd.Flags().StringVarP(&attachment, "attachment", "a", "", "Target an attachment service (e.g., postgres, redis)") | |
| 294 | + | cmd.Flags().StringVar(&fromFile, "from-file", "", "Read KEY=value lines from a mode 0600 file") | |
| 295 | + | cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON") | |
| 296 | + | _ = cmd.MarkFlagRequired("from-file") | |
| 291 | 297 | ||
| 292 | 298 | return cmd | |
| 293 | 299 | } | |
| @@ -222,32 +222,33 @@ func getKeyPrefix(isLastAttachment, isLastKey bool) string { | |||||
| 222 | 222 | ||||
| 223 | // newSecretsSetCmd creates the secrets set command. | 223 | // newSecretsSetCmd creates the secrets set command. | ||
| 224 | func newSecretsSetCmd() *cobra.Command { | 224 | func newSecretsSetCmd() *cobra.Command { | ||
| 225 | - | var attachment string | 225 | + | var attachment, fromFile string |
| 226 | + | var jsonOut bool | |||
| 226 | 227 | ||||
| 227 | cmd := &cobra.Command{ | 228 | cmd := &cobra.Command{ | ||
| 228 | - | Use: "set <domain> <KEY=value>...", | 229 | + | Use: "set <domain> --from-file <path>", |
| 229 | Short: "Set secrets for a domain or attachment", | 230 | Short: "Set secrets for a domain or attachment", | ||
| 230 | Long: `Set one or more secrets for a domain or an attachment container. | 231 | Long: `Set one or more secrets for a domain or an attachment container. | ||
| 231 | 232 | ||||
| 232 | - | Secrets are specified as KEY=value pairs. Multiple secrets can be set at once. | 233 | + | Secrets are read from a mode 0600 file containing one KEY=value pair per line. |
| 233 | 234 | ||||
| 234 | Use --attachment to target an attachment service (e.g., postgres, redis) instead | 235 | Use --attachment to target an attachment service (e.g., postgres, redis) instead | ||
| 235 | of the main domain container. | 236 | of the main domain container. | ||
| 236 | 237 | ||||
| 237 | Examples: | 238 | Examples: | ||
| 238 | - | gordon secrets set app.mydomain.com DATABASE_URL=postgres://localhost/db | 239 | + | gordon secrets set app.mydomain.com --from-file ./app.env |
| 239 | - | gordon secrets set app.mydomain.com API_KEY=secret123 DEBUG=false | 240 | + | gordon secrets set app.mydomain.com --attachment postgres --from-file ./postgres.env`, |
| 240 | - | gordon secrets set app.mydomain.com --attachment postgres POSTGRES_PASSWORD=secret | 241 | + | Args: cobra.ExactArgs(1), |
| 241 | - | gordon secrets set app.mydomain.com --attachment redis REDIS_PASSWORD=secret`, | |||
| 242 | - | Args: cobra.MinimumNArgs(2), | |||
| 243 | RunE: func(cmd *cobra.Command, args []string) error { | 242 | RunE: func(cmd *cobra.Command, args []string) error { | ||
| 244 | ctx := cmd.Context() | 243 | ctx := cmd.Context() | ||
| 245 | secretDomain := args[0] | 244 | secretDomain := args[0] | ||
| 246 | - | pairs := args[1:] | 245 | + | content, err := readProtectedSecretFile(fromFile) |
| 246 | + | if err != nil { | |||
| 247 | + | return fmt.Errorf("read secrets: %w", err) | |||
| 248 | + | } | |||
| 247 | 249 | ||||
| 248 | - | // Parse KEY=value pairs | |||
| 249 | secrets := make(map[string]string) | 250 | secrets := make(map[string]string) | ||
| 250 | - | for _, pair := range pairs { | 251 | + | for _, pair := range strings.Split(content, "\n") { |
| 251 | parts := strings.SplitN(pair, "=", 2) | 252 | parts := strings.SplitN(pair, "=", 2) | ||
| 252 | if len(parts) != 2 { | 253 | if len(parts) != 2 { | ||
| 253 | return fmt.Errorf("invalid format: %s (expected KEY=value)", pair) | 254 | return fmt.Errorf("invalid format: %s (expected KEY=value)", pair) | ||
| @@ -275,19 +276,24 @@ Examples: | |||||
| 275 | target = fmt.Sprintf("%s [%s]", secretDomain, attachment) | 276 | target = fmt.Sprintf("%s [%s]", secretDomain, attachment) | ||
| 276 | } | 277 | } | ||
| 277 | 278 | ||||
| 279 | + | if jsonOut { | |||
| 280 | + | return writeJSON(cmd.OutOrStdout(), map[string]any{ | |||
| 281 | + | "domain": secretDomain, "attachment": attachment, "count": len(secrets), "updated": true, | |||
| 282 | + | }) | |||
| 283 | + | } | |||
| 278 | if len(secrets) == 1 { | 284 | if len(secrets) == 1 { | ||
| 279 | for key := range secrets { | 285 | for key := range secrets { | ||
| 280 | - | fmt.Println(styles.RenderSuccess(fmt.Sprintf("Secret set: %s on %s", key, target))) | 286 | + | return cliWriteLine(cmd.OutOrStdout(), styles.RenderSuccess(fmt.Sprintf("Secret set: %s on %s", key, target))) |
| 281 | } | 287 | } | ||
| 282 | - | } else { | |||
| 283 | - | fmt.Println(styles.RenderSuccess(fmt.Sprintf("Set %d secrets for %s", len(secrets), target))) | |||
| 284 | } | 288 | } | ||
| 285 | - | 289 | + | return cliWriteLine(cmd.OutOrStdout(), styles.RenderSuccess(fmt.Sprintf("Set %d secrets for %s", len(secrets), target))) | |
| 286 | - | return nil | |||
| 287 | }, | 290 | }, | ||
| 288 | } | 291 | } | ||
| 289 | 292 | ||||
| 290 | cmd.Flags().StringVarP(&attachment, "attachment", "a", "", "Target an attachment service (e.g., postgres, redis)") | 293 | cmd.Flags().StringVarP(&attachment, "attachment", "a", "", "Target an attachment service (e.g., postgres, redis)") | ||
| 294 | + | cmd.Flags().StringVar(&fromFile, "from-file", "", "Read KEY=value lines from a mode 0600 file") | |||
| 295 | + | cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON") | |||
| 296 | + | _ = cmd.MarkFlagRequired("from-file") | |||
| 291 | 297 | ||||
| 292 | return cmd | 298 | return cmd | ||
| 293 | } | 299 | } | ||
M internal/adapters/in/http/registry/handler.go +5 -1
| @@ -370,6 +370,10 @@ func (h *Handler) handlePutManifest(w http.ResponseWriter, r *http.Request) { | |||
| 370 | 370 | digest, err := h.registrySvc.PutManifest(ctx, manifestObj) | |
| 371 | 371 | if err != nil { | |
| 372 | 372 | log.Error().Err(err).Str("name", name).Str("reference", reference).Msg("failed to store manifest") | |
| 373 | + | if errors.Is(err, domain.ErrDigestMismatch) || errors.Is(err, domain.ErrInvalidDigest) { | |
| 374 | + | h.sendRegistryError(w, http.StatusBadRequest, "DIGEST_INVALID", "manifest digest does not match content") | |
| 375 | + | return | |
| 376 | + | } | |
| 373 | 377 | h.sendRegistryError(w, http.StatusInternalServerError, "MANIFEST_INVALID", "failed to store manifest") | |
| 374 | 378 | return | |
| 375 | 379 | } | |
| @@ -387,7 +391,7 @@ func (h *Handler) handleGetBlob(w http.ResponseWriter, r *http.Request) { | |||
| 387 | 391 | ||
| 388 | 392 | log.Debug().Str("name", name).Str("digest", digest).Msg("GET blob") | |
| 389 | 393 | ||
| 390 | - | path, err := h.registrySvc.GetBlobPath(ctx, digest) | |
| 394 | + | path, err := h.registrySvc.GetBlobPath(ctx, name, digest) | |
| 391 | 395 | if err != nil { | |
| 392 | 396 | log.Warn().Err(err).Str("name", name).Str("digest", digest).Msg("blob not found") | |
| 393 | 397 | h.sendRegistryError(w, http.StatusNotFound, "BLOB_UNKNOWN", "blob not found") | |
| @@ -370,6 +370,10 @@ func (h *Handler) handlePutManifest(w http.ResponseWriter, r *http.Request) { | |||||
| 370 | digest, err := h.registrySvc.PutManifest(ctx, manifestObj) | 370 | digest, err := h.registrySvc.PutManifest(ctx, manifestObj) | ||
| 371 | if err != nil { | 371 | if err != nil { | ||
| 372 | log.Error().Err(err).Str("name", name).Str("reference", reference).Msg("failed to store manifest") | 372 | log.Error().Err(err).Str("name", name).Str("reference", reference).Msg("failed to store manifest") | ||
| 373 | + | if errors.Is(err, domain.ErrDigestMismatch) || errors.Is(err, domain.ErrInvalidDigest) { | |||
| 374 | + | h.sendRegistryError(w, http.StatusBadRequest, "DIGEST_INVALID", "manifest digest does not match content") | |||
| 375 | + | return | |||
| 376 | + | } | |||
| 373 | h.sendRegistryError(w, http.StatusInternalServerError, "MANIFEST_INVALID", "failed to store manifest") | 377 | h.sendRegistryError(w, http.StatusInternalServerError, "MANIFEST_INVALID", "failed to store manifest") | ||
| 374 | return | 378 | return | ||
| 375 | } | 379 | } | ||
| @@ -387,7 +391,7 @@ func (h *Handler) handleGetBlob(w http.ResponseWriter, r *http.Request) { | |||||
| 387 | 391 | ||||
| 388 | log.Debug().Str("name", name).Str("digest", digest).Msg("GET blob") | 392 | log.Debug().Str("name", name).Str("digest", digest).Msg("GET blob") | ||
| 389 | 393 | ||||
| 390 | - | path, err := h.registrySvc.GetBlobPath(ctx, digest) | 394 | + | path, err := h.registrySvc.GetBlobPath(ctx, name, digest) |
| 391 | if err != nil { | 395 | if err != nil { | ||
| 392 | log.Warn().Err(err).Str("name", name).Str("digest", digest).Msg("blob not found") | 396 | log.Warn().Err(err).Str("name", name).Str("digest", digest).Msg("blob not found") | ||
| 393 | h.sendRegistryError(w, http.StatusNotFound, "BLOB_UNKNOWN", "blob not found") | 397 | h.sendRegistryError(w, http.StatusNotFound, "BLOB_UNKNOWN", "blob not found") | ||
M internal/adapters/in/http/registry/handler_test.go +18 -13
| @@ -13,6 +13,7 @@ import ( | |||
| 13 | 13 | "github.com/bnema/zerowrap" | |
| 14 | 14 | "github.com/stretchr/testify/assert" | |
| 15 | 15 | "github.com/stretchr/testify/mock" | |
| 16 | + | "github.com/stretchr/testify/require" | |
| 16 | 17 | ||
| 17 | 18 | inmocks "github.com/bnema/gordon/internal/boundaries/in/mocks" | |
| 18 | 19 | "github.com/bnema/gordon/internal/domain" | |
| @@ -204,6 +205,8 @@ func TestHandler_GetBlob_Success(t *testing.T) { | |||
| 204 | 205 | registrySvc := inmocks.NewMockRegistryService(t) | |
| 205 | 206 | ||
| 206 | 207 | handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize) | |
| 208 | + | server := httptest.NewServer(handler) | |
| 209 | + | defer server.Close() | |
| 207 | 210 | ||
| 208 | 211 | // Create a temp file for http.ServeFile | |
| 209 | 212 | tmpFile, err := os.CreateTemp("", "blob-*") | |
| @@ -214,30 +217,32 @@ func TestHandler_GetBlob_Success(t *testing.T) { | |||
| 214 | 217 | _, err = tmpFile.Write(blobContent) | |
| 215 | 218 | assert.NoError(t, err) | |
| 216 | 219 | ||
| 217 | - | registrySvc.EXPECT().GetBlobPath(mock.Anything, "sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4").Return(tmpFile.Name(), nil) | |
| 220 | + | registrySvc.EXPECT().GetBlobPath(mock.Anything, "myapp", "sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4").Return(tmpFile.Name(), nil) | |
| 218 | 221 | ||
| 219 | - | req := httptest.NewRequest("GET", "/v2/myapp/blobs/sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4", nil) | |
| 220 | - | rec := httptest.NewRecorder() | |
| 221 | - | ||
| 222 | - | handler.ServeHTTP(rec, req) | |
| 222 | + | resp, err := server.Client().Get(server.URL + "/v2/myapp/blobs/sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4") | |
| 223 | + | require.NoError(t, err) | |
| 224 | + | defer resp.Body.Close() | |
| 225 | + | body, err := io.ReadAll(resp.Body) | |
| 226 | + | require.NoError(t, err) | |
| 223 | 227 | ||
| 224 | - | assert.Equal(t, http.StatusOK, rec.Code) | |
| 225 | - | assert.Equal(t, blobContent, rec.Body.Bytes()) | |
| 228 | + | assert.Equal(t, http.StatusOK, resp.StatusCode) | |
| 229 | + | assert.Equal(t, blobContent, body) | |
| 226 | 230 | } | |
| 227 | 231 | ||
| 228 | 232 | func TestHandler_GetBlob_NotFound(t *testing.T) { | |
| 229 | 233 | registrySvc := inmocks.NewMockRegistryService(t) | |
| 230 | 234 | ||
| 231 | 235 | handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize) | |
| 236 | + | server := httptest.NewServer(handler) | |
| 237 | + | defer server.Close() | |
| 232 | 238 | ||
| 233 | - | registrySvc.EXPECT().GetBlobPath(mock.Anything, "sha256:0000000000000000000000000000000000000000000000000000000000000000").Return("", assert.AnError) | |
| 239 | + | registrySvc.EXPECT().GetBlobPath(mock.Anything, "myapp", "sha256:0000000000000000000000000000000000000000000000000000000000000000").Return("", assert.AnError) | |
| 234 | 240 | ||
| 235 | - | req := httptest.NewRequest("GET", "/v2/myapp/blobs/sha256:0000000000000000000000000000000000000000000000000000000000000000", nil) | |
| 236 | - | rec := httptest.NewRecorder() | |
| 237 | - | ||
| 238 | - | handler.ServeHTTP(rec, req) | |
| 241 | + | resp, err := server.Client().Get(server.URL + "/v2/myapp/blobs/sha256:0000000000000000000000000000000000000000000000000000000000000000") | |
| 242 | + | require.NoError(t, err) | |
| 243 | + | defer resp.Body.Close() | |
| 239 | 244 | ||
| 240 | - | assert.Equal(t, http.StatusNotFound, rec.Code) | |
| 245 | + | assert.Equal(t, http.StatusNotFound, resp.StatusCode) | |
| 241 | 246 | } | |
| 242 | 247 | ||
| 243 | 248 | func TestHandler_BlobRoutes_MethodNotAllowed(t *testing.T) { | |
| @@ -13,6 +13,7 @@ import ( | |||||
| 13 | "github.com/bnema/zerowrap" | 13 | "github.com/bnema/zerowrap" | ||
| 14 | "github.com/stretchr/testify/assert" | 14 | "github.com/stretchr/testify/assert" | ||
| 15 | "github.com/stretchr/testify/mock" | 15 | "github.com/stretchr/testify/mock" | ||
| 16 | + | "github.com/stretchr/testify/require" | |||
| 16 | 17 | ||||
| 17 | inmocks "github.com/bnema/gordon/internal/boundaries/in/mocks" | 18 | inmocks "github.com/bnema/gordon/internal/boundaries/in/mocks" | ||
| 18 | "github.com/bnema/gordon/internal/domain" | 19 | "github.com/bnema/gordon/internal/domain" | ||
| @@ -204,6 +205,8 @@ func TestHandler_GetBlob_Success(t *testing.T) { | |||||
| 204 | registrySvc := inmocks.NewMockRegistryService(t) | 205 | registrySvc := inmocks.NewMockRegistryService(t) | ||
| 205 | 206 | ||||
| 206 | handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize) | 207 | handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize) | ||
| 208 | + | server := httptest.NewServer(handler) | |||
| 209 | + | defer server.Close() | |||
| 207 | 210 | ||||
| 208 | // Create a temp file for http.ServeFile | 211 | // Create a temp file for http.ServeFile | ||
| 209 | tmpFile, err := os.CreateTemp("", "blob-*") | 212 | tmpFile, err := os.CreateTemp("", "blob-*") | ||
| @@ -214,30 +217,32 @@ func TestHandler_GetBlob_Success(t *testing.T) { | |||||
| 214 | _, err = tmpFile.Write(blobContent) | 217 | _, err = tmpFile.Write(blobContent) | ||
| 215 | assert.NoError(t, err) | 218 | assert.NoError(t, err) | ||
| 216 | 219 | ||||
| 217 | - | registrySvc.EXPECT().GetBlobPath(mock.Anything, "sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4").Return(tmpFile.Name(), nil) | 220 | + | registrySvc.EXPECT().GetBlobPath(mock.Anything, "myapp", "sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4").Return(tmpFile.Name(), nil) |
| 218 | 221 | ||||
| 219 | - | req := httptest.NewRequest("GET", "/v2/myapp/blobs/sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4", nil) | 222 | + | resp, err := server.Client().Get(server.URL + "/v2/myapp/blobs/sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4") |
| 220 | - | rec := httptest.NewRecorder() | 223 | + | require.NoError(t, err) |
| 221 | - | 224 | + | defer resp.Body.Close() | |
| 222 | - | handler.ServeHTTP(rec, req) | 225 | + | body, err := io.ReadAll(resp.Body) |
| 226 | + | require.NoError(t, err) | |||
| 223 | 227 | ||||
| 224 | - | assert.Equal(t, http.StatusOK, rec.Code) | 228 | + | assert.Equal(t, http.StatusOK, resp.StatusCode) |
| 225 | - | assert.Equal(t, blobContent, rec.Body.Bytes()) | 229 | + | assert.Equal(t, blobContent, body) |
| 226 | } | 230 | } | ||
| 227 | 231 | ||||
| 228 | func TestHandler_GetBlob_NotFound(t *testing.T) { | 232 | func TestHandler_GetBlob_NotFound(t *testing.T) { | ||
| 229 | registrySvc := inmocks.NewMockRegistryService(t) | 233 | registrySvc := inmocks.NewMockRegistryService(t) | ||
| 230 | 234 | ||||
| 231 | handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize) | 235 | handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize) | ||
| 236 | + | server := httptest.NewServer(handler) | |||
| 237 | + | defer server.Close() | |||
| 232 | 238 | ||||
| 233 | - | registrySvc.EXPECT().GetBlobPath(mock.Anything, "sha256:0000000000000000000000000000000000000000000000000000000000000000").Return("", assert.AnError) | 239 | + | registrySvc.EXPECT().GetBlobPath(mock.Anything, "myapp", "sha256:0000000000000000000000000000000000000000000000000000000000000000").Return("", assert.AnError) |
| 234 | 240 | ||||
| 235 | - | req := httptest.NewRequest("GET", "/v2/myapp/blobs/sha256:0000000000000000000000000000000000000000000000000000000000000000", nil) | 241 | + | resp, err := server.Client().Get(server.URL + "/v2/myapp/blobs/sha256:0000000000000000000000000000000000000000000000000000000000000000") |
| 236 | - | rec := httptest.NewRecorder() | 242 | + | require.NoError(t, err) |
| 237 | - | 243 | + | defer resp.Body.Close() | |
| 238 | - | handler.ServeHTTP(rec, req) | |||
| 239 | 244 | ||||
| 240 | - | assert.Equal(t, http.StatusNotFound, rec.Code) | 245 | + | assert.Equal(t, http.StatusNotFound, resp.StatusCode) |
| 241 | } | 246 | } | ||
| 242 | 247 | ||||
| 243 | func TestHandler_BlobRoutes_MethodNotAllowed(t *testing.T) { | 248 | func TestHandler_BlobRoutes_MethodNotAllowed(t *testing.T) { | ||
M internal/adapters/out/docker/runtime.go +45 -4
| @@ -6,6 +6,7 @@ import ( | |||
| 6 | 6 | "bytes" | |
| 7 | 7 | "context" | |
| 8 | 8 | "encoding/base64" | |
| 9 | + | "encoding/binary" | |
| 9 | 10 | "encoding/json" | |
| 10 | 11 | "errors" | |
| 11 | 12 | "fmt" | |
| @@ -525,6 +526,7 @@ func (r *Runtime) GetContainerLogs(ctx context.Context, containerID string, foll | |||
| 525 | 526 | ShowStderr: true, | |
| 526 | 527 | Follow: follow, | |
| 527 | 528 | Timestamps: true, | |
| 529 | + | Tail: "10000", | |
| 528 | 530 | }) | |
| 529 | 531 | if err != nil { | |
| 530 | 532 | return nil, log.WrapErr(err, "failed to get container logs") | |
| @@ -747,6 +749,7 @@ func (r *Runtime) PruneImages(ctx context.Context, danglingOnly bool) (runtimepk | |||
| 747 | 749 | log := zerowrap.FromCtx(ctx) | |
| 748 | 750 | ||
| 749 | 751 | pruneFilters := filters.NewArgs() | |
| 752 | + | pruneFilters.Add("label", domain.LabelManaged+"=true") | |
| 750 | 753 | if danglingOnly { | |
| 751 | 754 | pruneFilters.Add("dangling", "true") | |
| 752 | 755 | } | |
| @@ -1724,12 +1727,50 @@ func (r *Runtime) ExecInContainer(ctx context.Context, containerID string, cmd [ | |||
| 1724 | 1727 | }, nil | |
| 1725 | 1728 | } | |
| 1726 | 1729 | ||
| 1730 | + | const maxExecOutputSize = 8 << 20 // 8 MiB per stream | |
| 1731 | + | ||
| 1732 | + | type boundedBuffer struct { | |
| 1733 | + | bytes.Buffer | |
| 1734 | + | limit int | |
| 1735 | + | } | |
| 1736 | + | ||
| 1737 | + | func (w *boundedBuffer) Write(p []byte) (int, error) { | |
| 1738 | + | remaining := w.limit - w.Len() | |
| 1739 | + | if remaining <= 0 { | |
| 1740 | + | return 0, fmt.Errorf("%w: limit is %d bytes", domain.ErrExecOutputExceeded, w.limit) | |
| 1741 | + | } | |
| 1742 | + | if len(p) > remaining { | |
| 1743 | + | _, _ = w.Buffer.Write(p[:remaining]) | |
| 1744 | + | return remaining, fmt.Errorf("%w: limit is %d bytes", domain.ErrExecOutputExceeded, w.limit) | |
| 1745 | + | } | |
| 1746 | + | return w.Buffer.Write(p) | |
| 1747 | + | } | |
| 1748 | + | ||
| 1727 | 1749 | func parseExecOutput(reader io.Reader) ([]byte, []byte, error) { | |
| 1728 | - | var stdout bytes.Buffer | |
| 1729 | - | var stderr bytes.Buffer | |
| 1750 | + | stdout := boundedBuffer{limit: maxExecOutputSize} | |
| 1751 | + | stderr := boundedBuffer{limit: maxExecOutputSize} | |
| 1752 | + | buffers := map[byte]*boundedBuffer{1: &stdout, 2: &stderr} | |
| 1753 | + | var header [8]byte | |
| 1730 | 1754 | ||
| 1731 | - | if _, err := stdcopy.StdCopy(&stdout, &stderr, reader); err != nil { | |
| 1732 | - | return nil, nil, err | |
| 1755 | + | for { | |
| 1756 | + | if _, err := io.ReadFull(reader, header[:]); err != nil { | |
| 1757 | + | if errors.Is(err, io.EOF) { | |
| 1758 | + | break | |
| 1759 | + | } | |
| 1760 | + | return nil, nil, fmt.Errorf("read Docker exec frame header: %w", err) | |
| 1761 | + | } | |
| 1762 | + | output, ok := buffers[header[0]] | |
| 1763 | + | if !ok { | |
| 1764 | + | return nil, nil, fmt.Errorf("invalid Docker exec stream %d", header[0]) | |
| 1765 | + | } | |
| 1766 | + | payloadSize := int64(binary.BigEndian.Uint32(header[4:])) | |
| 1767 | + | remaining := int64(output.limit - output.Len()) | |
| 1768 | + | if payloadSize > remaining { | |
| 1769 | + | return nil, nil, fmt.Errorf("%w: frame is %d bytes with %d bytes remaining", domain.ErrExecOutputExceeded, payloadSize, remaining) | |
| 1770 | + | } | |
| 1771 | + | if _, err := io.CopyN(output, reader, payloadSize); err != nil { | |
| 1772 | + | return nil, nil, fmt.Errorf("read Docker exec frame payload: %w", err) | |
| 1773 | + | } | |
| 1733 | 1774 | } | |
| 1734 | 1775 | ||
| 1735 | 1776 | return stdout.Bytes(), stderr.Bytes(), nil | |
| @@ -6,6 +6,7 @@ import ( | |||||
| 6 | "bytes" | 6 | "bytes" | ||
| 7 | "context" | 7 | "context" | ||
| 8 | "encoding/base64" | 8 | "encoding/base64" | ||
| 9 | + | "encoding/binary" | |||
| 9 | "encoding/json" | 10 | "encoding/json" | ||
| 10 | "errors" | 11 | "errors" | ||
| 11 | "fmt" | 12 | "fmt" | ||
| @@ -525,6 +526,7 @@ func (r *Runtime) GetContainerLogs(ctx context.Context, containerID string, foll | |||||
| 525 | ShowStderr: true, | 526 | ShowStderr: true, | ||
| 526 | Follow: follow, | 527 | Follow: follow, | ||
| 527 | Timestamps: true, | 528 | Timestamps: true, | ||
| 529 | + | Tail: "10000", | |||
| 528 | }) | 530 | }) | ||
| 529 | if err != nil { | 531 | if err != nil { | ||
| 530 | return nil, log.WrapErr(err, "failed to get container logs") | 532 | return nil, log.WrapErr(err, "failed to get container logs") | ||
| @@ -747,6 +749,7 @@ func (r *Runtime) PruneImages(ctx context.Context, danglingOnly bool) (runtimepk | |||||
| 747 | log := zerowrap.FromCtx(ctx) | 749 | log := zerowrap.FromCtx(ctx) | ||
| 748 | 750 | ||||
| 749 | pruneFilters := filters.NewArgs() | 751 | pruneFilters := filters.NewArgs() | ||
| 752 | + | pruneFilters.Add("label", domain.LabelManaged+"=true") | |||
| 750 | if danglingOnly { | 753 | if danglingOnly { | ||
| 751 | pruneFilters.Add("dangling", "true") | 754 | pruneFilters.Add("dangling", "true") | ||
| 752 | } | 755 | } | ||
| @@ -1724,12 +1727,50 @@ func (r *Runtime) ExecInContainer(ctx context.Context, containerID string, cmd [ | |||||
| 1724 | }, nil | 1727 | }, nil | ||
| 1725 | } | 1728 | } | ||
| 1726 | 1729 | ||||
| 1730 | + | const maxExecOutputSize = 8 << 20 // 8 MiB per stream | |||
| 1731 | + | ||||
| 1732 | + | type boundedBuffer struct { | |||
| 1733 | + | bytes.Buffer | |||
| 1734 | + | limit int | |||
| 1735 | + | } | |||
| 1736 | + | ||||
| 1737 | + | func (w *boundedBuffer) Write(p []byte) (int, error) { | |||
| 1738 | + | remaining := w.limit - w.Len() | |||
| 1739 | + | if remaining <= 0 { | |||
| 1740 | + | return 0, fmt.Errorf("%w: limit is %d bytes", domain.ErrExecOutputExceeded, w.limit) | |||
| 1741 | + | } | |||
| 1742 | + | if len(p) > remaining { | |||
| 1743 | + | _, _ = w.Buffer.Write(p[:remaining]) | |||
| 1744 | + | return remaining, fmt.Errorf("%w: limit is %d bytes", domain.ErrExecOutputExceeded, w.limit) | |||
| 1745 | + | } | |||
| 1746 | + | return w.Buffer.Write(p) | |||
| 1747 | + | } | |||
| 1748 | + | ||||
| 1727 | func parseExecOutput(reader io.Reader) ([]byte, []byte, error) { | 1749 | func parseExecOutput(reader io.Reader) ([]byte, []byte, error) { | ||
| 1728 | - | var stdout bytes.Buffer | 1750 | + | stdout := boundedBuffer{limit: maxExecOutputSize} |
| 1729 | - | var stderr bytes.Buffer | 1751 | + | stderr := boundedBuffer{limit: maxExecOutputSize} |
| 1752 | + | buffers := map[byte]*boundedBuffer{1: &stdout, 2: &stderr} | |||
| 1753 | + | var header [8]byte | |||
| 1730 | 1754 | ||||
| 1731 | - | if _, err := stdcopy.StdCopy(&stdout, &stderr, reader); err != nil { | 1755 | + | for { |
| 1732 | - | return nil, nil, err | 1756 | + | if _, err := io.ReadFull(reader, header[:]); err != nil { |
| 1757 | + | if errors.Is(err, io.EOF) { | |||
| 1758 | + | break | |||
| 1759 | + | } | |||
| 1760 | + | return nil, nil, fmt.Errorf("read Docker exec frame header: %w", err) | |||
| 1761 | + | } | |||
| 1762 | + | output, ok := buffers[header[0]] | |||
| 1763 | + | if !ok { | |||
| 1764 | + | return nil, nil, fmt.Errorf("invalid Docker exec stream %d", header[0]) | |||
| 1765 | + | } | |||
| 1766 | + | payloadSize := int64(binary.BigEndian.Uint32(header[4:])) | |||
| 1767 | + | remaining := int64(output.limit - output.Len()) | |||
| 1768 | + | if payloadSize > remaining { | |||
| 1769 | + | return nil, nil, fmt.Errorf("%w: frame is %d bytes with %d bytes remaining", domain.ErrExecOutputExceeded, payloadSize, remaining) | |||
| 1770 | + | } | |||
| 1771 | + | if _, err := io.CopyN(output, reader, payloadSize); err != nil { | |||
| 1772 | + | return nil, nil, fmt.Errorf("read Docker exec frame payload: %w", err) | |||
| 1773 | + | } | |||
| 1733 | } | 1774 | } | ||
| 1734 | 1775 | ||||
| 1735 | return stdout.Bytes(), stderr.Bytes(), nil | 1776 | return stdout.Bytes(), stderr.Bytes(), nil | ||
M internal/adapters/out/docker/runtime_exec_test.go +14 -0
| @@ -11,6 +11,8 @@ import ( | |||
| 11 | 11 | ||
| 12 | 12 | "github.com/stretchr/testify/assert" | |
| 13 | 13 | "github.com/stretchr/testify/require" | |
| 14 | + | ||
| 15 | + | "github.com/bnema/gordon/internal/domain" | |
| 14 | 16 | ) | |
| 15 | 17 | ||
| 16 | 18 | func TestParseExecOutput_SplitsStdoutAndStderr(t *testing.T) { | |
| @@ -23,6 +25,18 @@ func TestParseExecOutput_SplitsStdoutAndStderr(t *testing.T) { | |||
| 23 | 25 | assert.Equal(t, []byte("warn\n"), stderr) | |
| 24 | 26 | } | |
| 25 | 27 | ||
| 28 | + | func TestParseExecOutput_RejectsOversizedFrameBeforeReadingPayload(t *testing.T) { | |
| 29 | + | var header [8]byte | |
| 30 | + | header[0] = 1 | |
| 31 | + | binary.BigEndian.PutUint32(header[4:], maxExecOutputSize+1) | |
| 32 | + | ||
| 33 | + | stdout, stderr, err := parseExecOutput(bytes.NewReader(header[:])) | |
| 34 | + | ||
| 35 | + | assert.ErrorIs(t, err, domain.ErrExecOutputExceeded) | |
| 36 | + | assert.Nil(t, stdout) | |
| 37 | + | assert.Nil(t, stderr) | |
| 38 | + | } | |
| 39 | + | ||
| 26 | 40 | func TestRuntime_ExecInContainer_RejectsEmptyCommand(t *testing.T) { | |
| 27 | 41 | r := &Runtime{} | |
| 28 | 42 | ||
| @@ -11,6 +11,8 @@ import ( | |||||
| 11 | 11 | ||||
| 12 | "github.com/stretchr/testify/assert" | 12 | "github.com/stretchr/testify/assert" | ||
| 13 | "github.com/stretchr/testify/require" | 13 | "github.com/stretchr/testify/require" | ||
| 14 | + | ||||
| 15 | + | "github.com/bnema/gordon/internal/domain" | |||
| 14 | ) | 16 | ) | ||
| 15 | 17 | ||||
| 16 | func TestParseExecOutput_SplitsStdoutAndStderr(t *testing.T) { | 18 | func TestParseExecOutput_SplitsStdoutAndStderr(t *testing.T) { | ||
| @@ -23,6 +25,18 @@ func TestParseExecOutput_SplitsStdoutAndStderr(t *testing.T) { | |||||
| 23 | assert.Equal(t, []byte("warn\n"), stderr) | 25 | assert.Equal(t, []byte("warn\n"), stderr) | ||
| 24 | } | 26 | } | ||
| 25 | 27 | ||||
| 28 | + | func TestParseExecOutput_RejectsOversizedFrameBeforeReadingPayload(t *testing.T) { | |||
| 29 | + | var header [8]byte | |||
| 30 | + | header[0] = 1 | |||
| 31 | + | binary.BigEndian.PutUint32(header[4:], maxExecOutputSize+1) | |||
| 32 | + | ||||
| 33 | + | stdout, stderr, err := parseExecOutput(bytes.NewReader(header[:])) | |||
| 34 | + | ||||
| 35 | + | assert.ErrorIs(t, err, domain.ErrExecOutputExceeded) | |||
| 36 | + | assert.Nil(t, stdout) | |||
| 37 | + | assert.Nil(t, stderr) | |||
| 38 | + | } | |||
| 39 | + | ||||
| 26 | func TestRuntime_ExecInContainer_RejectsEmptyCommand(t *testing.T) { | 40 | func TestRuntime_ExecInContainer_RejectsEmptyCommand(t *testing.T) { | ||
| 27 | r := &Runtime{} | 41 | r := &Runtime{} | ||
| 28 | 42 | ||||
M internal/adapters/out/ratelimit/memory.go +51 -16
| @@ -2,6 +2,7 @@ | |||
| 2 | 2 | package ratelimit | |
| 3 | 3 | ||
| 4 | 4 | import ( | |
| 5 | + | "container/list" | |
| 5 | 6 | "context" | |
| 6 | 7 | "sync" | |
| 7 | 8 | "time" | |
| @@ -17,9 +18,21 @@ var _ out.RateLimiter = (*MemoryStore)(nil) | |||
| 17 | 18 | ||
| 18 | 19 | // MemoryStore is an in-memory rate limiter implementation using golang.org/x/time/rate. | |
| 19 | 20 | // Each unique key gets its own independent rate limiter. | |
| 21 | + | type limiterEntry struct { | |
| 22 | + | key string | |
| 23 | + | limiter *rate.Limiter | |
| 24 | + | lastSeen time.Time | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | const ( | |
| 28 | + | maxLimiterEntries = 10000 | |
| 29 | + | limiterIdleTTL = 10 * time.Minute | |
| 30 | + | ) | |
| 31 | + | ||
| 20 | 32 | type MemoryStore struct { | |
| 21 | - | limiters map[string]*rate.Limiter | |
| 22 | - | mu sync.RWMutex | |
| 33 | + | limiters map[string]*list.Element | |
| 34 | + | lru *list.List | |
| 35 | + | mu sync.Mutex | |
| 23 | 36 | rps float64 | |
| 24 | 37 | burst int | |
| 25 | 38 | log zerowrap.Logger | |
| @@ -28,7 +41,8 @@ type MemoryStore struct { | |||
| 28 | 41 | // NewMemoryStore creates a new in-memory rate limiter store. | |
| 29 | 42 | func NewMemoryStore(rps float64, burst int, log zerowrap.Logger) *MemoryStore { | |
| 30 | 43 | return &MemoryStore{ | |
| 31 | - | limiters: make(map[string]*rate.Limiter), | |
| 44 | + | limiters: make(map[string]*list.Element), | |
| 45 | + | lru: list.New(), | |
| 32 | 46 | rps: rps, | |
| 33 | 47 | burst: burst, | |
| 34 | 48 | log: log, | |
| @@ -48,23 +62,44 @@ func (s *MemoryStore) AllowN(_ context.Context, key string, n int) bool { | |||
| 48 | 62 | ||
| 49 | 63 | // getLimiter returns the rate limiter for the given key, creating one if it doesn't exist. | |
| 50 | 64 | func (s *MemoryStore) getLimiter(key string) *rate.Limiter { | |
| 51 | - | s.mu.RLock() | |
| 52 | - | limiter, exists := s.limiters[key] | |
| 53 | - | s.mu.RUnlock() | |
| 54 | - | ||
| 55 | - | if exists { | |
| 56 | - | return limiter | |
| 57 | - | } | |
| 58 | - | ||
| 65 | + | now := time.Now() | |
| 59 | 66 | s.mu.Lock() | |
| 60 | 67 | defer s.mu.Unlock() | |
| 61 | 68 | ||
| 62 | - | // Double-check after acquiring write lock | |
| 63 | - | if limiter, exists = s.limiters[key]; exists { | |
| 64 | - | return limiter | |
| 69 | + | if element, exists := s.limiters[key]; exists { | |
| 70 | + | entry := element.Value.(*limiterEntry) | |
| 71 | + | entry.lastSeen = now | |
| 72 | + | s.lru.MoveToFront(element) | |
| 73 | + | return entry.limiter | |
| 65 | 74 | } | |
| 66 | 75 | ||
| 67 | - | limiter = rate.NewLimiter(rate.Limit(s.rps), s.burst) | |
| 68 | - | s.limiters[key] = limiter | |
| 76 | + | s.evictExpiredOrOldest(now) | |
| 77 | + | limiter := rate.NewLimiter(rate.Limit(s.rps), s.burst) | |
| 78 | + | entry := &limiterEntry{key: key, limiter: limiter, lastSeen: now} | |
| 79 | + | s.limiters[key] = s.lru.PushFront(entry) | |
| 69 | 80 | return limiter | |
| 70 | 81 | } | |
| 82 | + | ||
| 83 | + | func (s *MemoryStore) evictExpiredOrOldest(now time.Time) { | |
| 84 | + | for element := s.lru.Back(); element != nil; { | |
| 85 | + | entry := element.Value.(*limiterEntry) | |
| 86 | + | if now.Sub(entry.lastSeen) <= limiterIdleTTL { | |
| 87 | + | break | |
| 88 | + | } | |
| 89 | + | previous := element.Prev() | |
| 90 | + | s.removeElement(element) | |
| 91 | + | element = previous | |
| 92 | + | } | |
| 93 | + | if len(s.limiters) >= maxLimiterEntries { | |
| 94 | + | s.removeElement(s.lru.Back()) | |
| 95 | + | } | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | func (s *MemoryStore) removeElement(element *list.Element) { | |
| 99 | + | if element == nil { | |
| 100 | + | return | |
| 101 | + | } | |
| 102 | + | entry := element.Value.(*limiterEntry) | |
| 103 | + | delete(s.limiters, entry.key) | |
| 104 | + | s.lru.Remove(element) | |
| 105 | + | } | |
| @@ -2,6 +2,7 @@ | |||||
| 2 | package ratelimit | 2 | package ratelimit | ||
| 3 | 3 | ||||
| 4 | import ( | 4 | import ( | ||
| 5 | + | "container/list" | |||
| 5 | "context" | 6 | "context" | ||
| 6 | "sync" | 7 | "sync" | ||
| 7 | "time" | 8 | "time" | ||
| @@ -17,9 +18,21 @@ var _ out.RateLimiter = (*MemoryStore)(nil) | |||||
| 17 | 18 | ||||
| 18 | // MemoryStore is an in-memory rate limiter implementation using golang.org/x/time/rate. | 19 | // MemoryStore is an in-memory rate limiter implementation using golang.org/x/time/rate. | ||
| 19 | // Each unique key gets its own independent rate limiter. | 20 | // Each unique key gets its own independent rate limiter. | ||
| 21 | + | type limiterEntry struct { | |||
| 22 | + | key string | |||
| 23 | + | limiter *rate.Limiter | |||
| 24 | + | lastSeen time.Time | |||
| 25 | + | } | |||
| 26 | + | ||||
| 27 | + | const ( | |||
| 28 | + | maxLimiterEntries = 10000 | |||
| 29 | + | limiterIdleTTL = 10 * time.Minute | |||
| 30 | + | ) | |||
| 31 | + | ||||
| 20 | type MemoryStore struct { | 32 | type MemoryStore struct { | ||
| 21 | - | limiters map[string]*rate.Limiter | 33 | + | limiters map[string]*list.Element |
| 22 | - | mu sync.RWMutex | 34 | + | lru *list.List |
| 35 | + | mu sync.Mutex | |||
| 23 | rps float64 | 36 | rps float64 | ||
| 24 | burst int | 37 | burst int | ||
| 25 | log zerowrap.Logger | 38 | log zerowrap.Logger | ||
| @@ -28,7 +41,8 @@ type MemoryStore struct { | |||||
| 28 | // NewMemoryStore creates a new in-memory rate limiter store. | 41 | // NewMemoryStore creates a new in-memory rate limiter store. | ||
| 29 | func NewMemoryStore(rps float64, burst int, log zerowrap.Logger) *MemoryStore { | 42 | func NewMemoryStore(rps float64, burst int, log zerowrap.Logger) *MemoryStore { | ||
| 30 | return &MemoryStore{ | 43 | return &MemoryStore{ | ||
| 31 | - | limiters: make(map[string]*rate.Limiter), | 44 | + | limiters: make(map[string]*list.Element), |
| 45 | + | lru: list.New(), | |||
| 32 | rps: rps, | 46 | rps: rps, | ||
| 33 | burst: burst, | 47 | burst: burst, | ||
| 34 | log: log, | 48 | log: log, | ||
| @@ -48,23 +62,44 @@ func (s *MemoryStore) AllowN(_ context.Context, key string, n int) bool { | |||||
| 48 | 62 | ||||
| 49 | // getLimiter returns the rate limiter for the given key, creating one if it doesn't exist. | 63 | // getLimiter returns the rate limiter for the given key, creating one if it doesn't exist. | ||
| 50 | func (s *MemoryStore) getLimiter(key string) *rate.Limiter { | 64 | func (s *MemoryStore) getLimiter(key string) *rate.Limiter { | ||
| 51 | - | s.mu.RLock() | 65 | + | now := time.Now() |
| 52 | - | limiter, exists := s.limiters[key] | |||
| 53 | - | s.mu.RUnlock() | |||
| 54 | - | ||||
| 55 | - | if exists { | |||
| 56 | - | return limiter | |||
| 57 | - | } | |||
| 58 | - | ||||
| 59 | s.mu.Lock() | 66 | s.mu.Lock() | ||
| 60 | defer s.mu.Unlock() | 67 | defer s.mu.Unlock() | ||
| 61 | 68 | ||||
| 62 | - | // Double-check after acquiring write lock | 69 | + | if element, exists := s.limiters[key]; exists { |
| 63 | - | if limiter, exists = s.limiters[key]; exists { | 70 | + | entry := element.Value.(*limiterEntry) |
| 64 | - | return limiter | 71 | + | entry.lastSeen = now |
| 72 | + | s.lru.MoveToFront(element) | |||
| 73 | + | return entry.limiter | |||
| 65 | } | 74 | } | ||
| 66 | 75 | ||||
| 67 | - | limiter = rate.NewLimiter(rate.Limit(s.rps), s.burst) | 76 | + | s.evictExpiredOrOldest(now) |
| 68 | - | s.limiters[key] = limiter | 77 | + | limiter := rate.NewLimiter(rate.Limit(s.rps), s.burst) |
| 78 | + | entry := &limiterEntry{key: key, limiter: limiter, lastSeen: now} | |||
| 79 | + | s.limiters[key] = s.lru.PushFront(entry) | |||
| 69 | return limiter | 80 | return limiter | ||
| 70 | } | 81 | } | ||
| 82 | + | ||||
| 83 | + | func (s *MemoryStore) evictExpiredOrOldest(now time.Time) { | |||
| 84 | + | for element := s.lru.Back(); element != nil; { | |||
| 85 | + | entry := element.Value.(*limiterEntry) | |||
| 86 | + | if now.Sub(entry.lastSeen) <= limiterIdleTTL { | |||
| 87 | + | break | |||
| 88 | + | } | |||
| 89 | + | previous := element.Prev() | |||
| 90 | + | s.removeElement(element) | |||
| 91 | + | element = previous | |||
| 92 | + | } | |||
| 93 | + | if len(s.limiters) >= maxLimiterEntries { | |||
| 94 | + | s.removeElement(s.lru.Back()) | |||
| 95 | + | } | |||
| 96 | + | } | |||
| 97 | + | ||||
| 98 | + | func (s *MemoryStore) removeElement(element *list.Element) { | |||
| 99 | + | if element == nil { | |||
| 100 | + | return | |||
| 101 | + | } | |||
| 102 | + | entry := element.Value.(*limiterEntry) | |||
| 103 | + | delete(s.limiters, entry.key) | |||
| 104 | + | s.lru.Remove(element) | |||
| 105 | + | } | |||
M internal/app/run.go +4 -2
| @@ -84,6 +84,7 @@ import ( | |||
| 84 | 84 | "github.com/bnema/gordon/internal/usecase/proxy" | |
| 85 | 85 | "github.com/bnema/gordon/internal/usecase/publictls" | |
| 86 | 86 | registrySvc "github.com/bnema/gordon/internal/usecase/registry" | |
| 87 | + | "github.com/bnema/gordon/internal/usecase/registrystate" | |
| 87 | 88 | secretsSvc "github.com/bnema/gordon/internal/usecase/secrets" | |
| 88 | 89 | servicecfg "github.com/bnema/gordon/internal/usecase/services" | |
| 89 | 90 | "github.com/bnema/gordon/internal/usecase/traffic" | |
| @@ -747,8 +748,9 @@ func (si *serviceInit) initRuntimeAndProxy() error { | |||
| 747 | 748 | return err | |
| 748 | 749 | } | |
| 749 | 750 | ||
| 750 | - | si.svc.registrySvc = registrySvc.NewService(si.svc.blobStorage, si.svc.manifestStorage, si.svc.eventBus) | |
| 751 | - | si.svc.imageSvc = images.NewService(si.svc.runtime, si.svc.manifestStorage, si.svc.blobStorage, si.log) | |
| 751 | + | registryState := registrystate.New() | |
| 752 | + | si.svc.registrySvc = registrySvc.NewService(si.svc.blobStorage, si.svc.manifestStorage, si.svc.eventBus, registryState) | |
| 753 | + | si.svc.imageSvc = images.NewService(si.svc.runtime, si.svc.manifestStorage, si.svc.blobStorage, si.log, registryState) | |
| 752 | 754 | si.svc.volumeSvc = volumesSvc.NewService(si.svc.runtime) | |
| 753 | 755 | ||
| 754 | 756 | injectTelemetryMetrics(si.cfg, si.svc, si.log) | |
| @@ -84,6 +84,7 @@ import ( | |||||
| 84 | "github.com/bnema/gordon/internal/usecase/proxy" | 84 | "github.com/bnema/gordon/internal/usecase/proxy" | ||
| 85 | "github.com/bnema/gordon/internal/usecase/publictls" | 85 | "github.com/bnema/gordon/internal/usecase/publictls" | ||
| 86 | registrySvc "github.com/bnema/gordon/internal/usecase/registry" | 86 | registrySvc "github.com/bnema/gordon/internal/usecase/registry" | ||
| 87 | + | "github.com/bnema/gordon/internal/usecase/registrystate" | |||
| 87 | secretsSvc "github.com/bnema/gordon/internal/usecase/secrets" | 88 | secretsSvc "github.com/bnema/gordon/internal/usecase/secrets" | ||
| 88 | servicecfg "github.com/bnema/gordon/internal/usecase/services" | 89 | servicecfg "github.com/bnema/gordon/internal/usecase/services" | ||
| 89 | "github.com/bnema/gordon/internal/usecase/traffic" | 90 | "github.com/bnema/gordon/internal/usecase/traffic" | ||
| @@ -747,8 +748,9 @@ func (si *serviceInit) initRuntimeAndProxy() error { | |||||
| 747 | return err | 748 | return err | ||
| 748 | } | 749 | } | ||
| 749 | 750 | ||||
| 750 | - | si.svc.registrySvc = registrySvc.NewService(si.svc.blobStorage, si.svc.manifestStorage, si.svc.eventBus) | 751 | + | registryState := registrystate.New() |
| 751 | - | si.svc.imageSvc = images.NewService(si.svc.runtime, si.svc.manifestStorage, si.svc.blobStorage, si.log) | 752 | + | si.svc.registrySvc = registrySvc.NewService(si.svc.blobStorage, si.svc.manifestStorage, si.svc.eventBus, registryState) |
| 753 | + | si.svc.imageSvc = images.NewService(si.svc.runtime, si.svc.manifestStorage, si.svc.blobStorage, si.log, registryState) | |||
| 752 | si.svc.volumeSvc = volumesSvc.NewService(si.svc.runtime) | 754 | si.svc.volumeSvc = volumesSvc.NewService(si.svc.runtime) | ||
| 753 | 755 | ||||
| 754 | injectTelemetryMetrics(si.cfg, si.svc, si.log) | 756 | injectTelemetryMetrics(si.cfg, si.svc, si.log) | ||
M internal/boundaries/in/mocks/mock_registry_service.go +18 -12
| @@ -438,8 +438,8 @@ func (_c *MockRegistryService_GetBlob_Call) RunAndReturn(run func(ctx context.Co | |||
| 438 | 438 | } | |
| 439 | 439 | ||
| 440 | 440 | // GetBlobPath provides a mock function for the type MockRegistryService | |
| 441 | - | func (_mock *MockRegistryService) GetBlobPath(ctx context.Context, digest string) (string, error) { | |
| 442 | - | ret := _mock.Called(ctx, digest) | |
| 441 | + | func (_mock *MockRegistryService) GetBlobPath(ctx context.Context, name string, digest string) (string, error) { | |
| 442 | + | ret := _mock.Called(ctx, name, digest) | |
| 443 | 443 | ||
| 444 | 444 | if len(ret) == 0 { | |
| 445 | 445 | panic("no return value specified for GetBlobPath") | |
| @@ -447,16 +447,16 @@ func (_mock *MockRegistryService) GetBlobPath(ctx context.Context, digest string | |||
| 447 | 447 | ||
| 448 | 448 | var r0 string | |
| 449 | 449 | var r1 error | |
| 450 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, string) (string, error)); ok { | |
| 451 | - | return returnFunc(ctx, digest) | |
| 450 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) (string, error)); ok { | |
| 451 | + | return returnFunc(ctx, name, digest) | |
| 452 | 452 | } | |
| 453 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, string) string); ok { | |
| 454 | - | r0 = returnFunc(ctx, digest) | |
| 453 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) string); ok { | |
| 454 | + | r0 = returnFunc(ctx, name, digest) | |
| 455 | 455 | } else { | |
| 456 | 456 | r0 = ret.Get(0).(string) | |
| 457 | 457 | } | |
| 458 | - | if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok { | |
| 459 | - | r1 = returnFunc(ctx, digest) | |
| 458 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, string, string) error); ok { | |
| 459 | + | r1 = returnFunc(ctx, name, digest) | |
| 460 | 460 | } else { | |
| 461 | 461 | r1 = ret.Error(1) | |
| 462 | 462 | } | |
| @@ -470,12 +470,13 @@ type MockRegistryService_GetBlobPath_Call struct { | |||
| 470 | 470 | ||
| 471 | 471 | // GetBlobPath is a helper method to define mock.On call | |
| 472 | 472 | // - ctx context.Context | |
| 473 | + | // - name string | |
| 473 | 474 | // - digest string | |
| 474 | - | func (_e *MockRegistryService_Expecter) GetBlobPath(ctx any, digest any) *MockRegistryService_GetBlobPath_Call { | |
| 475 | - | return &MockRegistryService_GetBlobPath_Call{Call: _e.mock.On("GetBlobPath", ctx, digest)} | |
| 475 | + | func (_e *MockRegistryService_Expecter) GetBlobPath(ctx any, name any, digest any) *MockRegistryService_GetBlobPath_Call { | |
| 476 | + | return &MockRegistryService_GetBlobPath_Call{Call: _e.mock.On("GetBlobPath", ctx, name, digest)} | |
| 476 | 477 | } | |
| 477 | 478 | ||
| 478 | - | func (_c *MockRegistryService_GetBlobPath_Call) Run(run func(ctx context.Context, digest string)) *MockRegistryService_GetBlobPath_Call { | |
| 479 | + | func (_c *MockRegistryService_GetBlobPath_Call) Run(run func(ctx context.Context, name string, digest string)) *MockRegistryService_GetBlobPath_Call { | |
| 479 | 480 | _c.Call.Run(func(args mock.Arguments) { | |
| 480 | 481 | var arg0 context.Context | |
| 481 | 482 | if args[0] != nil { | |
| @@ -485,9 +486,14 @@ func (_c *MockRegistryService_GetBlobPath_Call) Run(run func(ctx context.Context | |||
| 485 | 486 | if args[1] != nil { | |
| 486 | 487 | arg1 = args[1].(string) | |
| 487 | 488 | } | |
| 489 | + | var arg2 string | |
| 490 | + | if args[2] != nil { | |
| 491 | + | arg2 = args[2].(string) | |
| 492 | + | } | |
| 488 | 493 | run( | |
| 489 | 494 | arg0, | |
| 490 | 495 | arg1, | |
| 496 | + | arg2, | |
| 491 | 497 | ) | |
| 492 | 498 | }) | |
| 493 | 499 | return _c | |
| @@ -498,7 +504,7 @@ func (_c *MockRegistryService_GetBlobPath_Call) Return(s string, err error) *Moc | |||
| 498 | 504 | return _c | |
| 499 | 505 | } | |
| 500 | 506 | ||
| 501 | - | func (_c *MockRegistryService_GetBlobPath_Call) RunAndReturn(run func(ctx context.Context, digest string) (string, error)) *MockRegistryService_GetBlobPath_Call { | |
| 507 | + | func (_c *MockRegistryService_GetBlobPath_Call) RunAndReturn(run func(ctx context.Context, name string, digest string) (string, error)) *MockRegistryService_GetBlobPath_Call { | |
| 502 | 508 | _c.Call.Return(run) | |
| 503 | 509 | return _c | |
| 504 | 510 | } | |
| @@ -438,8 +438,8 @@ func (_c *MockRegistryService_GetBlob_Call) RunAndReturn(run func(ctx context.Co | |||||
| 438 | } | 438 | } | ||
| 439 | 439 | ||||
| 440 | // GetBlobPath provides a mock function for the type MockRegistryService | 440 | // GetBlobPath provides a mock function for the type MockRegistryService | ||
| 441 | - | func (_mock *MockRegistryService) GetBlobPath(ctx context.Context, digest string) (string, error) { | 441 | + | func (_mock *MockRegistryService) GetBlobPath(ctx context.Context, name string, digest string) (string, error) { |
| 442 | - | ret := _mock.Called(ctx, digest) | 442 | + | ret := _mock.Called(ctx, name, digest) |
| 443 | 443 | ||||
| 444 | if len(ret) == 0 { | 444 | if len(ret) == 0 { | ||
| 445 | panic("no return value specified for GetBlobPath") | 445 | panic("no return value specified for GetBlobPath") | ||
| @@ -447,16 +447,16 @@ func (_mock *MockRegistryService) GetBlobPath(ctx context.Context, digest string | |||||
| 447 | 447 | ||||
| 448 | var r0 string | 448 | var r0 string | ||
| 449 | var r1 error | 449 | var r1 error | ||
| 450 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, string) (string, error)); ok { | 450 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) (string, error)); ok { |
| 451 | - | return returnFunc(ctx, digest) | 451 | + | return returnFunc(ctx, name, digest) |
| 452 | } | 452 | } | ||
| 453 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, string) string); ok { | 453 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) string); ok { |
| 454 | - | r0 = returnFunc(ctx, digest) | 454 | + | r0 = returnFunc(ctx, name, digest) |
| 455 | } else { | 455 | } else { | ||
| 456 | r0 = ret.Get(0).(string) | 456 | r0 = ret.Get(0).(string) | ||
| 457 | } | 457 | } | ||
| 458 | - | if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok { | 458 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, string, string) error); ok { |
| 459 | - | r1 = returnFunc(ctx, digest) | 459 | + | r1 = returnFunc(ctx, name, digest) |
| 460 | } else { | 460 | } else { | ||
| 461 | r1 = ret.Error(1) | 461 | r1 = ret.Error(1) | ||
| 462 | } | 462 | } | ||
| @@ -470,12 +470,13 @@ type MockRegistryService_GetBlobPath_Call struct { | |||||
| 470 | 470 | ||||
| 471 | // GetBlobPath is a helper method to define mock.On call | 471 | // GetBlobPath is a helper method to define mock.On call | ||
| 472 | // - ctx context.Context | 472 | // - ctx context.Context | ||
| 473 | + | // - name string | |||
| 473 | // - digest string | 474 | // - digest string | ||
| 474 | - | func (_e *MockRegistryService_Expecter) GetBlobPath(ctx any, digest any) *MockRegistryService_GetBlobPath_Call { | 475 | + | func (_e *MockRegistryService_Expecter) GetBlobPath(ctx any, name any, digest any) *MockRegistryService_GetBlobPath_Call { |
| 475 | - | return &MockRegistryService_GetBlobPath_Call{Call: _e.mock.On("GetBlobPath", ctx, digest)} | 476 | + | return &MockRegistryService_GetBlobPath_Call{Call: _e.mock.On("GetBlobPath", ctx, name, digest)} |
| 476 | } | 477 | } | ||
| 477 | 478 | ||||
| 478 | - | func (_c *MockRegistryService_GetBlobPath_Call) Run(run func(ctx context.Context, digest string)) *MockRegistryService_GetBlobPath_Call { | 479 | + | func (_c *MockRegistryService_GetBlobPath_Call) Run(run func(ctx context.Context, name string, digest string)) *MockRegistryService_GetBlobPath_Call { |
| 479 | _c.Call.Run(func(args mock.Arguments) { | 480 | _c.Call.Run(func(args mock.Arguments) { | ||
| 480 | var arg0 context.Context | 481 | var arg0 context.Context | ||
| 481 | if args[0] != nil { | 482 | if args[0] != nil { | ||
| @@ -485,9 +486,14 @@ func (_c *MockRegistryService_GetBlobPath_Call) Run(run func(ctx context.Context | |||||
| 485 | if args[1] != nil { | 486 | if args[1] != nil { | ||
| 486 | arg1 = args[1].(string) | 487 | arg1 = args[1].(string) | ||
| 487 | } | 488 | } | ||
| 489 | + | var arg2 string | |||
| 490 | + | if args[2] != nil { | |||
| 491 | + | arg2 = args[2].(string) | |||
| 492 | + | } | |||
| 488 | run( | 493 | run( | ||
| 489 | arg0, | 494 | arg0, | ||
| 490 | arg1, | 495 | arg1, | ||
| 496 | + | arg2, | |||
| 491 | ) | 497 | ) | ||
| 492 | }) | 498 | }) | ||
| 493 | return _c | 499 | return _c | ||
| @@ -498,7 +504,7 @@ func (_c *MockRegistryService_GetBlobPath_Call) Return(s string, err error) *Moc | |||||
| 498 | return _c | 504 | return _c | ||
| 499 | } | 505 | } | ||
| 500 | 506 | ||||
| 501 | - | func (_c *MockRegistryService_GetBlobPath_Call) RunAndReturn(run func(ctx context.Context, digest string) (string, error)) *MockRegistryService_GetBlobPath_Call { | 507 | + | func (_c *MockRegistryService_GetBlobPath_Call) RunAndReturn(run func(ctx context.Context, name string, digest string) (string, error)) *MockRegistryService_GetBlobPath_Call { |
| 502 | _c.Call.Return(run) | 508 | _c.Call.Return(run) | ||
| 503 | return _c | 509 | return _c | ||
| 504 | } | 510 | } | ||
M internal/boundaries/in/registry.go +1 -1
| @@ -16,7 +16,7 @@ type RegistryService interface { | |||
| 16 | 16 | ||
| 17 | 17 | // Blob operations | |
| 18 | 18 | GetBlob(ctx context.Context, digest string) (io.ReadCloser, error) | |
| 19 | - | GetBlobPath(ctx context.Context, digest string) (string, error) | |
| 19 | + | GetBlobPath(ctx context.Context, name, digest string) (string, error) | |
| 20 | 20 | PutBlob(ctx context.Context, digest string, data io.Reader, size int64) error | |
| 21 | 21 | BlobExists(ctx context.Context, digest string) bool | |
| 22 | 22 | ||
| @@ -16,7 +16,7 @@ type RegistryService interface { | |||||
| 16 | 16 | ||||
| 17 | // Blob operations | 17 | // Blob operations | ||
| 18 | GetBlob(ctx context.Context, digest string) (io.ReadCloser, error) | 18 | GetBlob(ctx context.Context, digest string) (io.ReadCloser, error) | ||
| 19 | - | GetBlobPath(ctx context.Context, digest string) (string, error) | 19 | + | GetBlobPath(ctx context.Context, name, digest string) (string, error) |
| 20 | PutBlob(ctx context.Context, digest string, data io.Reader, size int64) error | 20 | PutBlob(ctx context.Context, digest string, data io.Reader, size int64) error | ||
| 21 | BlobExists(ctx context.Context, digest string) bool | 21 | BlobExists(ctx context.Context, digest string) bool | ||
| 22 | 22 | ||||
M internal/domain/auth.go +4 -8
| @@ -35,6 +35,7 @@ type TokenClaims struct { | |||
| 35 | 35 | IssuedAt int64 `json:"iat"` | |
| 36 | 36 | ExpiresAt int64 `json:"exp,omitempty"` // 0 means never expires | |
| 37 | 37 | Issuer string `json:"iss"` | |
| 38 | + | TokenType string `json:"token_type"` | |
| 38 | 39 | IsEphemeral bool `json:"-"` // true when token was issued by /auth/token (not stored) | |
| 39 | 40 | } | |
| 40 | 41 | ||
| @@ -257,17 +258,12 @@ func ScopesGrantRegistryAccess(grantedScopes []string, repoName, action string) | |||
| 257 | 258 | return false | |
| 258 | 259 | } | |
| 259 | 260 | ||
| 260 | - | // ScopesGrantAdminAccess reports whether any of the granted scope strings | |
| 261 | - | // authorise the given action on the named admin resource. | |
| 262 | - | // It handles the wildcard shorthand ("*") as well as full admin scope format | |
| 263 | - | // (admin:resource:actions). | |
| 261 | + | // ScopesGrantAdminAccess reports whether an explicit admin scope authorises | |
| 262 | + | // the given action on the named admin resource. Untyped shorthand scopes such | |
| 263 | + | // as "*" are registry-only and never grant administrative access. | |
| 264 | 264 | func ScopesGrantAdminAccess(grantedScopes []string, resource, action string) bool { | |
| 265 | 265 | for _, raw := range grantedScopes { | |
| 266 | 266 | scopeStr := strings.TrimSpace(raw) | |
| 267 | - | if scopeStr == ScopeActionAll { | |
| 268 | - | return true | |
| 269 | - | } | |
| 270 | - | ||
| 271 | 267 | adminScope, err := ParseAdminScope(scopeStr) | |
| 272 | 268 | if err != nil { | |
| 273 | 269 | continue | |
| @@ -35,6 +35,7 @@ type TokenClaims struct { | |||||
| 35 | IssuedAt int64 `json:"iat"` | 35 | IssuedAt int64 `json:"iat"` | ||
| 36 | ExpiresAt int64 `json:"exp,omitempty"` // 0 means never expires | 36 | ExpiresAt int64 `json:"exp,omitempty"` // 0 means never expires | ||
| 37 | Issuer string `json:"iss"` | 37 | Issuer string `json:"iss"` | ||
| 38 | + | TokenType string `json:"token_type"` | |||
| 38 | IsEphemeral bool `json:"-"` // true when token was issued by /auth/token (not stored) | 39 | IsEphemeral bool `json:"-"` // true when token was issued by /auth/token (not stored) | ||
| 39 | } | 40 | } | ||
| 40 | 41 | ||||
| @@ -257,17 +258,12 @@ func ScopesGrantRegistryAccess(grantedScopes []string, repoName, action string) | |||||
| 257 | return false | 258 | return false | ||
| 258 | } | 259 | } | ||
| 259 | 260 | ||||
| 260 | - | // ScopesGrantAdminAccess reports whether any of the granted scope strings | 261 | + | // ScopesGrantAdminAccess reports whether an explicit admin scope authorises |
| 261 | - | // authorise the given action on the named admin resource. | 262 | + | // the given action on the named admin resource. Untyped shorthand scopes such |
| 262 | - | // It handles the wildcard shorthand ("*") as well as full admin scope format | 263 | + | // as "*" are registry-only and never grant administrative access. |
| 263 | - | // (admin:resource:actions). | |||
| 264 | func ScopesGrantAdminAccess(grantedScopes []string, resource, action string) bool { | 264 | func ScopesGrantAdminAccess(grantedScopes []string, resource, action string) bool { | ||
| 265 | for _, raw := range grantedScopes { | 265 | for _, raw := range grantedScopes { | ||
| 266 | scopeStr := strings.TrimSpace(raw) | 266 | scopeStr := strings.TrimSpace(raw) | ||
| 267 | - | if scopeStr == ScopeActionAll { | |||
| 268 | - | return true | |||
| 269 | - | } | |||
| 270 | - | ||||
| 271 | adminScope, err := ParseAdminScope(scopeStr) | 267 | adminScope, err := ParseAdminScope(scopeStr) | ||
| 272 | if err != nil { | 268 | if err != nil { | ||
| 273 | continue | 269 | continue | ||
M internal/domain/auth_test.go +2 -2
| @@ -443,11 +443,11 @@ func TestScopesGrantAdminAccess(t *testing.T) { | |||
| 443 | 443 | want bool | |
| 444 | 444 | }{ | |
| 445 | 445 | { | |
| 446 | - | name: "wildcard shorthand grants any admin access", | |
| 446 | + | name: "wildcard shorthand does not grant admin access", | |
| 447 | 447 | granted: []string{"*"}, | |
| 448 | 448 | resource: "routes", | |
| 449 | 449 | action: "read", | |
| 450 | - | want: true, | |
| 450 | + | want: false, | |
| 451 | 451 | }, | |
| 452 | 452 | { | |
| 453 | 453 | name: "exact admin scope grants matching resource and action", | |
| @@ -443,11 +443,11 @@ func TestScopesGrantAdminAccess(t *testing.T) { | |||||
| 443 | want bool | 443 | want bool | ||
| 444 | }{ | 444 | }{ | ||
| 445 | { | 445 | { | ||
| 446 | - | name: "wildcard shorthand grants any admin access", | 446 | + | name: "wildcard shorthand does not grant admin access", |
| 447 | granted: []string{"*"}, | 447 | granted: []string{"*"}, | ||
| 448 | resource: "routes", | 448 | resource: "routes", | ||
| 449 | action: "read", | 449 | action: "read", | ||
| 450 | - | want: true, | 450 | + | want: false, |
| 451 | }, | 451 | }, | ||
| 452 | { | 452 | { | ||
| 453 | name: "exact admin scope grants matching resource and action", | 453 | name: "exact admin scope grants matching resource and action", | ||
M internal/domain/errors.go +15 -12
| @@ -28,13 +28,14 @@ var ( | |||
| 28 | 28 | ErrNoTargetAvailable = errors.New("no target available for route") | |
| 29 | 29 | ||
| 30 | 30 | // Registry errors | |
| 31 | - | ErrManifestNotFound = errors.New("manifest not found") | |
| 32 | - | ErrBlobNotFound = errors.New("blob not found") | |
| 33 | - | ErrUploadNotFound = errors.New("upload not found") | |
| 34 | - | ErrInvalidDigest = errors.New("invalid digest") | |
| 35 | - | ErrDigestMismatch = errors.New("digest mismatch") | |
| 36 | - | ErrUnauthorized = errors.New("unauthorized") | |
| 37 | - | ErrBlobSizeExceeded = errors.New("blob size exceeds maximum") | |
| 31 | + | ErrManifestNotFound = errors.New("manifest not found") | |
| 32 | + | ErrBlobNotFound = errors.New("blob not found") | |
| 33 | + | ErrUploadNotFound = errors.New("upload not found") | |
| 34 | + | ErrInvalidDigest = errors.New("invalid digest") | |
| 35 | + | ErrDigestMismatch = errors.New("digest mismatch") | |
| 36 | + | ErrUnauthorized = errors.New("unauthorized") | |
| 37 | + | ErrBlobSizeExceeded = errors.New("blob size exceeds maximum") | |
| 38 | + | ErrExecOutputExceeded = errors.New("container exec output exceeds maximum") | |
| 38 | 39 | ||
| 39 | 40 | // Network errors | |
| 40 | 41 | ErrNetworkNotFound = errors.New("network not found") | |
| @@ -58,11 +59,13 @@ var ( | |||
| 58 | 59 | ErrRouteConflict = errors.New("route conflicts with existing configuration") | |
| 59 | 60 | ||
| 60 | 61 | // Environment errors | |
| 61 | - | ErrEnvFileNotFound = errors.New("environment file not found") | |
| 62 | - | ErrSecretNotFound = errors.New("secret not found") | |
| 63 | - | ErrSecretsAlreadyExist = errors.New("secrets already exist") | |
| 64 | - | ErrProviderNotFound = errors.New("secret provider not found") | |
| 65 | - | ErrInvalidContainerName = errors.New("invalid container name") | |
| 62 | + | ErrEnvFileNotFound = errors.New("environment file not found") | |
| 63 | + | ErrSecretNotFound = errors.New("secret not found") | |
| 64 | + | ErrSecretsAlreadyExist = errors.New("secrets already exist") | |
| 65 | + | ErrProviderNotFound = errors.New("secret provider not found") | |
| 66 | + | ErrInvalidContainerName = errors.New("invalid container name") | |
| 67 | + | ErrAttachmentOwnershipMismatch = errors.New("attachment ownership mismatch") | |
| 68 | + | ErrReadinessLogSizeExceeded = errors.New("readiness log exceeds maximum") | |
| 66 | 69 | ||
| 67 | 70 | // Authentication errors | |
| 68 | 71 | ErrInvalidToken = errors.New("invalid token") | |
| @@ -28,13 +28,14 @@ var ( | |||||
| 28 | ErrNoTargetAvailable = errors.New("no target available for route") | 28 | ErrNoTargetAvailable = errors.New("no target available for route") | ||
| 29 | 29 | ||||
| 30 | // Registry errors | 30 | // Registry errors | ||
| 31 | - | ErrManifestNotFound = errors.New("manifest not found") | 31 | + | ErrManifestNotFound = errors.New("manifest not found") |
| 32 | - | ErrBlobNotFound = errors.New("blob not found") | 32 | + | ErrBlobNotFound = errors.New("blob not found") |
| 33 | - | ErrUploadNotFound = errors.New("upload not found") | 33 | + | ErrUploadNotFound = errors.New("upload not found") |
| 34 | - | ErrInvalidDigest = errors.New("invalid digest") | 34 | + | ErrInvalidDigest = errors.New("invalid digest") |
| 35 | - | ErrDigestMismatch = errors.New("digest mismatch") | 35 | + | ErrDigestMismatch = errors.New("digest mismatch") |
| 36 | - | ErrUnauthorized = errors.New("unauthorized") | 36 | + | ErrUnauthorized = errors.New("unauthorized") |
| 37 | - | ErrBlobSizeExceeded = errors.New("blob size exceeds maximum") | 37 | + | ErrBlobSizeExceeded = errors.New("blob size exceeds maximum") |
| 38 | + | ErrExecOutputExceeded = errors.New("container exec output exceeds maximum") | |||
| 38 | 39 | ||||
| 39 | // Network errors | 40 | // Network errors | ||
| 40 | ErrNetworkNotFound = errors.New("network not found") | 41 | ErrNetworkNotFound = errors.New("network not found") | ||
| @@ -58,11 +59,13 @@ var ( | |||||
| 58 | ErrRouteConflict = errors.New("route conflicts with existing configuration") | 59 | ErrRouteConflict = errors.New("route conflicts with existing configuration") | ||
| 59 | 60 | ||||
| 60 | // Environment errors | 61 | // Environment errors | ||
| 61 | - | ErrEnvFileNotFound = errors.New("environment file not found") | 62 | + | ErrEnvFileNotFound = errors.New("environment file not found") |
| 62 | - | ErrSecretNotFound = errors.New("secret not found") | 63 | + | ErrSecretNotFound = errors.New("secret not found") |
| 63 | - | ErrSecretsAlreadyExist = errors.New("secrets already exist") | 64 | + | ErrSecretsAlreadyExist = errors.New("secrets already exist") |
| 64 | - | ErrProviderNotFound = errors.New("secret provider not found") | 65 | + | ErrProviderNotFound = errors.New("secret provider not found") |
| 65 | - | ErrInvalidContainerName = errors.New("invalid container name") | 66 | + | ErrInvalidContainerName = errors.New("invalid container name") |
| 67 | + | ErrAttachmentOwnershipMismatch = errors.New("attachment ownership mismatch") | |||
| 68 | + | ErrReadinessLogSizeExceeded = errors.New("readiness log exceeds maximum") | |||
| 66 | 69 | ||||
| 67 | // Authentication errors | 70 | // Authentication errors | ||
| 68 | ErrInvalidToken = errors.New("invalid token") | 71 | ErrInvalidToken = errors.New("invalid token") | ||
M internal/domain/sanitize.go +11 -1
| @@ -1,6 +1,10 @@ | |||
| 1 | 1 | package domain | |
| 2 | 2 | ||
| 3 | - | import "strings" | |
| 3 | + | import ( | |
| 4 | + | "crypto/sha256" | |
| 5 | + | "fmt" | |
| 6 | + | "strings" | |
| 7 | + | ) | |
| 4 | 8 | ||
| 5 | 9 | // SanitizeDomainForContainer makes a domain safe for container naming. | |
| 6 | 10 | // It uses distinct replacements to avoid collisions between domains like | |
| @@ -19,6 +23,12 @@ func SanitizeDomainForContainer(domain string) string { | |||
| 19 | 23 | return result | |
| 20 | 24 | } | |
| 21 | 25 | ||
| 26 | + | // StableResourceName returns a Docker-safe, collision-resistant name fragment. | |
| 27 | + | func StableResourceName(value string) string { | |
| 28 | + | digest := sha256.Sum256([]byte(value)) | |
| 29 | + | return fmt.Sprintf("%s-%x", SanitizeDomainForContainer(value), digest[:6]) | |
| 30 | + | } | |
| 31 | + | ||
| 22 | 32 | // SanitizeDomainForContainerLegacy uses the OLD (buggy) sanitization that replaces | |
| 23 | 33 | // all separators with hyphens, for backwards compatibility. | |
| 24 | 34 | // | |
| @@ -1,6 +1,10 @@ | |||||
| 1 | package domain | 1 | package domain | ||
| 2 | 2 | ||||
| 3 | - | import "strings" | 3 | + | import ( |
| 4 | + | "crypto/sha256" | |||
| 5 | + | "fmt" | |||
| 6 | + | "strings" | |||
| 7 | + | ) | |||
| 4 | 8 | ||||
| 5 | // SanitizeDomainForContainer makes a domain safe for container naming. | 9 | // SanitizeDomainForContainer makes a domain safe for container naming. | ||
| 6 | // It uses distinct replacements to avoid collisions between domains like | 10 | // It uses distinct replacements to avoid collisions between domains like | ||
| @@ -19,6 +23,12 @@ func SanitizeDomainForContainer(domain string) string { | |||||
| 19 | return result | 23 | return result | ||
| 20 | } | 24 | } | ||
| 21 | 25 | ||||
| 26 | + | // StableResourceName returns a Docker-safe, collision-resistant name fragment. | |||
| 27 | + | func StableResourceName(value string) string { | |||
| 28 | + | digest := sha256.Sum256([]byte(value)) | |||
| 29 | + | return fmt.Sprintf("%s-%x", SanitizeDomainForContainer(value), digest[:6]) | |||
| 30 | + | } | |||
| 31 | + | ||||
| 22 | // SanitizeDomainForContainerLegacy uses the OLD (buggy) sanitization that replaces | 32 | // SanitizeDomainForContainerLegacy uses the OLD (buggy) sanitization that replaces | ||
| 23 | // all separators with hyphens, for backwards compatibility. | 33 | // all separators with hyphens, for backwards compatibility. | ||
| 24 | // | 34 | // | ||
M internal/usecase/auth/service.go +29 -20
| @@ -200,6 +200,9 @@ func buildTokenClaims(claims jwt.MapClaims) *domain.TokenClaims { | |||
| 200 | 200 | } | |
| 201 | 201 | } | |
| 202 | 202 | } | |
| 203 | + | if tokenType, ok := claims["token_type"].(string); ok { | |
| 204 | + | tokenClaims.TokenType = tokenType | |
| 205 | + | } | |
| 203 | 206 | ||
| 204 | 207 | return tokenClaims | |
| 205 | 208 | } | |
| @@ -238,6 +241,9 @@ func ensureTokenNotExpired(tokenClaims *domain.TokenClaims, log zerowrap.Logger) | |||
| 238 | 241 | // 2. Have lifetime ≤ MaxAccessTokenLifetime | |
| 239 | 242 | // 3. Be recently issued (within MaxAccessTokenLifetime) to prevent replay attacks | |
| 240 | 243 | func (s *Service) isEphemeralAccessToken(claims *domain.TokenClaims) bool { | |
| 244 | + | if claims.TokenType != "access" { | |
| 245 | + | return false // Only tokens minted by GenerateAccessToken may bypass the store | |
| 246 | + | } | |
| 241 | 247 | if claims.ExpiresAt <= 0 { | |
| 242 | 248 | return false // Never-expiring tokens require store validation | |
| 243 | 249 | } | |
| @@ -289,12 +295,13 @@ func (s *Service) GenerateToken(ctx context.Context, subject string, scopes []st | |||
| 289 | 295 | ||
| 290 | 296 | // Build JWT claims | |
| 291 | 297 | claims := jwt.MapClaims{ | |
| 292 | - | "jti": tokenID, | |
| 293 | - | "sub": subject, | |
| 294 | - | "iss": TokenIssuer, | |
| 295 | - | "iat": now.Unix(), | |
| 296 | - | "nbf": now.Unix(), // SECURITY: Not-before claim for clock skew protection | |
| 297 | - | "scopes": scopes, | |
| 298 | + | "jti": tokenID, | |
| 299 | + | "sub": subject, | |
| 300 | + | "iss": TokenIssuer, | |
| 301 | + | "iat": now.Unix(), | |
| 302 | + | "nbf": now.Unix(), // SECURITY: Not-before claim for clock skew protection | |
| 303 | + | "scopes": scopes, | |
| 304 | + | "token_type": "stored", | |
| 298 | 305 | } | |
| 299 | 306 | ||
| 300 | 307 | // Set expiry if specified | |
| @@ -349,13 +356,14 @@ func (s *Service) GenerateAccessToken(ctx context.Context, subject string, scope | |||
| 349 | 356 | now := time.Now().UTC() | |
| 350 | 357 | ||
| 351 | 358 | claims := jwt.MapClaims{ | |
| 352 | - | "jti": tokenID, | |
| 353 | - | "sub": subject, | |
| 354 | - | "iss": TokenIssuer, | |
| 355 | - | "iat": now.Unix(), | |
| 356 | - | "nbf": now.Unix(), // SECURITY: Not-before claim for clock skew protection | |
| 357 | - | "scopes": scopes, | |
| 358 | - | "exp": now.Add(expiry).Unix(), | |
| 359 | + | "jti": tokenID, | |
| 360 | + | "sub": subject, | |
| 361 | + | "iss": TokenIssuer, | |
| 362 | + | "iat": now.Unix(), | |
| 363 | + | "nbf": now.Unix(), // SECURITY: Not-before claim for clock skew protection | |
| 364 | + | "scopes": scopes, | |
| 365 | + | "exp": now.Add(expiry).Unix(), | |
| 366 | + | "token_type": "access", | |
| 359 | 367 | } | |
| 360 | 368 | ||
| 361 | 369 | jwtToken := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) | |
| @@ -507,13 +515,14 @@ func (s *Service) ExtendToken(ctx context.Context, tokenString string) (string, | |||
| 507 | 515 | newExpiresAt := now.Add(tokenExtensionTTL) | |
| 508 | 516 | ||
| 509 | 517 | newClaims := jwt.MapClaims{ | |
| 510 | - | "jti": tokenClaims.ID, // Reuse existing JTI to avoid invalidating concurrent requests | |
| 511 | - | "sub": tokenClaims.Subject, | |
| 512 | - | "iss": TokenIssuer, | |
| 513 | - | "iat": now.Unix(), // Update to current time (token is being re-issued) | |
| 514 | - | "nbf": now.Unix(), // SECURITY: not-before matches issuance time | |
| 515 | - | "scopes": tokenClaims.Scopes, | |
| 516 | - | "exp": newExpiresAt.Unix(), | |
| 518 | + | "jti": tokenClaims.ID, // Reuse existing JTI to avoid invalidating concurrent requests | |
| 519 | + | "sub": tokenClaims.Subject, | |
| 520 | + | "iss": TokenIssuer, | |
| 521 | + | "iat": now.Unix(), // Update to current time (token is being re-issued) | |
| 522 | + | "nbf": now.Unix(), // SECURITY: not-before matches issuance time | |
| 523 | + | "scopes": tokenClaims.Scopes, | |
| 524 | + | "exp": newExpiresAt.Unix(), | |
| 525 | + | "token_type": "stored", | |
| 517 | 526 | } | |
| 518 | 527 | ||
| 519 | 528 | jwtToken := jwt.NewWithClaims(jwt.SigningMethodHS256, newClaims) | |
| @@ -200,6 +200,9 @@ func buildTokenClaims(claims jwt.MapClaims) *domain.TokenClaims { | |||||
| 200 | } | 200 | } | ||
| 201 | } | 201 | } | ||
| 202 | } | 202 | } | ||
| 203 | + | if tokenType, ok := claims["token_type"].(string); ok { | |||
| 204 | + | tokenClaims.TokenType = tokenType | |||
| 205 | + | } | |||
| 203 | 206 | ||||
| 204 | return tokenClaims | 207 | return tokenClaims | ||
| 205 | } | 208 | } | ||
| @@ -238,6 +241,9 @@ func ensureTokenNotExpired(tokenClaims *domain.TokenClaims, log zerowrap.Logger) | |||||
| 238 | // 2. Have lifetime ≤ MaxAccessTokenLifetime | 241 | // 2. Have lifetime ≤ MaxAccessTokenLifetime | ||
| 239 | // 3. Be recently issued (within MaxAccessTokenLifetime) to prevent replay attacks | 242 | // 3. Be recently issued (within MaxAccessTokenLifetime) to prevent replay attacks | ||
| 240 | func (s *Service) isEphemeralAccessToken(claims *domain.TokenClaims) bool { | 243 | func (s *Service) isEphemeralAccessToken(claims *domain.TokenClaims) bool { | ||
| 244 | + | if claims.TokenType != "access" { | |||
| 245 | + | return false // Only tokens minted by GenerateAccessToken may bypass the store | |||
| 246 | + | } | |||
| 241 | if claims.ExpiresAt <= 0 { | 247 | if claims.ExpiresAt <= 0 { | ||
| 242 | return false // Never-expiring tokens require store validation | 248 | return false // Never-expiring tokens require store validation | ||
| 243 | } | 249 | } | ||
| @@ -289,12 +295,13 @@ func (s *Service) GenerateToken(ctx context.Context, subject string, scopes []st | |||||
| 289 | 295 | ||||
| 290 | // Build JWT claims | 296 | // Build JWT claims | ||
| 291 | claims := jwt.MapClaims{ | 297 | claims := jwt.MapClaims{ | ||
| 292 | - | "jti": tokenID, | 298 | + | "jti": tokenID, |
| 293 | - | "sub": subject, | 299 | + | "sub": subject, |
| 294 | - | "iss": TokenIssuer, | 300 | + | "iss": TokenIssuer, |
| 295 | - | "iat": now.Unix(), | 301 | + | "iat": now.Unix(), |
| 296 | - | "nbf": now.Unix(), // SECURITY: Not-before claim for clock skew protection | 302 | + | "nbf": now.Unix(), // SECURITY: Not-before claim for clock skew protection |
| 297 | - | "scopes": scopes, | 303 | + | "scopes": scopes, |
| 304 | + | "token_type": "stored", | |||
| 298 | } | 305 | } | ||
| 299 | 306 | ||||
| 300 | // Set expiry if specified | 307 | // Set expiry if specified | ||
| @@ -349,13 +356,14 @@ func (s *Service) GenerateAccessToken(ctx context.Context, subject string, scope | |||||
| 349 | now := time.Now().UTC() | 356 | now := time.Now().UTC() | ||
| 350 | 357 | ||||
| 351 | claims := jwt.MapClaims{ | 358 | claims := jwt.MapClaims{ | ||
| 352 | - | "jti": tokenID, | 359 | + | "jti": tokenID, |
| 353 | - | "sub": subject, | 360 | + | "sub": subject, |
| 354 | - | "iss": TokenIssuer, | 361 | + | "iss": TokenIssuer, |
| 355 | - | "iat": now.Unix(), | 362 | + | "iat": now.Unix(), |
| 356 | - | "nbf": now.Unix(), // SECURITY: Not-before claim for clock skew protection | 363 | + | "nbf": now.Unix(), // SECURITY: Not-before claim for clock skew protection |
| 357 | - | "scopes": scopes, | 364 | + | "scopes": scopes, |
| 358 | - | "exp": now.Add(expiry).Unix(), | 365 | + | "exp": now.Add(expiry).Unix(), |
| 366 | + | "token_type": "access", | |||
| 359 | } | 367 | } | ||
| 360 | 368 | ||||
| 361 | jwtToken := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) | 369 | jwtToken := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) | ||
| @@ -507,13 +515,14 @@ func (s *Service) ExtendToken(ctx context.Context, tokenString string) (string, | |||||
| 507 | newExpiresAt := now.Add(tokenExtensionTTL) | 515 | newExpiresAt := now.Add(tokenExtensionTTL) | ||
| 508 | 516 | ||||
| 509 | newClaims := jwt.MapClaims{ | 517 | newClaims := jwt.MapClaims{ | ||
| 510 | - | "jti": tokenClaims.ID, // Reuse existing JTI to avoid invalidating concurrent requests | 518 | + | "jti": tokenClaims.ID, // Reuse existing JTI to avoid invalidating concurrent requests |
| 511 | - | "sub": tokenClaims.Subject, | 519 | + | "sub": tokenClaims.Subject, |
| 512 | - | "iss": TokenIssuer, | 520 | + | "iss": TokenIssuer, |
| 513 | - | "iat": now.Unix(), // Update to current time (token is being re-issued) | 521 | + | "iat": now.Unix(), // Update to current time (token is being re-issued) |
| 514 | - | "nbf": now.Unix(), // SECURITY: not-before matches issuance time | 522 | + | "nbf": now.Unix(), // SECURITY: not-before matches issuance time |
| 515 | - | "scopes": tokenClaims.Scopes, | 523 | + | "scopes": tokenClaims.Scopes, |
| 516 | - | "exp": newExpiresAt.Unix(), | 524 | + | "exp": newExpiresAt.Unix(), |
| 525 | + | "token_type": "stored", | |||
| 517 | } | 526 | } | ||
| 518 | 527 | ||||
| 519 | jwtToken := jwt.NewWithClaims(jwt.SigningMethodHS256, newClaims) | 528 | jwtToken := jwt.NewWithClaims(jwt.SigningMethodHS256, newClaims) | ||
M internal/usecase/auth/service_test.go +15 -0
| @@ -802,6 +802,7 @@ func TestExtendTokenSlidesExpiry(t *testing.T) { | |||
| 802 | 802 | expectedExpiry := time.Now().Add(24 * time.Hour) | |
| 803 | 803 | actualExpiry := time.Unix(claims.ExpiresAt, 0) | |
| 804 | 804 | assert.WithinDuration(t, expectedExpiry, actualExpiry, 5*time.Minute, "expiry should be ~24h from now") | |
| 805 | + | assert.Equal(t, "stored", claims.TokenType) | |
| 805 | 806 | } | |
| 806 | 807 | ||
| 807 | 808 | func TestExtendTokenDebounce(t *testing.T) { | |
| @@ -939,6 +940,20 @@ func TestIsEphemeralAccessTokenRejectsFutureIat(t *testing.T) { | |||
| 939 | 940 | } | |
| 940 | 941 | } | |
| 941 | 942 | ||
| 943 | + | func TestIsEphemeralAccessTokenRequiresExplicitTokenType(t *testing.T) { | |
| 944 | + | svc, _ := newTestAuthService(t) | |
| 945 | + | now := time.Now().UTC() | |
| 946 | + | ||
| 947 | + | claims := &domain.TokenClaims{ | |
| 948 | + | IssuedAt: now.Unix(), | |
| 949 | + | ExpiresAt: now.Add(5 * time.Minute).Unix(), | |
| 950 | + | } | |
| 951 | + | ||
| 952 | + | assert.False(t, svc.isEphemeralAccessToken(claims)) | |
| 953 | + | claims.TokenType = "access" | |
| 954 | + | assert.True(t, svc.isEphemeralAccessToken(claims)) | |
| 955 | + | } | |
| 956 | + | ||
| 942 | 957 | func TestExtendTokenSkipsServiceToken(t *testing.T) { | |
| 943 | 958 | tokenStore := mocks.NewMockTokenStore(t) | |
| 944 | 959 | ||
| @@ -802,6 +802,7 @@ func TestExtendTokenSlidesExpiry(t *testing.T) { | |||||
| 802 | expectedExpiry := time.Now().Add(24 * time.Hour) | 802 | expectedExpiry := time.Now().Add(24 * time.Hour) | ||
| 803 | actualExpiry := time.Unix(claims.ExpiresAt, 0) | 803 | actualExpiry := time.Unix(claims.ExpiresAt, 0) | ||
| 804 | assert.WithinDuration(t, expectedExpiry, actualExpiry, 5*time.Minute, "expiry should be ~24h from now") | 804 | assert.WithinDuration(t, expectedExpiry, actualExpiry, 5*time.Minute, "expiry should be ~24h from now") | ||
| 805 | + | assert.Equal(t, "stored", claims.TokenType) | |||
| 805 | } | 806 | } | ||
| 806 | 807 | ||||
| 807 | func TestExtendTokenDebounce(t *testing.T) { | 808 | func TestExtendTokenDebounce(t *testing.T) { | ||
| @@ -939,6 +940,20 @@ func TestIsEphemeralAccessTokenRejectsFutureIat(t *testing.T) { | |||||
| 939 | } | 940 | } | ||
| 940 | } | 941 | } | ||
| 941 | 942 | ||||
| 943 | + | func TestIsEphemeralAccessTokenRequiresExplicitTokenType(t *testing.T) { | |||
| 944 | + | svc, _ := newTestAuthService(t) | |||
| 945 | + | now := time.Now().UTC() | |||
| 946 | + | ||||
| 947 | + | claims := &domain.TokenClaims{ | |||
| 948 | + | IssuedAt: now.Unix(), | |||
| 949 | + | ExpiresAt: now.Add(5 * time.Minute).Unix(), | |||
| 950 | + | } | |||
| 951 | + | ||||
| 952 | + | assert.False(t, svc.isEphemeralAccessToken(claims)) | |||
| 953 | + | claims.TokenType = "access" | |||
| 954 | + | assert.True(t, svc.isEphemeralAccessToken(claims)) | |||
| 955 | + | } | |||
| 956 | + | ||||
| 942 | func TestExtendTokenSkipsServiceToken(t *testing.T) { | 957 | func TestExtendTokenSkipsServiceToken(t *testing.T) { | ||
| 943 | tokenStore := mocks.NewMockTokenStore(t) | 958 | tokenStore := mocks.NewMockTokenStore(t) | ||
| 944 | 959 | ||||
M internal/usecase/auto/preview/handler.go +12 -1
| @@ -16,6 +16,7 @@ type AutoPreviewHandler struct { | |||
| 16 | 16 | serviceCtx context.Context | |
| 17 | 17 | config auto.AutoConfigProvider | |
| 18 | 18 | previewService *Service | |
| 19 | + | jobs chan struct{} | |
| 19 | 20 | } | |
| 20 | 21 | ||
| 21 | 22 | func NewAutoPreviewHandler( | |
| @@ -27,6 +28,7 @@ func NewAutoPreviewHandler( | |||
| 27 | 28 | serviceCtx: serviceCtx, | |
| 28 | 29 | config: config, | |
| 29 | 30 | previewService: previewService, | |
| 31 | + | jobs: make(chan struct{}, 16), | |
| 30 | 32 | } | |
| 31 | 33 | } | |
| 32 | 34 | ||
| @@ -73,8 +75,17 @@ func (h *AutoPreviewHandler) Handle(ctx context.Context, event domain.Event) err | |||
| 73 | 75 | continue | |
| 74 | 76 | } | |
| 75 | 77 | ||
| 76 | - | // Launch async — volume cloning may exceed event bus timeout | |
| 78 | + | // Launch async — volume cloning may exceed event bus timeout. The bounded | |
| 79 | + | // semaphore prevents a push burst from creating an unbounded goroutine backlog. | |
| 80 | + | select { | |
| 81 | + | case h.jobs <- struct{}{}: | |
| 82 | + | case <-ctx.Done(): | |
| 83 | + | return fmt.Errorf("schedule preview %s for %s: %w", previewName, baseRoute.Domain, ctx.Err()) | |
| 84 | + | case <-h.serviceCtx.Done(): | |
| 85 | + | return h.serviceCtx.Err() | |
| 86 | + | } | |
| 77 | 87 | go func(baseRoute baseRouteInfo, previewDomain string) { | |
| 88 | + | defer func() { <-h.jobs }() | |
| 78 | 89 | if err := h.previewService.CreatePreview(h.serviceCtx, CreatePreviewRequest{ | |
| 79 | 90 | Name: previewName, | |
| 80 | 91 | Domain: previewDomain, | |
| @@ -16,6 +16,7 @@ type AutoPreviewHandler struct { | |||||
| 16 | serviceCtx context.Context | 16 | serviceCtx context.Context | ||
| 17 | config auto.AutoConfigProvider | 17 | config auto.AutoConfigProvider | ||
| 18 | previewService *Service | 18 | previewService *Service | ||
| 19 | + | jobs chan struct{} | |||
| 19 | } | 20 | } | ||
| 20 | 21 | ||||
| 21 | func NewAutoPreviewHandler( | 22 | func NewAutoPreviewHandler( | ||
| @@ -27,6 +28,7 @@ func NewAutoPreviewHandler( | |||||
| 27 | serviceCtx: serviceCtx, | 28 | serviceCtx: serviceCtx, | ||
| 28 | config: config, | 29 | config: config, | ||
| 29 | previewService: previewService, | 30 | previewService: previewService, | ||
| 31 | + | jobs: make(chan struct{}, 16), | |||
| 30 | } | 32 | } | ||
| 31 | } | 33 | } | ||
| 32 | 34 | ||||
| @@ -73,8 +75,17 @@ func (h *AutoPreviewHandler) Handle(ctx context.Context, event domain.Event) err | |||||
| 73 | continue | 75 | continue | ||
| 74 | } | 76 | } | ||
| 75 | 77 | ||||
| 76 | - | // Launch async — volume cloning may exceed event bus timeout | 78 | + | // Launch async — volume cloning may exceed event bus timeout. The bounded |
| 79 | + | // semaphore prevents a push burst from creating an unbounded goroutine backlog. | |||
| 80 | + | select { | |||
| 81 | + | case h.jobs <- struct{}{}: | |||
| 82 | + | case <-ctx.Done(): | |||
| 83 | + | return fmt.Errorf("schedule preview %s for %s: %w", previewName, baseRoute.Domain, ctx.Err()) | |||
| 84 | + | case <-h.serviceCtx.Done(): | |||
| 85 | + | return h.serviceCtx.Err() | |||
| 86 | + | } | |||
| 77 | go func(baseRoute baseRouteInfo, previewDomain string) { | 87 | go func(baseRoute baseRouteInfo, previewDomain string) { | ||
| 88 | + | defer func() { <-h.jobs }() | |||
| 78 | if err := h.previewService.CreatePreview(h.serviceCtx, CreatePreviewRequest{ | 89 | if err := h.previewService.CreatePreview(h.serviceCtx, CreatePreviewRequest{ | ||
| 79 | Name: previewName, | 90 | Name: previewName, | ||
| 80 | Domain: previewDomain, | 91 | Domain: previewDomain, | ||
M internal/usecase/auto/preview/service.go +22 -8
| @@ -92,7 +92,17 @@ func (s *Service) Load(ctx context.Context) error { | |||
| 92 | 92 | ||
| 93 | 93 | func (s *Service) Add(ctx context.Context, p domain.PreviewRoute) error { | |
| 94 | 94 | s.mu.Lock() | |
| 95 | - | s.previews = append(s.previews, p) | |
| 95 | + | replaced := false | |
| 96 | + | for i := range s.previews { | |
| 97 | + | if s.previews[i].Domain == p.Domain { | |
| 98 | + | s.previews[i] = p | |
| 99 | + | replaced = true | |
| 100 | + | break | |
| 101 | + | } | |
| 102 | + | } | |
| 103 | + | if !replaced { | |
| 104 | + | s.previews = append(s.previews, p) | |
| 105 | + | } | |
| 96 | 106 | cp := make([]domain.PreviewRoute, len(s.previews)) | |
| 97 | 107 | copy(cp, s.previews) | |
| 98 | 108 | s.mu.Unlock() | |
| @@ -102,7 +112,7 @@ func (s *Service) Add(ctx context.Context, p domain.PreviewRoute) error { | |||
| 102 | 112 | func (s *Service) Update(ctx context.Context, p domain.PreviewRoute) error { | |
| 103 | 113 | s.mu.Lock() | |
| 104 | 114 | for i := range s.previews { | |
| 105 | - | if s.previews[i].Name == p.Name { | |
| 115 | + | if s.previews[i].Domain == p.Domain { | |
| 106 | 116 | s.previews[i] = p | |
| 107 | 117 | cp := make([]domain.PreviewRoute, len(s.previews)) | |
| 108 | 118 | copy(cp, s.previews) | |
| @@ -313,8 +323,8 @@ func (s *Service) qualifyImage(image string) string { | |||
| 313 | 323 | func generateVolumeName(prefix, domainName, volumePath string) string { | |
| 314 | 324 | return fmt.Sprintf("%s-%s-%s", | |
| 315 | 325 | prefix, | |
| 316 | - | strings.ReplaceAll(domainName, ".", "-"), | |
| 317 | - | strings.ReplaceAll(strings.Trim(volumePath, "/"), "/", "-")) | |
| 326 | + | domain.StableResourceName(domainName), | |
| 327 | + | domain.StableResourceName(strings.Trim(volumePath, "/"))) | |
| 318 | 328 | } | |
| 319 | 329 | ||
| 320 | 330 | func (s *Service) cloneBaseRouteVolumes(ctx context.Context, baseRoute, previewDomain string) ([]string, error) { | |
| @@ -328,7 +338,8 @@ func (s *Service) cloneBaseRouteVolumes(ctx context.Context, baseRoute, previewD | |||
| 328 | 338 | } | |
| 329 | 339 | ||
| 330 | 340 | log := zerowrap.FromCtx(ctx) | |
| 331 | - | basePrefix := prefix + "-" + strings.ReplaceAll(baseRoute, ".", "-") + "-" | |
| 341 | + | basePrefix := prefix + "-" + domain.StableResourceName(baseRoute) + "-" | |
| 342 | + | legacyBasePrefix := prefix + "-" + strings.ReplaceAll(baseRoute, ".", "-") + "-" | |
| 332 | 343 | ||
| 333 | 344 | vols, err := s.volumeCloner.ListVolumes(ctx) | |
| 334 | 345 | if err != nil { | |
| @@ -337,7 +348,7 @@ func (s *Service) cloneBaseRouteVolumes(ctx context.Context, baseRoute, previewD | |||
| 337 | 348 | ||
| 338 | 349 | var sourceVols []string | |
| 339 | 350 | for _, v := range vols { | |
| 340 | - | if strings.HasPrefix(v.Name, basePrefix) { | |
| 351 | + | if strings.HasPrefix(v.Name, basePrefix) || strings.HasPrefix(v.Name, legacyBasePrefix) { | |
| 341 | 352 | sourceVols = append(sourceVols, v.Name) | |
| 342 | 353 | } | |
| 343 | 354 | } | |
| @@ -348,6 +359,9 @@ func (s *Service) cloneBaseRouteVolumes(ctx context.Context, baseRoute, previewD | |||
| 348 | 359 | ||
| 349 | 360 | namer := func(sourceVolName string) string { | |
| 350 | 361 | pathSuffix := strings.TrimPrefix(sourceVolName, basePrefix) | |
| 362 | + | if pathSuffix == sourceVolName { | |
| 363 | + | pathSuffix = strings.TrimPrefix(sourceVolName, legacyBasePrefix) | |
| 364 | + | } | |
| 351 | 365 | return generateVolumeName(prefix, previewDomain, pathSuffix) | |
| 352 | 366 | } | |
| 353 | 367 | ||
| @@ -370,11 +384,11 @@ type CreatePreviewRequest struct { | |||
| 370 | 384 | ||
| 371 | 385 | // CreatePreview orchestrates the full preview creation: lock, clone volumes, start containers, register. | |
| 372 | 386 | func (s *Service) CreatePreview(ctx context.Context, req CreatePreviewRequest) error { | |
| 373 | - | lock := s.AcquireNameLock(req.Name) | |
| 387 | + | lock := s.AcquireNameLock(req.Domain) | |
| 374 | 388 | lock.Lock() | |
| 375 | 389 | defer func() { | |
| 376 | 390 | lock.Unlock() | |
| 377 | - | s.ReleaseNameLock(req.Name) | |
| 391 | + | s.ReleaseNameLock(req.Domain) | |
| 378 | 392 | }() | |
| 379 | 393 | ||
| 380 | 394 | log := zerowrap.FromCtx(ctx) | |
| @@ -92,7 +92,17 @@ func (s *Service) Load(ctx context.Context) error { | |||||
| 92 | 92 | ||||
| 93 | func (s *Service) Add(ctx context.Context, p domain.PreviewRoute) error { | 93 | func (s *Service) Add(ctx context.Context, p domain.PreviewRoute) error { | ||
| 94 | s.mu.Lock() | 94 | s.mu.Lock() | ||
| 95 | - | s.previews = append(s.previews, p) | 95 | + | replaced := false |
| 96 | + | for i := range s.previews { | |||
| 97 | + | if s.previews[i].Domain == p.Domain { | |||
| 98 | + | s.previews[i] = p | |||
| 99 | + | replaced = true | |||
| 100 | + | break | |||
| 101 | + | } | |||
| 102 | + | } | |||
| 103 | + | if !replaced { | |||
| 104 | + | s.previews = append(s.previews, p) | |||
| 105 | + | } | |||
| 96 | cp := make([]domain.PreviewRoute, len(s.previews)) | 106 | cp := make([]domain.PreviewRoute, len(s.previews)) | ||
| 97 | copy(cp, s.previews) | 107 | copy(cp, s.previews) | ||
| 98 | s.mu.Unlock() | 108 | s.mu.Unlock() | ||
| @@ -102,7 +112,7 @@ func (s *Service) Add(ctx context.Context, p domain.PreviewRoute) error { | |||||
| 102 | func (s *Service) Update(ctx context.Context, p domain.PreviewRoute) error { | 112 | func (s *Service) Update(ctx context.Context, p domain.PreviewRoute) error { | ||
| 103 | s.mu.Lock() | 113 | s.mu.Lock() | ||
| 104 | for i := range s.previews { | 114 | for i := range s.previews { | ||
| 105 | - | if s.previews[i].Name == p.Name { | 115 | + | if s.previews[i].Domain == p.Domain { |
| 106 | s.previews[i] = p | 116 | s.previews[i] = p | ||
| 107 | cp := make([]domain.PreviewRoute, len(s.previews)) | 117 | cp := make([]domain.PreviewRoute, len(s.previews)) | ||
| 108 | copy(cp, s.previews) | 118 | copy(cp, s.previews) | ||
| @@ -313,8 +323,8 @@ func (s *Service) qualifyImage(image string) string { | |||||
| 313 | func generateVolumeName(prefix, domainName, volumePath string) string { | 323 | func generateVolumeName(prefix, domainName, volumePath string) string { | ||
| 314 | return fmt.Sprintf("%s-%s-%s", | 324 | return fmt.Sprintf("%s-%s-%s", | ||
| 315 | prefix, | 325 | prefix, | ||
| 316 | - | strings.ReplaceAll(domainName, ".", "-"), | 326 | + | domain.StableResourceName(domainName), |
| 317 | - | strings.ReplaceAll(strings.Trim(volumePath, "/"), "/", "-")) | 327 | + | domain.StableResourceName(strings.Trim(volumePath, "/"))) |
| 318 | } | 328 | } | ||
| 319 | 329 | ||||
| 320 | func (s *Service) cloneBaseRouteVolumes(ctx context.Context, baseRoute, previewDomain string) ([]string, error) { | 330 | func (s *Service) cloneBaseRouteVolumes(ctx context.Context, baseRoute, previewDomain string) ([]string, error) { | ||
| @@ -328,7 +338,8 @@ func (s *Service) cloneBaseRouteVolumes(ctx context.Context, baseRoute, previewD | |||||
| 328 | } | 338 | } | ||
| 329 | 339 | ||||
| 330 | log := zerowrap.FromCtx(ctx) | 340 | log := zerowrap.FromCtx(ctx) | ||
| 331 | - | basePrefix := prefix + "-" + strings.ReplaceAll(baseRoute, ".", "-") + "-" | 341 | + | basePrefix := prefix + "-" + domain.StableResourceName(baseRoute) + "-" |
| 342 | + | legacyBasePrefix := prefix + "-" + strings.ReplaceAll(baseRoute, ".", "-") + "-" | |||
| 332 | 343 | ||||
| 333 | vols, err := s.volumeCloner.ListVolumes(ctx) | 344 | vols, err := s.volumeCloner.ListVolumes(ctx) | ||
| 334 | if err != nil { | 345 | if err != nil { | ||
| @@ -337,7 +348,7 @@ func (s *Service) cloneBaseRouteVolumes(ctx context.Context, baseRoute, previewD | |||||
| 337 | 348 | ||||
| 338 | var sourceVols []string | 349 | var sourceVols []string | ||
| 339 | for _, v := range vols { | 350 | for _, v := range vols { | ||
| 340 | - | if strings.HasPrefix(v.Name, basePrefix) { | 351 | + | if strings.HasPrefix(v.Name, basePrefix) || strings.HasPrefix(v.Name, legacyBasePrefix) { |
| 341 | sourceVols = append(sourceVols, v.Name) | 352 | sourceVols = append(sourceVols, v.Name) | ||
| 342 | } | 353 | } | ||
| 343 | } | 354 | } | ||
| @@ -348,6 +359,9 @@ func (s *Service) cloneBaseRouteVolumes(ctx context.Context, baseRoute, previewD | |||||
| 348 | 359 | ||||
| 349 | namer := func(sourceVolName string) string { | 360 | namer := func(sourceVolName string) string { | ||
| 350 | pathSuffix := strings.TrimPrefix(sourceVolName, basePrefix) | 361 | pathSuffix := strings.TrimPrefix(sourceVolName, basePrefix) | ||
| 362 | + | if pathSuffix == sourceVolName { | |||
| 363 | + | pathSuffix = strings.TrimPrefix(sourceVolName, legacyBasePrefix) | |||
| 364 | + | } | |||
| 351 | return generateVolumeName(prefix, previewDomain, pathSuffix) | 365 | return generateVolumeName(prefix, previewDomain, pathSuffix) | ||
| 352 | } | 366 | } | ||
| 353 | 367 | ||||
| @@ -370,11 +384,11 @@ type CreatePreviewRequest struct { | |||||
| 370 | 384 | ||||
| 371 | // CreatePreview orchestrates the full preview creation: lock, clone volumes, start containers, register. | 385 | // CreatePreview orchestrates the full preview creation: lock, clone volumes, start containers, register. | ||
| 372 | func (s *Service) CreatePreview(ctx context.Context, req CreatePreviewRequest) error { | 386 | func (s *Service) CreatePreview(ctx context.Context, req CreatePreviewRequest) error { | ||
| 373 | - | lock := s.AcquireNameLock(req.Name) | 387 | + | lock := s.AcquireNameLock(req.Domain) |
| 374 | lock.Lock() | 388 | lock.Lock() | ||
| 375 | defer func() { | 389 | defer func() { | ||
| 376 | lock.Unlock() | 390 | lock.Unlock() | ||
| 377 | - | s.ReleaseNameLock(req.Name) | 391 | + | s.ReleaseNameLock(req.Domain) |
| 378 | }() | 392 | }() | ||
| 379 | 393 | ||||
| 380 | log := zerowrap.FromCtx(ctx) | 394 | log := zerowrap.FromCtx(ctx) | ||
M internal/usecase/container/service.go +75 -15
| @@ -211,7 +211,7 @@ func (s *Service) acquireDomainDeployLock(ctx context.Context, domain string) (f | |||
| 211 | 211 | ||
| 212 | 212 | // buildContainerConfig constructs the container configuration for deployment. | |
| 213 | 213 | func (s *Service) deploymentContainerName(containerDomain string, existing *domain.Container) string { | |
| 214 | - | canonicalName := fmt.Sprintf("gordon-%s", containerDomain) | |
| 214 | + | canonicalName := managedContainerName(containerDomain) | |
| 215 | 215 | if existing == nil { | |
| 216 | 216 | return canonicalName | |
| 217 | 217 | } | |
| @@ -517,7 +517,7 @@ func (s *Service) resolveExistingContainer(ctx context.Context, domainName strin | |||
| 517 | 517 | ||
| 518 | 518 | // Slow path: query runtime for running containers | |
| 519 | 519 | log := zerowrap.FromCtx(ctx) | |
| 520 | - | canonicalName := fmt.Sprintf("gordon-%s", domainName) | |
| 520 | + | canonicalName := managedContainerName(domainName) | |
| 521 | 521 | candidateNames := map[string]bool{ | |
| 522 | 522 | canonicalName: true, | |
| 523 | 523 | canonicalName + "-new": true, | |
| @@ -1333,7 +1333,7 @@ func isManagedRouteContainerForDomain(c *domain.Container, domainName string) bo | |||
| 1333 | 1333 | if c.Labels[domain.LabelAttachment] == "true" { | |
| 1334 | 1334 | return false | |
| 1335 | 1335 | } | |
| 1336 | - | return c.Labels[domain.LabelRoute] == domainName || c.Labels[domain.LabelDomain] == domainName || c.Name == fmt.Sprintf("gordon-%s", domainName) | |
| 1336 | + | return c.Labels[domain.LabelRoute] == domainName || c.Labels[domain.LabelDomain] == domainName || c.Name == managedContainerName(domainName) | |
| 1337 | 1337 | } | |
| 1338 | 1338 | ||
| 1339 | 1339 | func isPreservedAttachmentForDomain(c *domain.Container, domainName string) bool { | |
| @@ -1997,7 +1997,7 @@ func (s *Service) cleanupOldContainer(ctx context.Context, old *domain.Container | |||
| 1997 | 1997 | } | |
| 1998 | 1998 | ||
| 1999 | 1999 | // Rename new container to canonical name | |
| 2000 | - | canonicalName := fmt.Sprintf("gordon-%s", domainName) | |
| 2000 | + | canonicalName := managedContainerName(domainName) | |
| 2001 | 2001 | if err := s.runtime.RenameContainer(ctx, newContainerID, canonicalName); err != nil { | |
| 2002 | 2002 | log.Warn().Err(err).Str("canonical_name", canonicalName).Msg("failed to rename container to canonical name") | |
| 2003 | 2003 | } | |
| @@ -2431,14 +2431,10 @@ func (s *Service) pullImage(ctx context.Context, pullRef string, isInternal bool | |||
| 2431 | 2431 | }, isInternal); err != nil { | |
| 2432 | 2432 | return log.WrapErr(fmt.Errorf("%w: %w", domain.ErrImagePullFailed, err), "failed to pull image") | |
| 2433 | 2433 | } | |
| 2434 | - | case cfg.RegistryAuthEnabled: | |
| 2435 | - | if cfg.ServiceTokenUsername == "" || cfg.ServiceToken == "" { | |
| 2436 | - | return log.WrapErr(fmt.Errorf("registry service token not configured"), "failed to pull image for registry auth") | |
| 2437 | - | } | |
| 2438 | - | if err := s.runtime.PullImageWithAuth(ctx, pullRef, cfg.ServiceTokenUsername, cfg.ServiceToken); err != nil { | |
| 2439 | - | return log.WrapErr(fmt.Errorf("%w: %w", domain.ErrImagePullFailed, err), "failed to pull image with auth") | |
| 2440 | - | } | |
| 2441 | 2434 | default: | |
| 2435 | + | // Never send Gordon's service credentials to an external registry. | |
| 2436 | + | // External pulls are anonymous until per-registry credentials have an | |
| 2437 | + | // explicit trust-boundary-aware configuration path. | |
| 2442 | 2438 | if err := s.runtime.PullImage(ctx, pullRef); err != nil { | |
| 2443 | 2439 | return log.WrapErr(fmt.Errorf("%w: %w", domain.ErrImagePullFailed, err), "failed to pull image") | |
| 2444 | 2440 | } | |
| @@ -2537,6 +2533,21 @@ func (s *Service) setupVolumes(ctx context.Context, domainName, imageRef string) | |||
| 2537 | 2533 | ||
| 2538 | 2534 | for _, path := range volumePaths { | |
| 2539 | 2535 | name := generateVolumeName(cfg.VolumePrefix, domainName, path) | |
| 2536 | + | legacyName := legacyVolumeName(cfg.VolumePrefix, domainName, path) | |
| 2537 | + | legacyExists, err := s.runtime.VolumeExists(ctx, legacyName) | |
| 2538 | + | if err != nil { | |
| 2539 | + | log.WrapErrWithFields(err, "failed to check legacy volume", map[string]any{"volume": legacyName}) | |
| 2540 | + | continue | |
| 2541 | + | } | |
| 2542 | + | if legacyExists { | |
| 2543 | + | owned, ownershipErr := s.legacyVolumeOwnedByDomain(ctx, legacyName, domainName) | |
| 2544 | + | if ownershipErr != nil { | |
| 2545 | + | log.Warn().Err(ownershipErr).Str("volume", legacyName).Msg("could not verify legacy volume ownership; using stable volume name") | |
| 2546 | + | } else if owned { | |
| 2547 | + | volumes[path] = legacyName | |
| 2548 | + | continue | |
| 2549 | + | } | |
| 2550 | + | } | |
| 2540 | 2551 | ||
| 2541 | 2552 | exists, err := s.runtime.VolumeExists(ctx, name) | |
| 2542 | 2553 | if err != nil { | |
| @@ -2593,7 +2604,7 @@ func (s *Service) generateNetworkName(identifier string) string { | |||
| 2593 | 2604 | cfg := s.config | |
| 2594 | 2605 | s.mu.RUnlock() | |
| 2595 | 2606 | ||
| 2596 | - | return fmt.Sprintf("%s-%s", cfg.NetworkPrefix, strings.ReplaceAll(identifier, ".", "-")) | |
| 2607 | + | return fmt.Sprintf("%s-%s", cfg.NetworkPrefix, domain.StableResourceName(identifier)) | |
| 2597 | 2608 | } | |
| 2598 | 2609 | ||
| 2599 | 2610 | func (s *Service) createNetworkIfNeeded(ctx context.Context, networkName string) error { | |
| @@ -2613,7 +2624,11 @@ func (s *Service) createNetworkIfNeeded(ctx context.Context, networkName string) | |||
| 2613 | 2624 | s.mu.RLock() | |
| 2614 | 2625 | internal := s.config.NetworkInternal | |
| 2615 | 2626 | s.mu.RUnlock() | |
| 2616 | - | if err := s.runtime.CreateNetwork(ctx, networkName, domain.NetworkConfig{Driver: "bridge", Internal: internal}); err != nil { | |
| 2627 | + | if err := s.runtime.CreateNetwork(ctx, networkName, domain.NetworkConfig{ | |
| 2628 | + | Driver: "bridge", | |
| 2629 | + | Internal: internal, | |
| 2630 | + | Labels: map[string]string{domain.LabelManaged: "true"}, | |
| 2631 | + | }); err != nil { | |
| 2617 | 2632 | return log.WrapErr(err, "failed to create network") | |
| 2618 | 2633 | } | |
| 2619 | 2634 | log.Info().Msg("created network for app isolation") | |
| @@ -2624,7 +2639,7 @@ func (s *Service) createNetworkIfNeeded(ctx context.Context, networkName string) | |||
| 2624 | 2639 | ||
| 2625 | 2640 | func (s *Service) cleanupOrphanedContainers(ctx context.Context, domainName string, skipContainerID string) error { | |
| 2626 | 2641 | log := zerowrap.FromCtx(ctx) | |
| 2627 | - | expectedName := fmt.Sprintf("gordon-%s", domainName) | |
| 2642 | + | expectedName := managedContainerName(domainName) | |
| 2628 | 2643 | expectedNewName := expectedName + "-new" | |
| 2629 | 2644 | expectedNextName := expectedName + "-next" | |
| 2630 | 2645 | ||
| @@ -2681,7 +2696,7 @@ func (s *Service) cleanupNetworkIfEmpty(ctx context.Context, networkName string) | |||
| 2681 | 2696 | } | |
| 2682 | 2697 | ||
| 2683 | 2698 | for _, n := range networks { | |
| 2684 | - | if n.Name == networkName && len(n.Containers) == 0 { | |
| 2699 | + | if n.Name == networkName && len(n.Containers) == 0 && n.Labels[domain.LabelManaged] == "true" { | |
| 2685 | 2700 | if err := s.runtime.RemoveNetwork(ctx, networkName); err != nil { | |
| 2686 | 2701 | return err | |
| 2687 | 2702 | } | |
| @@ -3116,6 +3131,9 @@ func (s *Service) resolveExistingAttachment(ctx context.Context, ownerDomain, se | |||
| 3116 | 3131 | containerName := fmt.Sprintf("gordon-%s-%s", sanitizeName(ownerDomain), serviceName) | |
| 3117 | 3132 | existingContainer := s.findContainerByName(ctx, containerName) | |
| 3118 | 3133 | if existingContainer != nil { | |
| 3134 | + | if err := validateAttachmentOwnership(existingContainer, ownerDomain, containerName); err != nil { | |
| 3135 | + | return nil, err | |
| 3136 | + | } | |
| 3119 | 3137 | return existingContainer, nil | |
| 3120 | 3138 | } | |
| 3121 | 3139 | ||
| @@ -3125,6 +3143,9 @@ func (s *Service) resolveExistingAttachment(ctx context.Context, ownerDomain, se | |||
| 3125 | 3143 | return nil, nil | |
| 3126 | 3144 | } | |
| 3127 | 3145 | ||
| 3146 | + | if err := validateAttachmentOwnership(existingContainer, ownerDomain, containerNameLegacy); err != nil { | |
| 3147 | + | return nil, err | |
| 3148 | + | } | |
| 3128 | 3149 | log.Info().Str("container_name", containerNameLegacy).Msg("found attachment with legacy naming, will be replaced") | |
| 3129 | 3150 | if err := s.runtime.StopContainer(ctx, existingContainer.ID); err != nil { | |
| 3130 | 3151 | return nil, log.WrapErr(err, "failed to stop legacy attachment container") | |
| @@ -3136,8 +3157,21 @@ func (s *Service) resolveExistingAttachment(ctx context.Context, ownerDomain, se | |||
| 3136 | 3157 | return nil, nil | |
| 3137 | 3158 | } | |
| 3138 | 3159 | ||
| 3160 | + | func validateAttachmentOwnership(container *domain.Container, ownerDomain, containerName string) error { | |
| 3161 | + | if container.Labels[domain.LabelManaged] != "true" || | |
| 3162 | + | container.Labels[domain.LabelAttachment] != "true" || | |
| 3163 | + | container.Labels[domain.LabelAttachedTo] != ownerDomain { | |
| 3164 | + | return fmt.Errorf("%w: attachment name %q is owned by another workload", domain.ErrAttachmentOwnershipMismatch, containerName) | |
| 3165 | + | } | |
| 3166 | + | return nil | |
| 3167 | + | } | |
| 3168 | + | ||
| 3139 | 3169 | // Utility functions | |
| 3140 | 3170 | ||
| 3171 | + | func managedContainerName(domainName string) string { | |
| 3172 | + | return "gordon-" + domainName | |
| 3173 | + | } | |
| 3174 | + | ||
| 3141 | 3175 | func normalizeImageRef(image string) string { | |
| 3142 | 3176 | if isDigestRef(image) { | |
| 3143 | 3177 | return image | |
| @@ -3179,12 +3213,38 @@ func normalizeImageRef(image string) string { | |||
| 3179 | 3213 | } | |
| 3180 | 3214 | ||
| 3181 | 3215 | func generateVolumeName(prefix, domainName, volumePath string) string { | |
| 3216 | + | return fmt.Sprintf("%s-%s-%s", | |
| 3217 | + | prefix, | |
| 3218 | + | domain.StableResourceName(domainName), | |
| 3219 | + | domain.StableResourceName(strings.Trim(volumePath, "/"))) | |
| 3220 | + | } | |
| 3221 | + | ||
| 3222 | + | func legacyVolumeName(prefix, domainName, volumePath string) string { | |
| 3182 | 3223 | return fmt.Sprintf("%s-%s-%s", | |
| 3183 | 3224 | prefix, | |
| 3184 | 3225 | strings.ReplaceAll(domainName, ".", "-"), | |
| 3185 | 3226 | strings.ReplaceAll(strings.Trim(volumePath, "/"), "/", "-")) | |
| 3186 | 3227 | } | |
| 3187 | 3228 | ||
| 3229 | + | func (s *Service) legacyVolumeOwnedByDomain(ctx context.Context, volumeName, domainName string) (bool, error) { | |
| 3230 | + | containers, err := s.runtime.ListContainers(ctx, true) | |
| 3231 | + | if err != nil { | |
| 3232 | + | return false, fmt.Errorf("list containers for legacy volume ownership: %w", err) | |
| 3233 | + | } | |
| 3234 | + | for _, container := range containers { | |
| 3235 | + | if container.Labels[domain.LabelManaged] != "true" || | |
| 3236 | + | (container.Labels[domain.LabelRoute] != domainName && container.Labels[domain.LabelDomain] != domainName) { | |
| 3237 | + | continue | |
| 3238 | + | } | |
| 3239 | + | for _, mount := range container.VolumeMounts { | |
| 3240 | + | if mount.Name == volumeName || mount.Source == volumeName { | |
| 3241 | + | return true, nil | |
| 3242 | + | } | |
| 3243 | + | } | |
| 3244 | + | } | |
| 3245 | + | return false, nil | |
| 3246 | + | } | |
| 3247 | + | ||
| 3188 | 3248 | func mergeEnvironmentVariables(dockerfileEnv, userEnv []string) []string { | |
| 3189 | 3249 | envMap := make(map[string]string) | |
| 3190 | 3250 | ||
| @@ -211,7 +211,7 @@ func (s *Service) acquireDomainDeployLock(ctx context.Context, domain string) (f | |||||
| 211 | 211 | ||||
| 212 | // buildContainerConfig constructs the container configuration for deployment. | 212 | // buildContainerConfig constructs the container configuration for deployment. | ||
| 213 | func (s *Service) deploymentContainerName(containerDomain string, existing *domain.Container) string { | 213 | func (s *Service) deploymentContainerName(containerDomain string, existing *domain.Container) string { | ||
| 214 | - | canonicalName := fmt.Sprintf("gordon-%s", containerDomain) | 214 | + | canonicalName := managedContainerName(containerDomain) |
| 215 | if existing == nil { | 215 | if existing == nil { | ||
| 216 | return canonicalName | 216 | return canonicalName | ||
| 217 | } | 217 | } | ||
| @@ -517,7 +517,7 @@ func (s *Service) resolveExistingContainer(ctx context.Context, domainName strin | |||||
| 517 | 517 | ||||
| 518 | // Slow path: query runtime for running containers | 518 | // Slow path: query runtime for running containers | ||
| 519 | log := zerowrap.FromCtx(ctx) | 519 | log := zerowrap.FromCtx(ctx) | ||
| 520 | - | canonicalName := fmt.Sprintf("gordon-%s", domainName) | 520 | + | canonicalName := managedContainerName(domainName) |
| 521 | candidateNames := map[string]bool{ | 521 | candidateNames := map[string]bool{ | ||
| 522 | canonicalName: true, | 522 | canonicalName: true, | ||
| 523 | canonicalName + "-new": true, | 523 | canonicalName + "-new": true, | ||
| @@ -1333,7 +1333,7 @@ func isManagedRouteContainerForDomain(c *domain.Container, domainName string) bo | |||||
| 1333 | if c.Labels[domain.LabelAttachment] == "true" { | 1333 | if c.Labels[domain.LabelAttachment] == "true" { | ||
| 1334 | return false | 1334 | return false | ||
| 1335 | } | 1335 | } | ||
| 1336 | - | return c.Labels[domain.LabelRoute] == domainName || c.Labels[domain.LabelDomain] == domainName || c.Name == fmt.Sprintf("gordon-%s", domainName) | 1336 | + | return c.Labels[domain.LabelRoute] == domainName || c.Labels[domain.LabelDomain] == domainName || c.Name == managedContainerName(domainName) |
| 1337 | } | 1337 | } | ||
| 1338 | 1338 | ||||
| 1339 | func isPreservedAttachmentForDomain(c *domain.Container, domainName string) bool { | 1339 | func isPreservedAttachmentForDomain(c *domain.Container, domainName string) bool { | ||
| @@ -1997,7 +1997,7 @@ func (s *Service) cleanupOldContainer(ctx context.Context, old *domain.Container | |||||
| 1997 | } | 1997 | } | ||
| 1998 | 1998 | ||||
| 1999 | // Rename new container to canonical name | 1999 | // Rename new container to canonical name | ||
| 2000 | - | canonicalName := fmt.Sprintf("gordon-%s", domainName) | 2000 | + | canonicalName := managedContainerName(domainName) |
| 2001 | if err := s.runtime.RenameContainer(ctx, newContainerID, canonicalName); err != nil { | 2001 | if err := s.runtime.RenameContainer(ctx, newContainerID, canonicalName); err != nil { | ||
| 2002 | log.Warn().Err(err).Str("canonical_name", canonicalName).Msg("failed to rename container to canonical name") | 2002 | log.Warn().Err(err).Str("canonical_name", canonicalName).Msg("failed to rename container to canonical name") | ||
| 2003 | } | 2003 | } | ||
| @@ -2431,14 +2431,10 @@ func (s *Service) pullImage(ctx context.Context, pullRef string, isInternal bool | |||||
| 2431 | }, isInternal); err != nil { | 2431 | }, isInternal); err != nil { | ||
| 2432 | return log.WrapErr(fmt.Errorf("%w: %w", domain.ErrImagePullFailed, err), "failed to pull image") | 2432 | return log.WrapErr(fmt.Errorf("%w: %w", domain.ErrImagePullFailed, err), "failed to pull image") | ||
| 2433 | } | 2433 | } | ||
| 2434 | - | case cfg.RegistryAuthEnabled: | |||
| 2435 | - | if cfg.ServiceTokenUsername == "" || cfg.ServiceToken == "" { | |||
| 2436 | - | return log.WrapErr(fmt.Errorf("registry service token not configured"), "failed to pull image for registry auth") | |||
| 2437 | - | } | |||
| 2438 | - | if err := s.runtime.PullImageWithAuth(ctx, pullRef, cfg.ServiceTokenUsername, cfg.ServiceToken); err != nil { | |||
| 2439 | - | return log.WrapErr(fmt.Errorf("%w: %w", domain.ErrImagePullFailed, err), "failed to pull image with auth") | |||
| 2440 | - | } | |||
| 2441 | default: | 2434 | default: | ||
| 2435 | + | // Never send Gordon's service credentials to an external registry. | |||
| 2436 | + | // External pulls are anonymous until per-registry credentials have an | |||
| 2437 | + | // explicit trust-boundary-aware configuration path. | |||
| 2442 | if err := s.runtime.PullImage(ctx, pullRef); err != nil { | 2438 | if err := s.runtime.PullImage(ctx, pullRef); err != nil { | ||
| 2443 | return log.WrapErr(fmt.Errorf("%w: %w", domain.ErrImagePullFailed, err), "failed to pull image") | 2439 | return log.WrapErr(fmt.Errorf("%w: %w", domain.ErrImagePullFailed, err), "failed to pull image") | ||
| 2444 | } | 2440 | } | ||
| @@ -2537,6 +2533,21 @@ func (s *Service) setupVolumes(ctx context.Context, domainName, imageRef string) | |||||
| 2537 | 2533 | ||||
| 2538 | for _, path := range volumePaths { | 2534 | for _, path := range volumePaths { | ||
| 2539 | name := generateVolumeName(cfg.VolumePrefix, domainName, path) | 2535 | name := generateVolumeName(cfg.VolumePrefix, domainName, path) | ||
| 2536 | + | legacyName := legacyVolumeName(cfg.VolumePrefix, domainName, path) | |||
| 2537 | + | legacyExists, err := s.runtime.VolumeExists(ctx, legacyName) | |||
| 2538 | + | if err != nil { | |||
| 2539 | + | log.WrapErrWithFields(err, "failed to check legacy volume", map[string]any{"volume": legacyName}) | |||
| 2540 | + | continue | |||
| 2541 | + | } | |||
| 2542 | + | if legacyExists { | |||
| 2543 | + | owned, ownershipErr := s.legacyVolumeOwnedByDomain(ctx, legacyName, domainName) | |||
| 2544 | + | if ownershipErr != nil { | |||
| 2545 | + | log.Warn().Err(ownershipErr).Str("volume", legacyName).Msg("could not verify legacy volume ownership; using stable volume name") | |||
| 2546 | + | } else if owned { | |||
| 2547 | + | volumes[path] = legacyName | |||
| 2548 | + | continue | |||
| 2549 | + | } | |||
| 2550 | + | } | |||
| 2540 | 2551 | ||||
| 2541 | exists, err := s.runtime.VolumeExists(ctx, name) | 2552 | exists, err := s.runtime.VolumeExists(ctx, name) | ||
| 2542 | if err != nil { | 2553 | if err != nil { | ||
| @@ -2593,7 +2604,7 @@ func (s *Service) generateNetworkName(identifier string) string { | |||||
| 2593 | cfg := s.config | 2604 | cfg := s.config | ||
| 2594 | s.mu.RUnlock() | 2605 | s.mu.RUnlock() | ||
| 2595 | 2606 | ||||
| 2596 | - | return fmt.Sprintf("%s-%s", cfg.NetworkPrefix, strings.ReplaceAll(identifier, ".", "-")) | 2607 | + | return fmt.Sprintf("%s-%s", cfg.NetworkPrefix, domain.StableResourceName(identifier)) |
| 2597 | } | 2608 | } | ||
| 2598 | 2609 | ||||
| 2599 | func (s *Service) createNetworkIfNeeded(ctx context.Context, networkName string) error { | 2610 | func (s *Service) createNetworkIfNeeded(ctx context.Context, networkName string) error { | ||
| @@ -2613,7 +2624,11 @@ func (s *Service) createNetworkIfNeeded(ctx context.Context, networkName string) | |||||
| 2613 | s.mu.RLock() | 2624 | s.mu.RLock() | ||
| 2614 | internal := s.config.NetworkInternal | 2625 | internal := s.config.NetworkInternal | ||
| 2615 | s.mu.RUnlock() | 2626 | s.mu.RUnlock() | ||
| 2616 | - | if err := s.runtime.CreateNetwork(ctx, networkName, domain.NetworkConfig{Driver: "bridge", Internal: internal}); err != nil { | 2627 | + | if err := s.runtime.CreateNetwork(ctx, networkName, domain.NetworkConfig{ |
| 2628 | + | Driver: "bridge", | |||
| 2629 | + | Internal: internal, | |||
| 2630 | + | Labels: map[string]string{domain.LabelManaged: "true"}, | |||
| 2631 | + | }); err != nil { | |||
| 2617 | return log.WrapErr(err, "failed to create network") | 2632 | return log.WrapErr(err, "failed to create network") | ||
| 2618 | } | 2633 | } | ||
| 2619 | log.Info().Msg("created network for app isolation") | 2634 | log.Info().Msg("created network for app isolation") | ||
| @@ -2624,7 +2639,7 @@ func (s *Service) createNetworkIfNeeded(ctx context.Context, networkName string) | |||||
| 2624 | 2639 | ||||
| 2625 | func (s *Service) cleanupOrphanedContainers(ctx context.Context, domainName string, skipContainerID string) error { | 2640 | func (s *Service) cleanupOrphanedContainers(ctx context.Context, domainName string, skipContainerID string) error { | ||
| 2626 | log := zerowrap.FromCtx(ctx) | 2641 | log := zerowrap.FromCtx(ctx) | ||
| 2627 | - | expectedName := fmt.Sprintf("gordon-%s", domainName) | 2642 | + | expectedName := managedContainerName(domainName) |
| 2628 | expectedNewName := expectedName + "-new" | 2643 | expectedNewName := expectedName + "-new" | ||
| 2629 | expectedNextName := expectedName + "-next" | 2644 | expectedNextName := expectedName + "-next" | ||
| 2630 | 2645 | ||||
| @@ -2681,7 +2696,7 @@ func (s *Service) cleanupNetworkIfEmpty(ctx context.Context, networkName string) | |||||
| 2681 | } | 2696 | } | ||
| 2682 | 2697 | ||||
| 2683 | for _, n := range networks { | 2698 | for _, n := range networks { | ||
| 2684 | - | if n.Name == networkName && len(n.Containers) == 0 { | 2699 | + | if n.Name == networkName && len(n.Containers) == 0 && n.Labels[domain.LabelManaged] == "true" { |
| 2685 | if err := s.runtime.RemoveNetwork(ctx, networkName); err != nil { | 2700 | if err := s.runtime.RemoveNetwork(ctx, networkName); err != nil { | ||
| 2686 | return err | 2701 | return err | ||
| 2687 | } | 2702 | } | ||
| @@ -3116,6 +3131,9 @@ func (s *Service) resolveExistingAttachment(ctx context.Context, ownerDomain, se | |||||
| 3116 | containerName := fmt.Sprintf("gordon-%s-%s", sanitizeName(ownerDomain), serviceName) | 3131 | containerName := fmt.Sprintf("gordon-%s-%s", sanitizeName(ownerDomain), serviceName) | ||
| 3117 | existingContainer := s.findContainerByName(ctx, containerName) | 3132 | existingContainer := s.findContainerByName(ctx, containerName) | ||
| 3118 | if existingContainer != nil { | 3133 | if existingContainer != nil { | ||
| 3134 | + | if err := validateAttachmentOwnership(existingContainer, ownerDomain, containerName); err != nil { | |||
| 3135 | + | return nil, err | |||
| 3136 | + | } | |||
| 3119 | return existingContainer, nil | 3137 | return existingContainer, nil | ||
| 3120 | } | 3138 | } | ||
| 3121 | 3139 | ||||
| @@ -3125,6 +3143,9 @@ func (s *Service) resolveExistingAttachment(ctx context.Context, ownerDomain, se | |||||
| 3125 | return nil, nil | 3143 | return nil, nil | ||
| 3126 | } | 3144 | } | ||
| 3127 | 3145 | ||||
| 3146 | + | if err := validateAttachmentOwnership(existingContainer, ownerDomain, containerNameLegacy); err != nil { | |||
| 3147 | + | return nil, err | |||
| 3148 | + | } | |||
| 3128 | log.Info().Str("container_name", containerNameLegacy).Msg("found attachment with legacy naming, will be replaced") | 3149 | log.Info().Str("container_name", containerNameLegacy).Msg("found attachment with legacy naming, will be replaced") | ||
| 3129 | if err := s.runtime.StopContainer(ctx, existingContainer.ID); err != nil { | 3150 | if err := s.runtime.StopContainer(ctx, existingContainer.ID); err != nil { | ||
| 3130 | return nil, log.WrapErr(err, "failed to stop legacy attachment container") | 3151 | return nil, log.WrapErr(err, "failed to stop legacy attachment container") | ||
| @@ -3136,8 +3157,21 @@ func (s *Service) resolveExistingAttachment(ctx context.Context, ownerDomain, se | |||||
| 3136 | return nil, nil | 3157 | return nil, nil | ||
| 3137 | } | 3158 | } | ||
| 3138 | 3159 | ||||
| 3160 | + | func validateAttachmentOwnership(container *domain.Container, ownerDomain, containerName string) error { | |||
| 3161 | + | if container.Labels[domain.LabelManaged] != "true" || | |||
| 3162 | + | container.Labels[domain.LabelAttachment] != "true" || | |||
| 3163 | + | container.Labels[domain.LabelAttachedTo] != ownerDomain { | |||
| 3164 | + | return fmt.Errorf("%w: attachment name %q is owned by another workload", domain.ErrAttachmentOwnershipMismatch, containerName) | |||
| 3165 | + | } | |||
| 3166 | + | return nil | |||
| 3167 | + | } | |||
| 3168 | + | ||||
| 3139 | // Utility functions | 3169 | // Utility functions | ||
| 3140 | 3170 | ||||
| 3171 | + | func managedContainerName(domainName string) string { | |||
| 3172 | + | return "gordon-" + domainName | |||
| 3173 | + | } | |||
| 3174 | + | ||||
| 3141 | func normalizeImageRef(image string) string { | 3175 | func normalizeImageRef(image string) string { | ||
| 3142 | if isDigestRef(image) { | 3176 | if isDigestRef(image) { | ||
| 3143 | return image | 3177 | return image | ||
| @@ -3179,12 +3213,38 @@ func normalizeImageRef(image string) string { | |||||
| 3179 | } | 3213 | } | ||
| 3180 | 3214 | ||||
| 3181 | func generateVolumeName(prefix, domainName, volumePath string) string { | 3215 | func generateVolumeName(prefix, domainName, volumePath string) string { | ||
| 3216 | + | return fmt.Sprintf("%s-%s-%s", | |||
| 3217 | + | prefix, | |||
| 3218 | + | domain.StableResourceName(domainName), | |||
| 3219 | + | domain.StableResourceName(strings.Trim(volumePath, "/"))) | |||
| 3220 | + | } | |||
| 3221 | + | ||||
| 3222 | + | func legacyVolumeName(prefix, domainName, volumePath string) string { | |||
| 3182 | return fmt.Sprintf("%s-%s-%s", | 3223 | return fmt.Sprintf("%s-%s-%s", | ||
| 3183 | prefix, | 3224 | prefix, | ||
| 3184 | strings.ReplaceAll(domainName, ".", "-"), | 3225 | strings.ReplaceAll(domainName, ".", "-"), | ||
| 3185 | strings.ReplaceAll(strings.Trim(volumePath, "/"), "/", "-")) | 3226 | strings.ReplaceAll(strings.Trim(volumePath, "/"), "/", "-")) | ||
| 3186 | } | 3227 | } | ||
| 3187 | 3228 | ||||
| 3229 | + | func (s *Service) legacyVolumeOwnedByDomain(ctx context.Context, volumeName, domainName string) (bool, error) { | |||
| 3230 | + | containers, err := s.runtime.ListContainers(ctx, true) | |||
| 3231 | + | if err != nil { | |||
| 3232 | + | return false, fmt.Errorf("list containers for legacy volume ownership: %w", err) | |||
| 3233 | + | } | |||
| 3234 | + | for _, container := range containers { | |||
| 3235 | + | if container.Labels[domain.LabelManaged] != "true" || | |||
| 3236 | + | (container.Labels[domain.LabelRoute] != domainName && container.Labels[domain.LabelDomain] != domainName) { | |||
| 3237 | + | continue | |||
| 3238 | + | } | |||
| 3239 | + | for _, mount := range container.VolumeMounts { | |||
| 3240 | + | if mount.Name == volumeName || mount.Source == volumeName { | |||
| 3241 | + | return true, nil | |||
| 3242 | + | } | |||
| 3243 | + | } | |||
| 3244 | + | } | |||
| 3245 | + | return false, nil | |||
| 3246 | + | } | |||
| 3247 | + | ||||
| 3188 | func mergeEnvironmentVariables(dockerfileEnv, userEnv []string) []string { | 3248 | func mergeEnvironmentVariables(dockerfileEnv, userEnv []string) []string { | ||
| 3189 | envMap := make(map[string]string) | 3249 | envMap := make(map[string]string) | ||
| 3190 | 3250 | ||||
M internal/usecase/container/service_test.go +82 -36
| @@ -200,16 +200,15 @@ func TestService_GetNetworkForApp_UsesLiveConfigProvider(t *testing.T) { | |||
| 200 | 200 | nil, Config{NetworkIsolation: true, NetworkPrefix: "gordon"}, provider, | |
| 201 | 201 | ) | |
| 202 | 202 | ||
| 203 | - | // Should use group network: generateNetworkName produces "gordon-shared-group" | |
| 203 | + | // Group and per-domain networks use collision-resistant names. | |
| 204 | 204 | network := svc.getNetworkForApp("app1.example.com") | |
| 205 | - | assert.Equal(t, "gordon-shared-group", network) | |
| 205 | + | assert.Equal(t, svc.generateNetworkName("shared-group"), network) | |
| 206 | 206 | ||
| 207 | 207 | network = svc.getNetworkForApp("app3.example.com") | |
| 208 | - | assert.Equal(t, "gordon-shared-group", network) | |
| 208 | + | assert.Equal(t, svc.generateNetworkName("shared-group"), network) | |
| 209 | 209 | ||
| 210 | - | // Domain not in any group gets its own network: "gordon-solo-example-com" | |
| 211 | 210 | network = svc.getNetworkForApp("solo.example.com") | |
| 212 | - | assert.Equal(t, "gordon-solo-example-com", network) | |
| 211 | + | assert.Equal(t, svc.generateNetworkName("solo.example.com"), network) | |
| 213 | 212 | } | |
| 214 | 213 | ||
| 215 | 214 | func TestService_ManagedContainersMetric_GlobalSeriesOnDeployReplaceAndRemove(t *testing.T) { | |
| @@ -704,7 +703,7 @@ func TestService_Deploy_StrictHealthModeWithoutHealthcheckReturnsHint(t *testing | |||
| 704 | 703 | assert.ErrorContains(t, deployErr.Err, "no healthcheck detected") | |
| 705 | 704 | } | |
| 706 | 705 | ||
| 707 | - | func TestService_PullImage_UsesServiceTokenForExternalPull(t *testing.T) { | |
| 706 | + | func TestService_PullImage_DoesNotSendServiceTokenToExternalRegistry(t *testing.T) { | |
| 708 | 707 | runtime := mocks.NewMockContainerRuntime(t) | |
| 709 | 708 | ||
| 710 | 709 | config := Config{ | |
| @@ -717,30 +716,13 @@ func TestService_PullImage_UsesServiceTokenForExternalPull(t *testing.T) { | |||
| 717 | 716 | svc := NewService(runtime, nil, nil, nil, config, nil) | |
| 718 | 717 | ctx := testContext() | |
| 719 | 718 | ||
| 720 | - | runtime.EXPECT().PullImageWithAuth(mock.Anything, "registry.example.com/myapp:latest", "gordon-service", "service-token").Return(nil) | |
| 719 | + | runtime.EXPECT().PullImage(mock.Anything, "registry.example.com/myapp:latest").Return(nil) | |
| 721 | 720 | ||
| 722 | 721 | err := svc.pullImage(ctx, "registry.example.com/myapp:latest", false) | |
| 723 | 722 | ||
| 724 | 723 | assert.NoError(t, err) | |
| 725 | 724 | } | |
| 726 | 725 | ||
| 727 | - | func TestService_PullImage_RequiresServiceTokenForExternalPull(t *testing.T) { | |
| 728 | - | runtime := mocks.NewMockContainerRuntime(t) | |
| 729 | - | ||
| 730 | - | config := Config{ | |
| 731 | - | AllowedRegistries: []string{"docker.io"}, | |
| 732 | - | RegistryAuthEnabled: true, | |
| 733 | - | } | |
| 734 | - | ||
| 735 | - | svc := NewService(runtime, nil, nil, nil, config, nil) | |
| 736 | - | ctx := testContext() | |
| 737 | - | ||
| 738 | - | err := svc.pullImage(ctx, "registry.example.com/myapp:latest", false) | |
| 739 | - | ||
| 740 | - | assert.Error(t, err) | |
| 741 | - | assert.Contains(t, err.Error(), "registry service token not configured") | |
| 742 | - | } | |
| 743 | - | ||
| 744 | 726 | func TestService_PullImage_InternalRetriesOnConnectionRefused(t *testing.T) { | |
| 745 | 727 | runtime := mocks.NewMockContainerRuntime(t) | |
| 746 | 728 | ||
| @@ -1041,13 +1023,17 @@ func TestService_Deploy_WithNetworkIsolation(t *testing.T) { | |||
| 1041 | 1023 | envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil) | |
| 1042 | 1024 | runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:latest").Return([]string{}, nil) | |
| 1043 | 1025 | ||
| 1044 | - | // Network isolation - should check and create network | |
| 1045 | - | runtime.EXPECT().NetworkExists(mock.Anything, "gordon-test-example-com").Return(false, nil) | |
| 1046 | - | runtime.EXPECT().CreateNetwork(mock.Anything, "gordon-test-example-com", domain.NetworkConfig{Driver: "bridge"}).Return(nil) | |
| 1026 | + | // Network isolation - should check and create an owned network. | |
| 1027 | + | networkName := svc.generateNetworkName("test.example.com") | |
| 1028 | + | runtime.EXPECT().NetworkExists(mock.Anything, networkName).Return(false, nil) | |
| 1029 | + | runtime.EXPECT().CreateNetwork(mock.Anything, networkName, domain.NetworkConfig{ | |
| 1030 | + | Driver: "bridge", | |
| 1031 | + | Labels: map[string]string{domain.LabelManaged: "true"}, | |
| 1032 | + | }).Return(nil) | |
| 1047 | 1033 | ||
| 1048 | 1034 | container := &domain.Container{ID: "container-123", Status: "created"} | |
| 1049 | 1035 | runtime.EXPECT().CreateContainer(mock.Anything, mock.MatchedBy(func(cfg *domain.ContainerConfig) bool { | |
| 1050 | - | return cfg.NetworkMode == "gordon-test-example-com" | |
| 1036 | + | return cfg.NetworkMode == networkName | |
| 1051 | 1037 | })).Return(container, nil) | |
| 1052 | 1038 | runtime.EXPECT().StartContainer(mock.Anything, "container-123").Return(nil) | |
| 1053 | 1039 | ||
| @@ -1100,10 +1086,14 @@ func TestService_Deploy_WithVolumeAutoCreate(t *testing.T) { | |||
| 1100 | 1086 | runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:latest").Return([]string{}, nil) | |
| 1101 | 1087 | ||
| 1102 | 1088 | // Volume operations | |
| 1089 | + | dataVolume := generateVolumeName("gordon", "test.example.com", "/data") | |
| 1090 | + | configVolume := generateVolumeName("gordon", "test.example.com", "/config") | |
| 1103 | 1091 | runtime.EXPECT().InspectImageVolumes(mock.Anything, "myapp:latest").Return([]string{"/data", "/config"}, nil) | |
| 1104 | - | runtime.EXPECT().VolumeExists(mock.Anything, "gordon-test-example-com-data").Return(false, nil) | |
| 1105 | - | runtime.EXPECT().CreateVolume(mock.Anything, "gordon-test-example-com-data").Return(nil) | |
| 1106 | - | runtime.EXPECT().VolumeExists(mock.Anything, "gordon-test-example-com-config").Return(true, nil) | |
| 1092 | + | runtime.EXPECT().VolumeExists(mock.Anything, legacyVolumeName("gordon", "test.example.com", "/data")).Return(false, nil) | |
| 1093 | + | runtime.EXPECT().VolumeExists(mock.Anything, dataVolume).Return(false, nil) | |
| 1094 | + | runtime.EXPECT().CreateVolume(mock.Anything, dataVolume).Return(nil) | |
| 1095 | + | runtime.EXPECT().VolumeExists(mock.Anything, legacyVolumeName("gordon", "test.example.com", "/config")).Return(false, nil) | |
| 1096 | + | runtime.EXPECT().VolumeExists(mock.Anything, configVolume).Return(true, nil) | |
| 1107 | 1097 | ||
| 1108 | 1098 | container := &domain.Container{ID: "container-123", Status: "created"} | |
| 1109 | 1099 | runtime.EXPECT().CreateContainer(mock.Anything, mock.MatchedBy(func(cfg *domain.ContainerConfig) bool { | |
| @@ -1203,10 +1193,11 @@ func TestService_Remove_WithNetworkCleanup(t *testing.T) { | |||
| 1203 | 1193 | } | |
| 1204 | 1194 | ||
| 1205 | 1195 | runtime.EXPECT().RemoveContainer(mock.Anything, "container-123", true).Return(nil) | |
| 1196 | + | networkName := svc.generateNetworkName("test.example.com") | |
| 1206 | 1197 | runtime.EXPECT().ListNetworks(mock.Anything).Return([]*domain.NetworkInfo{ | |
| 1207 | - | {Name: "gordon-test-example-com", Containers: []string{}}, | |
| 1198 | + | {Name: networkName, Containers: []string{}, Labels: map[string]string{domain.LabelManaged: "true"}}, | |
| 1208 | 1199 | }, nil) | |
| 1209 | - | runtime.EXPECT().RemoveNetwork(mock.Anything, "gordon-test-example-com").Return(nil) | |
| 1200 | + | runtime.EXPECT().RemoveNetwork(mock.Anything, networkName).Return(nil) | |
| 1210 | 1201 | ||
| 1211 | 1202 | err := svc.Remove(ctx, "container-123", true) | |
| 1212 | 1203 | ||
| @@ -1777,14 +1768,14 @@ func TestGenerateVolumeName(t *testing.T) { | |||
| 1777 | 1768 | prefix: "gordon", | |
| 1778 | 1769 | domain: "app.example.com", | |
| 1779 | 1770 | volumePath: "/data", | |
| 1780 | - | expected: "gordon-app-example-com-data", | |
| 1771 | + | expected: "gordon-app__example__com-28059829b105-data-3a6eb0790f39", | |
| 1781 | 1772 | }, | |
| 1782 | 1773 | { | |
| 1783 | 1774 | name: "nested path", | |
| 1784 | 1775 | prefix: "gordon", | |
| 1785 | 1776 | domain: "app.example.com", | |
| 1786 | 1777 | volumePath: "/var/lib/data", | |
| 1787 | - | expected: "gordon-app-example-com-var-lib-data", | |
| 1778 | + | expected: "gordon-app__example__com-28059829b105-var--lib--data-323f0a44c820", | |
| 1788 | 1779 | }, | |
| 1789 | 1780 | } | |
| 1790 | 1781 | ||
| @@ -1796,6 +1787,57 @@ func TestGenerateVolumeName(t *testing.T) { | |||
| 1796 | 1787 | } | |
| 1797 | 1788 | } | |
| 1798 | 1789 | ||
| 1790 | + | func TestService_SetupVolumes_ReusesLegacyVolume(t *testing.T) { | |
| 1791 | + | runtime := mocks.NewMockContainerRuntime(t) | |
| 1792 | + | svc := NewService(runtime, nil, nil, nil, Config{VolumeAutoCreate: true, VolumePrefix: "gordon"}, nil) | |
| 1793 | + | legacyName := legacyVolumeName("gordon", "app.example.com", "/data") | |
| 1794 | + | ||
| 1795 | + | runtime.EXPECT().InspectImageVolumes(mock.Anything, "myapp:latest").Return([]string{"/data"}, nil) | |
| 1796 | + | runtime.EXPECT().VolumeExists(mock.Anything, legacyName).Return(true, nil) | |
| 1797 | + | runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{{ | |
| 1798 | + | Labels: map[string]string{domain.LabelManaged: "true", domain.LabelRoute: "app.example.com"}, | |
| 1799 | + | VolumeMounts: []domain.ContainerVolumeMount{{Name: legacyName}}, | |
| 1800 | + | }}, nil) | |
| 1801 | + | ||
| 1802 | + | volumes, err := svc.setupVolumes(testContext(), "app.example.com", "myapp:latest") | |
| 1803 | + | ||
| 1804 | + | require.NoError(t, err) | |
| 1805 | + | assert.Equal(t, map[string]string{"/data": legacyName}, volumes) | |
| 1806 | + | } | |
| 1807 | + | ||
| 1808 | + | func TestService_SetupVolumes_DoesNotReuseCollidingLegacyVolume(t *testing.T) { | |
| 1809 | + | runtime := mocks.NewMockContainerRuntime(t) | |
| 1810 | + | svc := NewService(runtime, nil, nil, nil, Config{VolumeAutoCreate: true, VolumePrefix: "gordon"}, nil) | |
| 1811 | + | legacyName := legacyVolumeName("gordon", "app.example.com", "/data") | |
| 1812 | + | stableName := generateVolumeName("gordon", "app.example.com", "/data") | |
| 1813 | + | require.Equal(t, legacyName, legacyVolumeName("gordon", "app-example.com", "/data")) | |
| 1814 | + | ||
| 1815 | + | runtime.EXPECT().InspectImageVolumes(mock.Anything, "myapp:latest").Return([]string{"/data"}, nil) | |
| 1816 | + | runtime.EXPECT().VolumeExists(mock.Anything, legacyName).Return(true, nil) | |
| 1817 | + | runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{{ | |
| 1818 | + | Labels: map[string]string{domain.LabelManaged: "true", domain.LabelRoute: "app-example.com"}, | |
| 1819 | + | VolumeMounts: []domain.ContainerVolumeMount{{Name: legacyName}}, | |
| 1820 | + | }}, nil) | |
| 1821 | + | runtime.EXPECT().VolumeExists(mock.Anything, stableName).Return(true, nil) | |
| 1822 | + | ||
| 1823 | + | volumes, err := svc.setupVolumes(testContext(), "app.example.com", "myapp:latest") | |
| 1824 | + | ||
| 1825 | + | require.NoError(t, err) | |
| 1826 | + | assert.Equal(t, map[string]string{"/data": stableName}, volumes) | |
| 1827 | + | } | |
| 1828 | + | ||
| 1829 | + | func TestValidateAttachmentOwnership(t *testing.T) { | |
| 1830 | + | owned := &domain.Container{Labels: map[string]string{ | |
| 1831 | + | domain.LabelManaged: "true", domain.LabelAttachment: "true", domain.LabelAttachedTo: "app.example.com", | |
| 1832 | + | }} | |
| 1833 | + | require.NoError(t, validateAttachmentOwnership(owned, "app.example.com", "gordon-app-postgres")) | |
| 1834 | + | ||
| 1835 | + | foreign := &domain.Container{Labels: map[string]string{ | |
| 1836 | + | domain.LabelManaged: "true", domain.LabelAttachment: "true", domain.LabelAttachedTo: "other.example.com", | |
| 1837 | + | }} | |
| 1838 | + | assert.ErrorIs(t, validateAttachmentOwnership(foreign, "app.example.com", "gordon-app-postgres"), domain.ErrAttachmentOwnershipMismatch) | |
| 1839 | + | } | |
| 1840 | + | ||
| 1799 | 1841 | func TestMergeEnvironmentVariables(t *testing.T) { | |
| 1800 | 1842 | dockerEnv := []string{"FOO=docker", "BAR=docker"} | |
| 1801 | 1843 | userEnv := []string{"FOO=user", "BAZ=user"} | |
| @@ -4480,7 +4522,11 @@ func TestService_CreateNetworkIfNeeded_UsesInternalOptionWhenConfigured(t *testi | |||
| 4480 | 4522 | svc := NewService(runtime, nil, nil, nil, Config{NetworkInternal: true}, nil) | |
| 4481 | 4523 | ||
| 4482 | 4524 | runtime.EXPECT().NetworkExists(mock.Anything, "gordon-app").Return(false, nil) | |
| 4483 | - | runtime.EXPECT().CreateNetwork(mock.Anything, "gordon-app", domain.NetworkConfig{Driver: "bridge", Internal: true}).Return(nil) | |
| 4525 | + | runtime.EXPECT().CreateNetwork(mock.Anything, "gordon-app", domain.NetworkConfig{ | |
| 4526 | + | Driver: "bridge", | |
| 4527 | + | Internal: true, | |
| 4528 | + | Labels: map[string]string{domain.LabelManaged: "true"}, | |
| 4529 | + | }).Return(nil) | |
| 4484 | 4530 | ||
| 4485 | 4531 | require.NoError(t, svc.createNetworkIfNeeded(testContext(), "gordon-app")) | |
| 4486 | 4532 | } | |
| @@ -200,16 +200,15 @@ func TestService_GetNetworkForApp_UsesLiveConfigProvider(t *testing.T) { | |||||
| 200 | nil, Config{NetworkIsolation: true, NetworkPrefix: "gordon"}, provider, | 200 | nil, Config{NetworkIsolation: true, NetworkPrefix: "gordon"}, provider, | ||
| 201 | ) | 201 | ) | ||
| 202 | 202 | ||||
| 203 | - | // Should use group network: generateNetworkName produces "gordon-shared-group" | 203 | + | // Group and per-domain networks use collision-resistant names. |
| 204 | network := svc.getNetworkForApp("app1.example.com") | 204 | network := svc.getNetworkForApp("app1.example.com") | ||
| 205 | - | assert.Equal(t, "gordon-shared-group", network) | 205 | + | assert.Equal(t, svc.generateNetworkName("shared-group"), network) |
| 206 | 206 | ||||
| 207 | network = svc.getNetworkForApp("app3.example.com") | 207 | network = svc.getNetworkForApp("app3.example.com") | ||
| 208 | - | assert.Equal(t, "gordon-shared-group", network) | 208 | + | assert.Equal(t, svc.generateNetworkName("shared-group"), network) |
| 209 | 209 | ||||
| 210 | - | // Domain not in any group gets its own network: "gordon-solo-example-com" | |||
| 211 | network = svc.getNetworkForApp("solo.example.com") | 210 | network = svc.getNetworkForApp("solo.example.com") | ||
| 212 | - | assert.Equal(t, "gordon-solo-example-com", network) | 211 | + | assert.Equal(t, svc.generateNetworkName("solo.example.com"), network) |
| 213 | } | 212 | } | ||
| 214 | 213 | ||||
| 215 | func TestService_ManagedContainersMetric_GlobalSeriesOnDeployReplaceAndRemove(t *testing.T) { | 214 | func TestService_ManagedContainersMetric_GlobalSeriesOnDeployReplaceAndRemove(t *testing.T) { | ||
| @@ -704,7 +703,7 @@ func TestService_Deploy_StrictHealthModeWithoutHealthcheckReturnsHint(t *testing | |||||
| 704 | assert.ErrorContains(t, deployErr.Err, "no healthcheck detected") | 703 | assert.ErrorContains(t, deployErr.Err, "no healthcheck detected") | ||
| 705 | } | 704 | } | ||
| 706 | 705 | ||||
| 707 | - | func TestService_PullImage_UsesServiceTokenForExternalPull(t *testing.T) { | 706 | + | func TestService_PullImage_DoesNotSendServiceTokenToExternalRegistry(t *testing.T) { |
| 708 | runtime := mocks.NewMockContainerRuntime(t) | 707 | runtime := mocks.NewMockContainerRuntime(t) | ||
| 709 | 708 | ||||
| 710 | config := Config{ | 709 | config := Config{ | ||
| @@ -717,30 +716,13 @@ func TestService_PullImage_UsesServiceTokenForExternalPull(t *testing.T) { | |||||
| 717 | svc := NewService(runtime, nil, nil, nil, config, nil) | 716 | svc := NewService(runtime, nil, nil, nil, config, nil) | ||
| 718 | ctx := testContext() | 717 | ctx := testContext() | ||
| 719 | 718 | ||||
| 720 | - | runtime.EXPECT().PullImageWithAuth(mock.Anything, "registry.example.com/myapp:latest", "gordon-service", "service-token").Return(nil) | 719 | + | runtime.EXPECT().PullImage(mock.Anything, "registry.example.com/myapp:latest").Return(nil) |
| 721 | 720 | ||||
| 722 | err := svc.pullImage(ctx, "registry.example.com/myapp:latest", false) | 721 | err := svc.pullImage(ctx, "registry.example.com/myapp:latest", false) | ||
| 723 | 722 | ||||
| 724 | assert.NoError(t, err) | 723 | assert.NoError(t, err) | ||
| 725 | } | 724 | } | ||
| 726 | 725 | ||||
| 727 | - | func TestService_PullImage_RequiresServiceTokenForExternalPull(t *testing.T) { | |||
| 728 | - | runtime := mocks.NewMockContainerRuntime(t) | |||
| 729 | - | ||||
| 730 | - | config := Config{ | |||
| 731 | - | AllowedRegistries: []string{"docker.io"}, | |||
| 732 | - | RegistryAuthEnabled: true, | |||
| 733 | - | } | |||
| 734 | - | ||||
| 735 | - | svc := NewService(runtime, nil, nil, nil, config, nil) | |||
| 736 | - | ctx := testContext() | |||
| 737 | - | ||||
| 738 | - | err := svc.pullImage(ctx, "registry.example.com/myapp:latest", false) | |||
| 739 | - | ||||
| 740 | - | assert.Error(t, err) | |||
| 741 | - | assert.Contains(t, err.Error(), "registry service token not configured") | |||
| 742 | - | } | |||
| 743 | - | ||||
| 744 | func TestService_PullImage_InternalRetriesOnConnectionRefused(t *testing.T) { | 726 | func TestService_PullImage_InternalRetriesOnConnectionRefused(t *testing.T) { | ||
| 745 | runtime := mocks.NewMockContainerRuntime(t) | 727 | runtime := mocks.NewMockContainerRuntime(t) | ||
| 746 | 728 | ||||
| @@ -1041,13 +1023,17 @@ func TestService_Deploy_WithNetworkIsolation(t *testing.T) { | |||||
| 1041 | envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil) | 1023 | envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil) | ||
| 1042 | runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:latest").Return([]string{}, nil) | 1024 | runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:latest").Return([]string{}, nil) | ||
| 1043 | 1025 | ||||
| 1044 | - | // Network isolation - should check and create network | 1026 | + | // Network isolation - should check and create an owned network. |
| 1045 | - | runtime.EXPECT().NetworkExists(mock.Anything, "gordon-test-example-com").Return(false, nil) | 1027 | + | networkName := svc.generateNetworkName("test.example.com") |
| 1046 | - | runtime.EXPECT().CreateNetwork(mock.Anything, "gordon-test-example-com", domain.NetworkConfig{Driver: "bridge"}).Return(nil) | 1028 | + | runtime.EXPECT().NetworkExists(mock.Anything, networkName).Return(false, nil) |
| 1029 | + | runtime.EXPECT().CreateNetwork(mock.Anything, networkName, domain.NetworkConfig{ | |||
| 1030 | + | Driver: "bridge", | |||
| 1031 | + | Labels: map[string]string{domain.LabelManaged: "true"}, | |||
| 1032 | + | }).Return(nil) | |||
| 1047 | 1033 | ||||
| 1048 | container := &domain.Container{ID: "container-123", Status: "created"} | 1034 | container := &domain.Container{ID: "container-123", Status: "created"} | ||
| 1049 | runtime.EXPECT().CreateContainer(mock.Anything, mock.MatchedBy(func(cfg *domain.ContainerConfig) bool { | 1035 | runtime.EXPECT().CreateContainer(mock.Anything, mock.MatchedBy(func(cfg *domain.ContainerConfig) bool { | ||
| 1050 | - | return cfg.NetworkMode == "gordon-test-example-com" | 1036 | + | return cfg.NetworkMode == networkName |
| 1051 | })).Return(container, nil) | 1037 | })).Return(container, nil) | ||
| 1052 | runtime.EXPECT().StartContainer(mock.Anything, "container-123").Return(nil) | 1038 | runtime.EXPECT().StartContainer(mock.Anything, "container-123").Return(nil) | ||
| 1053 | 1039 | ||||
| @@ -1100,10 +1086,14 @@ func TestService_Deploy_WithVolumeAutoCreate(t *testing.T) { | |||||
| 1100 | runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:latest").Return([]string{}, nil) | 1086 | runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:latest").Return([]string{}, nil) | ||
| 1101 | 1087 | ||||
| 1102 | // Volume operations | 1088 | // Volume operations | ||
| 1089 | + | dataVolume := generateVolumeName("gordon", "test.example.com", "/data") | |||
| 1090 | + | configVolume := generateVolumeName("gordon", "test.example.com", "/config") | |||
| 1103 | runtime.EXPECT().InspectImageVolumes(mock.Anything, "myapp:latest").Return([]string{"/data", "/config"}, nil) | 1091 | runtime.EXPECT().InspectImageVolumes(mock.Anything, "myapp:latest").Return([]string{"/data", "/config"}, nil) | ||
| 1104 | - | runtime.EXPECT().VolumeExists(mock.Anything, "gordon-test-example-com-data").Return(false, nil) | 1092 | + | runtime.EXPECT().VolumeExists(mock.Anything, legacyVolumeName("gordon", "test.example.com", "/data")).Return(false, nil) |
| 1105 | - | runtime.EXPECT().CreateVolume(mock.Anything, "gordon-test-example-com-data").Return(nil) | 1093 | + | runtime.EXPECT().VolumeExists(mock.Anything, dataVolume).Return(false, nil) |
| 1106 | - | runtime.EXPECT().VolumeExists(mock.Anything, "gordon-test-example-com-config").Return(true, nil) | 1094 | + | runtime.EXPECT().CreateVolume(mock.Anything, dataVolume).Return(nil) |
| 1095 | + | runtime.EXPECT().VolumeExists(mock.Anything, legacyVolumeName("gordon", "test.example.com", "/config")).Return(false, nil) | |||
| 1096 | + | runtime.EXPECT().VolumeExists(mock.Anything, configVolume).Return(true, nil) | |||
| 1107 | 1097 | ||||
| 1108 | container := &domain.Container{ID: "container-123", Status: "created"} | 1098 | container := &domain.Container{ID: "container-123", Status: "created"} | ||
| 1109 | runtime.EXPECT().CreateContainer(mock.Anything, mock.MatchedBy(func(cfg *domain.ContainerConfig) bool { | 1099 | runtime.EXPECT().CreateContainer(mock.Anything, mock.MatchedBy(func(cfg *domain.ContainerConfig) bool { | ||
| @@ -1203,10 +1193,11 @@ func TestService_Remove_WithNetworkCleanup(t *testing.T) { | |||||
| 1203 | } | 1193 | } | ||
| 1204 | 1194 | ||||
| 1205 | runtime.EXPECT().RemoveContainer(mock.Anything, "container-123", true).Return(nil) | 1195 | runtime.EXPECT().RemoveContainer(mock.Anything, "container-123", true).Return(nil) | ||
| 1196 | + | networkName := svc.generateNetworkName("test.example.com") | |||
| 1206 | runtime.EXPECT().ListNetworks(mock.Anything).Return([]*domain.NetworkInfo{ | 1197 | runtime.EXPECT().ListNetworks(mock.Anything).Return([]*domain.NetworkInfo{ | ||
| 1207 | - | {Name: "gordon-test-example-com", Containers: []string{}}, | 1198 | + | {Name: networkName, Containers: []string{}, Labels: map[string]string{domain.LabelManaged: "true"}}, |
| 1208 | }, nil) | 1199 | }, nil) | ||
| 1209 | - | runtime.EXPECT().RemoveNetwork(mock.Anything, "gordon-test-example-com").Return(nil) | 1200 | + | runtime.EXPECT().RemoveNetwork(mock.Anything, networkName).Return(nil) |
| 1210 | 1201 | ||||
| 1211 | err := svc.Remove(ctx, "container-123", true) | 1202 | err := svc.Remove(ctx, "container-123", true) | ||
| 1212 | 1203 | ||||
| @@ -1777,14 +1768,14 @@ func TestGenerateVolumeName(t *testing.T) { | |||||
| 1777 | prefix: "gordon", | 1768 | prefix: "gordon", | ||
| 1778 | domain: "app.example.com", | 1769 | domain: "app.example.com", | ||
| 1779 | volumePath: "/data", | 1770 | volumePath: "/data", | ||
| 1780 | - | expected: "gordon-app-example-com-data", | 1771 | + | expected: "gordon-app__example__com-28059829b105-data-3a6eb0790f39", |
| 1781 | }, | 1772 | }, | ||
| 1782 | { | 1773 | { | ||
| 1783 | name: "nested path", | 1774 | name: "nested path", | ||
| 1784 | prefix: "gordon", | 1775 | prefix: "gordon", | ||
| 1785 | domain: "app.example.com", | 1776 | domain: "app.example.com", | ||
| 1786 | volumePath: "/var/lib/data", | 1777 | volumePath: "/var/lib/data", | ||
| 1787 | - | expected: "gordon-app-example-com-var-lib-data", | 1778 | + | expected: "gordon-app__example__com-28059829b105-var--lib--data-323f0a44c820", |
| 1788 | }, | 1779 | }, | ||
| 1789 | } | 1780 | } | ||
| 1790 | 1781 | ||||
| @@ -1796,6 +1787,57 @@ func TestGenerateVolumeName(t *testing.T) { | |||||
| 1796 | } | 1787 | } | ||
| 1797 | } | 1788 | } | ||
| 1798 | 1789 | ||||
| 1790 | + | func TestService_SetupVolumes_ReusesLegacyVolume(t *testing.T) { | |||
| 1791 | + | runtime := mocks.NewMockContainerRuntime(t) | |||
| 1792 | + | svc := NewService(runtime, nil, nil, nil, Config{VolumeAutoCreate: true, VolumePrefix: "gordon"}, nil) | |||
| 1793 | + | legacyName := legacyVolumeName("gordon", "app.example.com", "/data") | |||
| 1794 | + | ||||
| 1795 | + | runtime.EXPECT().InspectImageVolumes(mock.Anything, "myapp:latest").Return([]string{"/data"}, nil) | |||
| 1796 | + | runtime.EXPECT().VolumeExists(mock.Anything, legacyName).Return(true, nil) | |||
| 1797 | + | runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{{ | |||
| 1798 | + | Labels: map[string]string{domain.LabelManaged: "true", domain.LabelRoute: "app.example.com"}, | |||
| 1799 | + | VolumeMounts: []domain.ContainerVolumeMount{{Name: legacyName}}, | |||
| 1800 | + | }}, nil) | |||
| 1801 | + | ||||
| 1802 | + | volumes, err := svc.setupVolumes(testContext(), "app.example.com", "myapp:latest") | |||
| 1803 | + | ||||
| 1804 | + | require.NoError(t, err) | |||
| 1805 | + | assert.Equal(t, map[string]string{"/data": legacyName}, volumes) | |||
| 1806 | + | } | |||
| 1807 | + | ||||
| 1808 | + | func TestService_SetupVolumes_DoesNotReuseCollidingLegacyVolume(t *testing.T) { | |||
| 1809 | + | runtime := mocks.NewMockContainerRuntime(t) | |||
| 1810 | + | svc := NewService(runtime, nil, nil, nil, Config{VolumeAutoCreate: true, VolumePrefix: "gordon"}, nil) | |||
| 1811 | + | legacyName := legacyVolumeName("gordon", "app.example.com", "/data") | |||
| 1812 | + | stableName := generateVolumeName("gordon", "app.example.com", "/data") | |||
| 1813 | + | require.Equal(t, legacyName, legacyVolumeName("gordon", "app-example.com", "/data")) | |||
| 1814 | + | ||||
| 1815 | + | runtime.EXPECT().InspectImageVolumes(mock.Anything, "myapp:latest").Return([]string{"/data"}, nil) | |||
| 1816 | + | runtime.EXPECT().VolumeExists(mock.Anything, legacyName).Return(true, nil) | |||
| 1817 | + | runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{{ | |||
| 1818 | + | Labels: map[string]string{domain.LabelManaged: "true", domain.LabelRoute: "app-example.com"}, | |||
| 1819 | + | VolumeMounts: []domain.ContainerVolumeMount{{Name: legacyName}}, | |||
| 1820 | + | }}, nil) | |||
| 1821 | + | runtime.EXPECT().VolumeExists(mock.Anything, stableName).Return(true, nil) | |||
| 1822 | + | ||||
| 1823 | + | volumes, err := svc.setupVolumes(testContext(), "app.example.com", "myapp:latest") | |||
| 1824 | + | ||||
| 1825 | + | require.NoError(t, err) | |||
| 1826 | + | assert.Equal(t, map[string]string{"/data": stableName}, volumes) | |||
| 1827 | + | } | |||
| 1828 | + | ||||
| 1829 | + | func TestValidateAttachmentOwnership(t *testing.T) { | |||
| 1830 | + | owned := &domain.Container{Labels: map[string]string{ | |||
| 1831 | + | domain.LabelManaged: "true", domain.LabelAttachment: "true", domain.LabelAttachedTo: "app.example.com", | |||
| 1832 | + | }} | |||
| 1833 | + | require.NoError(t, validateAttachmentOwnership(owned, "app.example.com", "gordon-app-postgres")) | |||
| 1834 | + | ||||
| 1835 | + | foreign := &domain.Container{Labels: map[string]string{ | |||
| 1836 | + | domain.LabelManaged: "true", domain.LabelAttachment: "true", domain.LabelAttachedTo: "other.example.com", | |||
| 1837 | + | }} | |||
| 1838 | + | assert.ErrorIs(t, validateAttachmentOwnership(foreign, "app.example.com", "gordon-app-postgres"), domain.ErrAttachmentOwnershipMismatch) | |||
| 1839 | + | } | |||
| 1840 | + | ||||
| 1799 | func TestMergeEnvironmentVariables(t *testing.T) { | 1841 | func TestMergeEnvironmentVariables(t *testing.T) { | ||
| 1800 | dockerEnv := []string{"FOO=docker", "BAR=docker"} | 1842 | dockerEnv := []string{"FOO=docker", "BAR=docker"} | ||
| 1801 | userEnv := []string{"FOO=user", "BAZ=user"} | 1843 | userEnv := []string{"FOO=user", "BAZ=user"} | ||
| @@ -4480,7 +4522,11 @@ func TestService_CreateNetworkIfNeeded_UsesInternalOptionWhenConfigured(t *testi | |||||
| 4480 | svc := NewService(runtime, nil, nil, nil, Config{NetworkInternal: true}, nil) | 4522 | svc := NewService(runtime, nil, nil, nil, Config{NetworkInternal: true}, nil) | ||
| 4481 | 4523 | ||||
| 4482 | runtime.EXPECT().NetworkExists(mock.Anything, "gordon-app").Return(false, nil) | 4524 | runtime.EXPECT().NetworkExists(mock.Anything, "gordon-app").Return(false, nil) | ||
| 4483 | - | runtime.EXPECT().CreateNetwork(mock.Anything, "gordon-app", domain.NetworkConfig{Driver: "bridge", Internal: true}).Return(nil) | 4525 | + | runtime.EXPECT().CreateNetwork(mock.Anything, "gordon-app", domain.NetworkConfig{ |
| 4526 | + | Driver: "bridge", | |||
| 4527 | + | Internal: true, | |||
| 4528 | + | Labels: map[string]string{domain.LabelManaged: "true"}, | |||
| 4529 | + | }).Return(nil) | |||
| 4484 | 4530 | ||||
| 4485 | require.NoError(t, svc.createNetworkIfNeeded(testContext(), "gordon-app")) | 4531 | require.NoError(t, svc.createNetworkIfNeeded(testContext(), "gordon-app")) | ||
| 4486 | } | 4532 | } | ||
M internal/usecase/images/service.go +18 -0
| @@ -7,12 +7,14 @@ import ( | |||
| 7 | 7 | "fmt" | |
| 8 | 8 | "sort" | |
| 9 | 9 | "strings" | |
| 10 | + | "sync" | |
| 10 | 11 | "time" | |
| 11 | 12 | ||
| 12 | 13 | "github.com/bnema/zerowrap" | |
| 13 | 14 | ||
| 14 | 15 | "github.com/bnema/gordon/internal/boundaries/out" | |
| 15 | 16 | "github.com/bnema/gordon/internal/domain" | |
| 17 | + | "github.com/bnema/gordon/internal/usecase/registrystate" | |
| 16 | 18 | "github.com/bnema/gordon/pkg/runtime" | |
| 17 | 19 | "github.com/bnema/gordon/pkg/validation" | |
| 18 | 20 | ) | |
| @@ -23,6 +25,8 @@ type Service struct { | |||
| 23 | 25 | manifestStorage out.ManifestStorage | |
| 24 | 26 | blobStorage out.BlobStorage | |
| 25 | 27 | log zerowrap.Logger | |
| 28 | + | mutationMu *sync.RWMutex | |
| 29 | + | registryState *registrystate.State | |
| 26 | 30 | } | |
| 27 | 31 | ||
| 28 | 32 | type imageRuntime interface { | |
| @@ -36,12 +40,19 @@ func NewService( | |||
| 36 | 40 | manifestStorage out.ManifestStorage, | |
| 37 | 41 | blobStorage out.BlobStorage, | |
| 38 | 42 | log zerowrap.Logger, | |
| 43 | + | states ...*registrystate.State, | |
| 39 | 44 | ) *Service { | |
| 45 | + | registryState := registrystate.New() | |
| 46 | + | if len(states) > 0 && states[0] != nil { | |
| 47 | + | registryState = states[0] | |
| 48 | + | } | |
| 40 | 49 | return &Service{ | |
| 41 | 50 | runtime: rt, | |
| 42 | 51 | manifestStorage: manifestStorage, | |
| 43 | 52 | blobStorage: blobStorage, | |
| 44 | 53 | log: log, | |
| 54 | + | mutationMu: ®istryState.MutationMu, | |
| 55 | + | registryState: registryState, | |
| 45 | 56 | } | |
| 46 | 57 | } | |
| 47 | 58 | ||
| @@ -194,6 +205,9 @@ func (s *Service) PruneRuntime(ctx context.Context) (domain.ImagePruneReport, er | |||
| 194 | 205 | // It keeps the "latest" tag when present and keeps keepLast most-recent | |
| 195 | 206 | // non-latest tags from tagInfos. | |
| 196 | 207 | func (s *Service) PruneRegistry(ctx context.Context, keepLast int) (domain.ImagePruneReport, error) { | |
| 208 | + | s.mutationMu.Lock() | |
| 209 | + | defer s.mutationMu.Unlock() | |
| 210 | + | ||
| 197 | 211 | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | |
| 198 | 212 | zerowrap.FieldLayer: "usecase", | |
| 199 | 213 | zerowrap.FieldUseCase: "PruneRegistry", | |
| @@ -234,6 +248,10 @@ func (s *Service) PruneRegistry(ctx context.Context, keepLast int) (domain.Image | |||
| 234 | 248 | } | |
| 235 | 249 | } | |
| 236 | 250 | ||
| 251 | + | for digest := range s.registryState.PendingDigests(time.Now().UTC()) { | |
| 252 | + | referencedDigests[digest] = struct{}{} | |
| 253 | + | } | |
| 254 | + | ||
| 237 | 255 | blobs, err := s.blobStorage.ListBlobs() | |
| 238 | 256 | if err != nil { | |
| 239 | 257 | return domain.ImagePruneReport{}, log.WrapErr(err, "failed to list blobs") | |
| @@ -7,12 +7,14 @@ import ( | |||||
| 7 | "fmt" | 7 | "fmt" | ||
| 8 | "sort" | 8 | "sort" | ||
| 9 | "strings" | 9 | "strings" | ||
| 10 | + | "sync" | |||
| 10 | "time" | 11 | "time" | ||
| 11 | 12 | ||||
| 12 | "github.com/bnema/zerowrap" | 13 | "github.com/bnema/zerowrap" | ||
| 13 | 14 | ||||
| 14 | "github.com/bnema/gordon/internal/boundaries/out" | 15 | "github.com/bnema/gordon/internal/boundaries/out" | ||
| 15 | "github.com/bnema/gordon/internal/domain" | 16 | "github.com/bnema/gordon/internal/domain" | ||
| 17 | + | "github.com/bnema/gordon/internal/usecase/registrystate" | |||
| 16 | "github.com/bnema/gordon/pkg/runtime" | 18 | "github.com/bnema/gordon/pkg/runtime" | ||
| 17 | "github.com/bnema/gordon/pkg/validation" | 19 | "github.com/bnema/gordon/pkg/validation" | ||
| 18 | ) | 20 | ) | ||
| @@ -23,6 +25,8 @@ type Service struct { | |||||
| 23 | manifestStorage out.ManifestStorage | 25 | manifestStorage out.ManifestStorage | ||
| 24 | blobStorage out.BlobStorage | 26 | blobStorage out.BlobStorage | ||
| 25 | log zerowrap.Logger | 27 | log zerowrap.Logger | ||
| 28 | + | mutationMu *sync.RWMutex | |||
| 29 | + | registryState *registrystate.State | |||
| 26 | } | 30 | } | ||
| 27 | 31 | ||||
| 28 | type imageRuntime interface { | 32 | type imageRuntime interface { | ||
| @@ -36,12 +40,19 @@ func NewService( | |||||
| 36 | manifestStorage out.ManifestStorage, | 40 | manifestStorage out.ManifestStorage, | ||
| 37 | blobStorage out.BlobStorage, | 41 | blobStorage out.BlobStorage, | ||
| 38 | log zerowrap.Logger, | 42 | log zerowrap.Logger, | ||
| 43 | + | states ...*registrystate.State, | |||
| 39 | ) *Service { | 44 | ) *Service { | ||
| 45 | + | registryState := registrystate.New() | |||
| 46 | + | if len(states) > 0 && states[0] != nil { | |||
| 47 | + | registryState = states[0] | |||
| 48 | + | } | |||
| 40 | return &Service{ | 49 | return &Service{ | ||
| 41 | runtime: rt, | 50 | runtime: rt, | ||
| 42 | manifestStorage: manifestStorage, | 51 | manifestStorage: manifestStorage, | ||
| 43 | blobStorage: blobStorage, | 52 | blobStorage: blobStorage, | ||
| 44 | log: log, | 53 | log: log, | ||
| 54 | + | mutationMu: ®istryState.MutationMu, | |||
| 55 | + | registryState: registryState, | |||
| 45 | } | 56 | } | ||
| 46 | } | 57 | } | ||
| 47 | 58 | ||||
| @@ -194,6 +205,9 @@ func (s *Service) PruneRuntime(ctx context.Context) (domain.ImagePruneReport, er | |||||
| 194 | // It keeps the "latest" tag when present and keeps keepLast most-recent | 205 | // It keeps the "latest" tag when present and keeps keepLast most-recent | ||
| 195 | // non-latest tags from tagInfos. | 206 | // non-latest tags from tagInfos. | ||
| 196 | func (s *Service) PruneRegistry(ctx context.Context, keepLast int) (domain.ImagePruneReport, error) { | 207 | func (s *Service) PruneRegistry(ctx context.Context, keepLast int) (domain.ImagePruneReport, error) { | ||
| 208 | + | s.mutationMu.Lock() | |||
| 209 | + | defer s.mutationMu.Unlock() | |||
| 210 | + | ||||
| 197 | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | 211 | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | ||
| 198 | zerowrap.FieldLayer: "usecase", | 212 | zerowrap.FieldLayer: "usecase", | ||
| 199 | zerowrap.FieldUseCase: "PruneRegistry", | 213 | zerowrap.FieldUseCase: "PruneRegistry", | ||
| @@ -234,6 +248,10 @@ func (s *Service) PruneRegistry(ctx context.Context, keepLast int) (domain.Image | |||||
| 234 | } | 248 | } | ||
| 235 | } | 249 | } | ||
| 236 | 250 | ||||
| 251 | + | for digest := range s.registryState.PendingDigests(time.Now().UTC()) { | |||
| 252 | + | referencedDigests[digest] = struct{}{} | |||
| 253 | + | } | |||
| 254 | + | ||||
| 237 | blobs, err := s.blobStorage.ListBlobs() | 255 | blobs, err := s.blobStorage.ListBlobs() | ||
| 238 | if err != nil { | 256 | if err != nil { | ||
| 239 | return domain.ImagePruneReport{}, log.WrapErr(err, "failed to list blobs") | 257 | return domain.ImagePruneReport{}, log.WrapErr(err, "failed to list blobs") | ||
M internal/usecase/images/service_test.go +19 -0
| @@ -13,6 +13,7 @@ import ( | |||
| 13 | 13 | ||
| 14 | 14 | "github.com/bnema/gordon/internal/boundaries/out" | |
| 15 | 15 | "github.com/bnema/gordon/internal/domain" | |
| 16 | + | "github.com/bnema/gordon/internal/usecase/registrystate" | |
| 16 | 17 | pkgruntime "github.com/bnema/gordon/pkg/runtime" | |
| 17 | 18 | ) | |
| 18 | 19 | ||
| @@ -332,6 +333,24 @@ func TestService_PruneRegistry_GarbageCollectsUnreferencedBlobs(t *testing.T) { | |||
| 332 | 333 | assert.Equal(t, []string{"sha256:orphan"}, blobStorage.deletedBlobs) | |
| 333 | 334 | } | |
| 334 | 335 | ||
| 336 | + | func TestService_PruneRegistry_PreservesPendingBlob(t *testing.T) { | |
| 337 | + | manifestStorage := newFakeManifestStorage() | |
| 338 | + | manifestStorage.repositories = []string{"gordon/api"} | |
| 339 | + | manifestStorage.tagsByRepo["gordon/api"] = []string{"latest"} | |
| 340 | + | manifestStorage.modTimes[manifestRefKey("gordon/api", "latest")] = time.Now().UTC() | |
| 341 | + | manifestStorage.manifests[manifestRefKey("gordon/api", "latest")] = mustManifestJSON(t, "sha256:cfg-live", "sha256:layer-live") | |
| 342 | + | blobStorage := &fakeBlobStorage{blobs: []string{"sha256:pending"}} | |
| 343 | + | state := registrystate.New() | |
| 344 | + | state.AddPending("sha256:pending", time.Now().UTC()) | |
| 345 | + | svc := NewService(&fakeRuntime{}, manifestStorage, blobStorage, zerowrap.Default(), state) | |
| 346 | + | ||
| 347 | + | report, err := svc.PruneRegistry(context.Background(), 1) | |
| 348 | + | ||
| 349 | + | require.NoError(t, err) | |
| 350 | + | assert.Zero(t, report.Registry.BlobsRemoved) | |
| 351 | + | assert.Empty(t, blobStorage.deletedBlobs) | |
| 352 | + | } | |
| 353 | + | ||
| 335 | 354 | func TestService_PruneRegistry_PreservesSharedBlobs(t *testing.T) { | |
| 336 | 355 | manifestStorage := newFakeManifestStorage() | |
| 337 | 356 | manifestStorage.repositories = []string{"gordon/api"} | |
| @@ -13,6 +13,7 @@ import ( | |||||
| 13 | 13 | ||||
| 14 | "github.com/bnema/gordon/internal/boundaries/out" | 14 | "github.com/bnema/gordon/internal/boundaries/out" | ||
| 15 | "github.com/bnema/gordon/internal/domain" | 15 | "github.com/bnema/gordon/internal/domain" | ||
| 16 | + | "github.com/bnema/gordon/internal/usecase/registrystate" | |||
| 16 | pkgruntime "github.com/bnema/gordon/pkg/runtime" | 17 | pkgruntime "github.com/bnema/gordon/pkg/runtime" | ||
| 17 | ) | 18 | ) | ||
| 18 | 19 | ||||
| @@ -332,6 +333,24 @@ func TestService_PruneRegistry_GarbageCollectsUnreferencedBlobs(t *testing.T) { | |||||
| 332 | assert.Equal(t, []string{"sha256:orphan"}, blobStorage.deletedBlobs) | 333 | assert.Equal(t, []string{"sha256:orphan"}, blobStorage.deletedBlobs) | ||
| 333 | } | 334 | } | ||
| 334 | 335 | ||||
| 336 | + | func TestService_PruneRegistry_PreservesPendingBlob(t *testing.T) { | |||
| 337 | + | manifestStorage := newFakeManifestStorage() | |||
| 338 | + | manifestStorage.repositories = []string{"gordon/api"} | |||
| 339 | + | manifestStorage.tagsByRepo["gordon/api"] = []string{"latest"} | |||
| 340 | + | manifestStorage.modTimes[manifestRefKey("gordon/api", "latest")] = time.Now().UTC() | |||
| 341 | + | manifestStorage.manifests[manifestRefKey("gordon/api", "latest")] = mustManifestJSON(t, "sha256:cfg-live", "sha256:layer-live") | |||
| 342 | + | blobStorage := &fakeBlobStorage{blobs: []string{"sha256:pending"}} | |||
| 343 | + | state := registrystate.New() | |||
| 344 | + | state.AddPending("sha256:pending", time.Now().UTC()) | |||
| 345 | + | svc := NewService(&fakeRuntime{}, manifestStorage, blobStorage, zerowrap.Default(), state) | |||
| 346 | + | ||||
| 347 | + | report, err := svc.PruneRegistry(context.Background(), 1) | |||
| 348 | + | ||||
| 349 | + | require.NoError(t, err) | |||
| 350 | + | assert.Zero(t, report.Registry.BlobsRemoved) | |||
| 351 | + | assert.Empty(t, blobStorage.deletedBlobs) | |||
| 352 | + | } | |||
| 353 | + | ||||
| 335 | func TestService_PruneRegistry_PreservesSharedBlobs(t *testing.T) { | 354 | func TestService_PruneRegistry_PreservesSharedBlobs(t *testing.T) { | ||
| 336 | manifestStorage := newFakeManifestStorage() | 355 | manifestStorage := newFakeManifestStorage() | ||
| 337 | manifestStorage.repositories = []string{"gordon/api"} | 356 | manifestStorage.repositories = []string{"gordon/api"} | ||
M internal/usecase/registry/service.go +176 -4
| @@ -4,6 +4,9 @@ package registry | |||
| 4 | 4 | import ( | |
| 5 | 5 | "context" | |
| 6 | 6 | "crypto/sha256" | |
| 7 | + | "crypto/sha512" | |
| 8 | + | "encoding/json" | |
| 9 | + | "errors" | |
| 7 | 10 | "fmt" | |
| 8 | 11 | "io" | |
| 9 | 12 | "strings" | |
| @@ -19,6 +22,7 @@ import ( | |||
| 19 | 22 | "github.com/bnema/gordon/internal/adapters/out/telemetry" | |
| 20 | 23 | "github.com/bnema/gordon/internal/boundaries/out" | |
| 21 | 24 | "github.com/bnema/gordon/internal/domain" | |
| 25 | + | "github.com/bnema/gordon/internal/usecase/registrystate" | |
| 22 | 26 | "github.com/bnema/gordon/pkg/validation" | |
| 23 | 27 | ) | |
| 24 | 28 | ||
| @@ -31,6 +35,8 @@ type Service struct { | |||
| 31 | 35 | eventBus out.EventPublisher | |
| 32 | 36 | metrics *telemetry.Metrics | |
| 33 | 37 | suppressedImages sync.Map // imageName -> *time.Timer | |
| 38 | + | mutationMu *sync.RWMutex | |
| 39 | + | registryState *registrystate.State | |
| 34 | 40 | } | |
| 35 | 41 | ||
| 36 | 42 | // SetMetrics sets the telemetry metrics for the registry service. | |
| @@ -43,11 +49,18 @@ func NewService( | |||
| 43 | 49 | blobStorage out.BlobStorage, | |
| 44 | 50 | manifestStorage out.ManifestStorage, | |
| 45 | 51 | eventBus out.EventPublisher, | |
| 52 | + | states ...*registrystate.State, | |
| 46 | 53 | ) *Service { | |
| 54 | + | registryState := registrystate.New() | |
| 55 | + | if len(states) > 0 && states[0] != nil { | |
| 56 | + | registryState = states[0] | |
| 57 | + | } | |
| 47 | 58 | return &Service{ | |
| 48 | 59 | blobStorage: blobStorage, | |
| 49 | 60 | manifestStorage: manifestStorage, | |
| 50 | 61 | eventBus: eventBus, | |
| 62 | + | mutationMu: ®istryState.MutationMu, | |
| 63 | + | registryState: registryState, | |
| 51 | 64 | } | |
| 52 | 65 | } | |
| 53 | 66 | ||
| @@ -155,6 +168,9 @@ func (s *Service) GetManifest(ctx context.Context, name, reference string) (*dom | |||
| 155 | 168 | ||
| 156 | 169 | // PutManifest stores a manifest and returns the calculated digest. | |
| 157 | 170 | func (s *Service) PutManifest(ctx context.Context, manifest *domain.Manifest) (string, error) { | |
| 171 | + | s.mutationMu.RLock() | |
| 172 | + | defer s.mutationMu.RUnlock() | |
| 173 | + | ||
| 158 | 174 | ctx, span := registryTracer.Start(ctx, "registry.put_manifest", | |
| 159 | 175 | trace.WithAttributes( | |
| 160 | 176 | attribute.String("name", manifest.Name), | |
| @@ -171,12 +187,25 @@ func (s *Service) PutManifest(ctx context.Context, manifest *domain.Manifest) (s | |||
| 171 | 187 | }) | |
| 172 | 188 | log := zerowrap.FromCtx(ctx) | |
| 173 | 189 | ||
| 174 | - | // Calculate digest | |
| 190 | + | // Calculate and verify the digest before mutating storage. A digest-addressed | |
| 191 | + | // manifest must match its URL reference or clients could later retrieve | |
| 192 | + | // attacker-controlled bytes under a trusted content address. | |
| 175 | 193 | digest := fmt.Sprintf("sha256:%x", sha256.Sum256(manifest.Data)) | |
| 194 | + | if validation.IsDigest(manifest.Reference) { | |
| 195 | + | matches, err := manifestDigestMatches(manifest.Reference, manifest.Data) | |
| 196 | + | if err != nil { | |
| 197 | + | return "", fmt.Errorf("validate manifest digest: %w", err) | |
| 198 | + | } | |
| 199 | + | if !matches { | |
| 200 | + | return "", fmt.Errorf("%w: manifest content does not match %s", domain.ErrDigestMismatch, manifest.Reference) | |
| 201 | + | } | |
| 202 | + | digest = manifest.Reference | |
| 203 | + | } | |
| 176 | 204 | ||
| 177 | 205 | if err := s.manifestStorage.PutManifest(manifest.Name, manifest.Reference, manifest.ContentType, manifest.Data); err != nil { | |
| 178 | 206 | return "", log.WrapErr(err, "failed to store manifest") | |
| 179 | 207 | } | |
| 208 | + | s.registryState.MarkPublished(manifestReferencedDigests(manifest.Data)) | |
| 180 | 209 | ||
| 181 | 210 | // Record push metrics | |
| 182 | 211 | if s.metrics != nil { | |
| @@ -191,7 +220,7 @@ func (s *Service) PutManifest(ctx context.Context, manifest *domain.Manifest) (s | |||
| 191 | 220 | // Publish image pushed event only for tag references (not digests). | |
| 192 | 221 | // A docker push sends manifests by both digest and tag; firing only on | |
| 193 | 222 | // tag prevents duplicate deploy triggers for the same push. | |
| 194 | - | if s.eventBus != nil && !strings.HasPrefix(manifest.Reference, "sha256:") { | |
| 223 | + | if s.eventBus != nil && !validation.IsDigest(manifest.Reference) { | |
| 195 | 224 | if s.IsDeployEventSuppressed(manifest.Name) { | |
| 196 | 225 | log.Info().Str("image", manifest.Name).Msg("skipping image.pushed event: CLI deploy intent active") | |
| 197 | 226 | } else { | |
| @@ -212,6 +241,9 @@ func (s *Service) PutManifest(ctx context.Context, manifest *domain.Manifest) (s | |||
| 212 | 241 | ||
| 213 | 242 | // DeleteManifest removes a manifest. | |
| 214 | 243 | func (s *Service) DeleteManifest(ctx context.Context, name, reference string) error { | |
| 244 | + | s.mutationMu.RLock() | |
| 245 | + | defer s.mutationMu.RUnlock() | |
| 246 | + | ||
| 215 | 247 | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | |
| 216 | 248 | zerowrap.FieldLayer: "usecase", | |
| 217 | 249 | zerowrap.FieldUseCase: "DeleteManifest", | |
| @@ -245,15 +277,25 @@ func (s *Service) GetBlob(ctx context.Context, digest string) (io.ReadCloser, er | |||
| 245 | 277 | return reader, nil | |
| 246 | 278 | } | |
| 247 | 279 | ||
| 248 | - | // GetBlobPath returns the filesystem path to a blob for direct serving. | |
| 249 | - | func (s *Service) GetBlobPath(ctx context.Context, digest string) (string, error) { | |
| 280 | + | // GetBlobPath returns the filesystem path to a blob only when a manifest in | |
| 281 | + | // the requested repository references it. | |
| 282 | + | func (s *Service) GetBlobPath(ctx context.Context, name, digest string) (string, error) { | |
| 250 | 283 | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | |
| 251 | 284 | zerowrap.FieldLayer: "usecase", | |
| 252 | 285 | zerowrap.FieldUseCase: "GetBlobPath", | |
| 286 | + | "name": name, | |
| 253 | 287 | "digest": digest, | |
| 254 | 288 | }) | |
| 255 | 289 | log := zerowrap.FromCtx(ctx) | |
| 256 | 290 | ||
| 291 | + | referenced, err := s.repositoryReferencesDigest(name, digest) | |
| 292 | + | if err != nil { | |
| 293 | + | return "", log.WrapErr(err, "failed to verify blob ownership") | |
| 294 | + | } | |
| 295 | + | if !referenced { | |
| 296 | + | return "", domain.ErrBlobNotFound | |
| 297 | + | } | |
| 298 | + | ||
| 257 | 299 | path, err := s.blobStorage.GetBlobPath(digest) | |
| 258 | 300 | if err != nil { | |
| 259 | 301 | return "", log.WrapErr(err, "failed to get blob path") | |
| @@ -262,8 +304,137 @@ func (s *Service) GetBlobPath(ctx context.Context, digest string) (string, error | |||
| 262 | 304 | return path, nil | |
| 263 | 305 | } | |
| 264 | 306 | ||
| 307 | + | type manifestDescriptor struct { | |
| 308 | + | Digest string `json:"digest"` | |
| 309 | + | } | |
| 310 | + | ||
| 311 | + | type manifestReferences struct { | |
| 312 | + | Config manifestDescriptor `json:"config"` | |
| 313 | + | Layers []manifestDescriptor `json:"layers"` | |
| 314 | + | Manifests []manifestDescriptor `json:"manifests"` | |
| 315 | + | Blobs []manifestDescriptor `json:"blobs"` | |
| 316 | + | Subject *manifestDescriptor `json:"subject"` | |
| 317 | + | } | |
| 318 | + | ||
| 319 | + | const maxManifestTraversal = 10000 | |
| 320 | + | ||
| 321 | + | func (s *Service) repositoryReferencesDigest(name, target string) (bool, error) { | |
| 322 | + | tags, err := s.manifestStorage.ListTags(name) | |
| 323 | + | if err != nil { | |
| 324 | + | if errors.Is(err, domain.ErrManifestNotFound) { | |
| 325 | + | return false, nil | |
| 326 | + | } | |
| 327 | + | return false, fmt.Errorf("list tags for repository %s: %w", name, err) | |
| 328 | + | } | |
| 329 | + | ||
| 330 | + | if len(tags) > maxManifestTraversal { | |
| 331 | + | return false, fmt.Errorf("%w: repository %s exceeds manifest traversal limit", domain.ErrBlobNotFound, name) | |
| 332 | + | } | |
| 333 | + | queue := append([]string(nil), tags...) | |
| 334 | + | seen := make(map[string]struct{}, len(queue)) | |
| 335 | + | for len(queue) > 0 { | |
| 336 | + | reference := queue[0] | |
| 337 | + | queue = queue[1:] | |
| 338 | + | if _, ok := seen[reference]; ok { | |
| 339 | + | continue | |
| 340 | + | } | |
| 341 | + | seen[reference] = struct{}{} | |
| 342 | + | if len(seen) > maxManifestTraversal { | |
| 343 | + | return false, fmt.Errorf("%w: repository %s exceeds manifest traversal limit", domain.ErrBlobNotFound, name) | |
| 344 | + | } | |
| 345 | + | ||
| 346 | + | data, _, err := s.manifestStorage.GetManifest(name, reference) | |
| 347 | + | if err != nil { | |
| 348 | + | return false, fmt.Errorf("get manifest %s for repository %s: %w", reference, name, err) | |
| 349 | + | } | |
| 350 | + | var refs manifestReferences | |
| 351 | + | if err := json.Unmarshal(data, &refs); err != nil { | |
| 352 | + | return false, fmt.Errorf("decode manifest %s: %w", reference, err) | |
| 353 | + | } | |
| 354 | + | ||
| 355 | + | if manifestReferencesTarget(refs, target) { | |
| 356 | + | return true, nil | |
| 357 | + | } | |
| 358 | + | nestedManifests := refs.Manifests | |
| 359 | + | if refs.Subject != nil && refs.Subject.Digest != "" { | |
| 360 | + | nestedManifests = append(append([]manifestDescriptor(nil), refs.Manifests...), *refs.Subject) | |
| 361 | + | } | |
| 362 | + | if len(seen)+len(queue)+len(nestedManifests) > maxManifestTraversal { | |
| 363 | + | return false, fmt.Errorf("%w: repository %s exceeds manifest traversal limit", domain.ErrBlobNotFound, name) | |
| 364 | + | } | |
| 365 | + | queue = appendManifestDigests(queue, nestedManifests) | |
| 366 | + | } | |
| 367 | + | return false, nil | |
| 368 | + | } | |
| 369 | + | ||
| 370 | + | func manifestReferencesTarget(refs manifestReferences, target string) bool { | |
| 371 | + | return refs.Config.Digest == target || | |
| 372 | + | descriptorListContains(refs.Layers, target) || | |
| 373 | + | descriptorListContains(refs.Manifests, target) || | |
| 374 | + | descriptorListContains(refs.Blobs, target) || | |
| 375 | + | (refs.Subject != nil && refs.Subject.Digest == target) | |
| 376 | + | } | |
| 377 | + | ||
| 378 | + | func appendManifestDigests(queue []string, descriptors []manifestDescriptor) []string { | |
| 379 | + | for _, descriptor := range descriptors { | |
| 380 | + | if descriptor.Digest != "" { | |
| 381 | + | queue = append(queue, descriptor.Digest) | |
| 382 | + | } | |
| 383 | + | } | |
| 384 | + | return queue | |
| 385 | + | } | |
| 386 | + | ||
| 387 | + | func manifestReferencedDigests(data []byte) []string { | |
| 388 | + | var refs manifestReferences | |
| 389 | + | if json.Unmarshal(data, &refs) != nil { | |
| 390 | + | return nil | |
| 391 | + | } | |
| 392 | + | digests := make([]string, 0, 1+len(refs.Layers)+len(refs.Manifests)+len(refs.Blobs)) | |
| 393 | + | if refs.Config.Digest != "" { | |
| 394 | + | digests = append(digests, refs.Config.Digest) | |
| 395 | + | } | |
| 396 | + | for _, descriptors := range [][]manifestDescriptor{refs.Layers, refs.Manifests, refs.Blobs} { | |
| 397 | + | for _, descriptor := range descriptors { | |
| 398 | + | if descriptor.Digest != "" { | |
| 399 | + | digests = append(digests, descriptor.Digest) | |
| 400 | + | } | |
| 401 | + | } | |
| 402 | + | } | |
| 403 | + | if refs.Subject != nil && refs.Subject.Digest != "" { | |
| 404 | + | digests = append(digests, refs.Subject.Digest) | |
| 405 | + | } | |
| 406 | + | return digests | |
| 407 | + | } | |
| 408 | + | ||
| 409 | + | func descriptorListContains(descriptors []manifestDescriptor, digest string) bool { | |
| 410 | + | for _, descriptor := range descriptors { | |
| 411 | + | if descriptor.Digest == digest { | |
| 412 | + | return true | |
| 413 | + | } | |
| 414 | + | } | |
| 415 | + | return false | |
| 416 | + | } | |
| 417 | + | ||
| 418 | + | func manifestDigestMatches(reference string, data []byte) (bool, error) { | |
| 419 | + | algorithm, _, ok := strings.Cut(reference, ":") | |
| 420 | + | if !ok { | |
| 421 | + | return false, domain.ErrInvalidDigest | |
| 422 | + | } | |
| 423 | + | switch algorithm { | |
| 424 | + | case "sha256": | |
| 425 | + | return reference == fmt.Sprintf("sha256:%x", sha256.Sum256(data)), nil | |
| 426 | + | case "sha512": | |
| 427 | + | return reference == fmt.Sprintf("sha512:%x", sha512.Sum512(data)), nil | |
| 428 | + | default: | |
| 429 | + | return false, domain.ErrInvalidDigest | |
| 430 | + | } | |
| 431 | + | } | |
| 432 | + | ||
| 265 | 433 | // PutBlob stores a blob with the given digest. | |
| 266 | 434 | func (s *Service) PutBlob(ctx context.Context, digest string, data io.Reader, size int64) error { | |
| 435 | + | s.mutationMu.RLock() | |
| 436 | + | defer s.mutationMu.RUnlock() | |
| 437 | + | ||
| 267 | 438 | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | |
| 268 | 439 | zerowrap.FieldLayer: "usecase", | |
| 269 | 440 | zerowrap.FieldUseCase: "PutBlob", | |
| @@ -275,6 +446,7 @@ func (s *Service) PutBlob(ctx context.Context, digest string, data io.Reader, si | |||
| 275 | 446 | if err := s.blobStorage.PutBlob(digest, data, size); err != nil { | |
| 276 | 447 | return log.WrapErr(err, "failed to store blob") | |
| 277 | 448 | } | |
| 449 | + | s.registryState.AddPending(digest, time.Now().UTC()) | |
| 278 | 450 | ||
| 279 | 451 | log.Info().Msg("blob stored") | |
| 280 | 452 | return nil | |
| @@ -4,6 +4,9 @@ package registry | |||||
| 4 | import ( | 4 | import ( | ||
| 5 | "context" | 5 | "context" | ||
| 6 | "crypto/sha256" | 6 | "crypto/sha256" | ||
| 7 | + | "crypto/sha512" | |||
| 8 | + | "encoding/json" | |||
| 9 | + | "errors" | |||
| 7 | "fmt" | 10 | "fmt" | ||
| 8 | "io" | 11 | "io" | ||
| 9 | "strings" | 12 | "strings" | ||
| @@ -19,6 +22,7 @@ import ( | |||||
| 19 | "github.com/bnema/gordon/internal/adapters/out/telemetry" | 22 | "github.com/bnema/gordon/internal/adapters/out/telemetry" | ||
| 20 | "github.com/bnema/gordon/internal/boundaries/out" | 23 | "github.com/bnema/gordon/internal/boundaries/out" | ||
| 21 | "github.com/bnema/gordon/internal/domain" | 24 | "github.com/bnema/gordon/internal/domain" | ||
| 25 | + | "github.com/bnema/gordon/internal/usecase/registrystate" | |||
| 22 | "github.com/bnema/gordon/pkg/validation" | 26 | "github.com/bnema/gordon/pkg/validation" | ||
| 23 | ) | 27 | ) | ||
| 24 | 28 | ||||
| @@ -31,6 +35,8 @@ type Service struct { | |||||
| 31 | eventBus out.EventPublisher | 35 | eventBus out.EventPublisher | ||
| 32 | metrics *telemetry.Metrics | 36 | metrics *telemetry.Metrics | ||
| 33 | suppressedImages sync.Map // imageName -> *time.Timer | 37 | suppressedImages sync.Map // imageName -> *time.Timer | ||
| 38 | + | mutationMu *sync.RWMutex | |||
| 39 | + | registryState *registrystate.State | |||
| 34 | } | 40 | } | ||
| 35 | 41 | ||||
| 36 | // SetMetrics sets the telemetry metrics for the registry service. | 42 | // SetMetrics sets the telemetry metrics for the registry service. | ||
| @@ -43,11 +49,18 @@ func NewService( | |||||
| 43 | blobStorage out.BlobStorage, | 49 | blobStorage out.BlobStorage, | ||
| 44 | manifestStorage out.ManifestStorage, | 50 | manifestStorage out.ManifestStorage, | ||
| 45 | eventBus out.EventPublisher, | 51 | eventBus out.EventPublisher, | ||
| 52 | + | states ...*registrystate.State, | |||
| 46 | ) *Service { | 53 | ) *Service { | ||
| 54 | + | registryState := registrystate.New() | |||
| 55 | + | if len(states) > 0 && states[0] != nil { | |||
| 56 | + | registryState = states[0] | |||
| 57 | + | } | |||
| 47 | return &Service{ | 58 | return &Service{ | ||
| 48 | blobStorage: blobStorage, | 59 | blobStorage: blobStorage, | ||
| 49 | manifestStorage: manifestStorage, | 60 | manifestStorage: manifestStorage, | ||
| 50 | eventBus: eventBus, | 61 | eventBus: eventBus, | ||
| 62 | + | mutationMu: ®istryState.MutationMu, | |||
| 63 | + | registryState: registryState, | |||
| 51 | } | 64 | } | ||
| 52 | } | 65 | } | ||
| 53 | 66 | ||||
| @@ -155,6 +168,9 @@ func (s *Service) GetManifest(ctx context.Context, name, reference string) (*dom | |||||
| 155 | 168 | ||||
| 156 | // PutManifest stores a manifest and returns the calculated digest. | 169 | // PutManifest stores a manifest and returns the calculated digest. | ||
| 157 | func (s *Service) PutManifest(ctx context.Context, manifest *domain.Manifest) (string, error) { | 170 | func (s *Service) PutManifest(ctx context.Context, manifest *domain.Manifest) (string, error) { | ||
| 171 | + | s.mutationMu.RLock() | |||
| 172 | + | defer s.mutationMu.RUnlock() | |||
| 173 | + | ||||
| 158 | ctx, span := registryTracer.Start(ctx, "registry.put_manifest", | 174 | ctx, span := registryTracer.Start(ctx, "registry.put_manifest", | ||
| 159 | trace.WithAttributes( | 175 | trace.WithAttributes( | ||
| 160 | attribute.String("name", manifest.Name), | 176 | attribute.String("name", manifest.Name), | ||
| @@ -171,12 +187,25 @@ func (s *Service) PutManifest(ctx context.Context, manifest *domain.Manifest) (s | |||||
| 171 | }) | 187 | }) | ||
| 172 | log := zerowrap.FromCtx(ctx) | 188 | log := zerowrap.FromCtx(ctx) | ||
| 173 | 189 | ||||
| 174 | - | // Calculate digest | 190 | + | // Calculate and verify the digest before mutating storage. A digest-addressed |
| 191 | + | // manifest must match its URL reference or clients could later retrieve | |||
| 192 | + | // attacker-controlled bytes under a trusted content address. | |||
| 175 | digest := fmt.Sprintf("sha256:%x", sha256.Sum256(manifest.Data)) | 193 | digest := fmt.Sprintf("sha256:%x", sha256.Sum256(manifest.Data)) | ||
| 194 | + | if validation.IsDigest(manifest.Reference) { | |||
| 195 | + | matches, err := manifestDigestMatches(manifest.Reference, manifest.Data) | |||
| 196 | + | if err != nil { | |||
| 197 | + | return "", fmt.Errorf("validate manifest digest: %w", err) | |||
| 198 | + | } | |||
| 199 | + | if !matches { | |||
| 200 | + | return "", fmt.Errorf("%w: manifest content does not match %s", domain.ErrDigestMismatch, manifest.Reference) | |||
| 201 | + | } | |||
| 202 | + | digest = manifest.Reference | |||
| 203 | + | } | |||
| 176 | 204 | ||||
| 177 | if err := s.manifestStorage.PutManifest(manifest.Name, manifest.Reference, manifest.ContentType, manifest.Data); err != nil { | 205 | if err := s.manifestStorage.PutManifest(manifest.Name, manifest.Reference, manifest.ContentType, manifest.Data); err != nil { | ||
| 178 | return "", log.WrapErr(err, "failed to store manifest") | 206 | return "", log.WrapErr(err, "failed to store manifest") | ||
| 179 | } | 207 | } | ||
| 208 | + | s.registryState.MarkPublished(manifestReferencedDigests(manifest.Data)) | |||
| 180 | 209 | ||||
| 181 | // Record push metrics | 210 | // Record push metrics | ||
| 182 | if s.metrics != nil { | 211 | if s.metrics != nil { | ||
| @@ -191,7 +220,7 @@ func (s *Service) PutManifest(ctx context.Context, manifest *domain.Manifest) (s | |||||
| 191 | // Publish image pushed event only for tag references (not digests). | 220 | // Publish image pushed event only for tag references (not digests). | ||
| 192 | // A docker push sends manifests by both digest and tag; firing only on | 221 | // A docker push sends manifests by both digest and tag; firing only on | ||
| 193 | // tag prevents duplicate deploy triggers for the same push. | 222 | // tag prevents duplicate deploy triggers for the same push. | ||
| 194 | - | if s.eventBus != nil && !strings.HasPrefix(manifest.Reference, "sha256:") { | 223 | + | if s.eventBus != nil && !validation.IsDigest(manifest.Reference) { |
| 195 | if s.IsDeployEventSuppressed(manifest.Name) { | 224 | if s.IsDeployEventSuppressed(manifest.Name) { | ||
| 196 | log.Info().Str("image", manifest.Name).Msg("skipping image.pushed event: CLI deploy intent active") | 225 | log.Info().Str("image", manifest.Name).Msg("skipping image.pushed event: CLI deploy intent active") | ||
| 197 | } else { | 226 | } else { | ||
| @@ -212,6 +241,9 @@ func (s *Service) PutManifest(ctx context.Context, manifest *domain.Manifest) (s | |||||
| 212 | 241 | ||||
| 213 | // DeleteManifest removes a manifest. | 242 | // DeleteManifest removes a manifest. | ||
| 214 | func (s *Service) DeleteManifest(ctx context.Context, name, reference string) error { | 243 | func (s *Service) DeleteManifest(ctx context.Context, name, reference string) error { | ||
| 244 | + | s.mutationMu.RLock() | |||
| 245 | + | defer s.mutationMu.RUnlock() | |||
| 246 | + | ||||
| 215 | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | 247 | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | ||
| 216 | zerowrap.FieldLayer: "usecase", | 248 | zerowrap.FieldLayer: "usecase", | ||
| 217 | zerowrap.FieldUseCase: "DeleteManifest", | 249 | zerowrap.FieldUseCase: "DeleteManifest", | ||
| @@ -245,15 +277,25 @@ func (s *Service) GetBlob(ctx context.Context, digest string) (io.ReadCloser, er | |||||
| 245 | return reader, nil | 277 | return reader, nil | ||
| 246 | } | 278 | } | ||
| 247 | 279 | ||||
| 248 | - | // GetBlobPath returns the filesystem path to a blob for direct serving. | 280 | + | // GetBlobPath returns the filesystem path to a blob only when a manifest in |
| 249 | - | func (s *Service) GetBlobPath(ctx context.Context, digest string) (string, error) { | 281 | + | // the requested repository references it. |
| 282 | + | func (s *Service) GetBlobPath(ctx context.Context, name, digest string) (string, error) { | |||
| 250 | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | 283 | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | ||
| 251 | zerowrap.FieldLayer: "usecase", | 284 | zerowrap.FieldLayer: "usecase", | ||
| 252 | zerowrap.FieldUseCase: "GetBlobPath", | 285 | zerowrap.FieldUseCase: "GetBlobPath", | ||
| 286 | + | "name": name, | |||
| 253 | "digest": digest, | 287 | "digest": digest, | ||
| 254 | }) | 288 | }) | ||
| 255 | log := zerowrap.FromCtx(ctx) | 289 | log := zerowrap.FromCtx(ctx) | ||
| 256 | 290 | ||||
| 291 | + | referenced, err := s.repositoryReferencesDigest(name, digest) | |||
| 292 | + | if err != nil { | |||
| 293 | + | return "", log.WrapErr(err, "failed to verify blob ownership") | |||
| 294 | + | } | |||
| 295 | + | if !referenced { | |||
| 296 | + | return "", domain.ErrBlobNotFound | |||
| 297 | + | } | |||
| 298 | + | ||||
| 257 | path, err := s.blobStorage.GetBlobPath(digest) | 299 | path, err := s.blobStorage.GetBlobPath(digest) | ||
| 258 | if err != nil { | 300 | if err != nil { | ||
| 259 | return "", log.WrapErr(err, "failed to get blob path") | 301 | return "", log.WrapErr(err, "failed to get blob path") | ||
| @@ -262,8 +304,137 @@ func (s *Service) GetBlobPath(ctx context.Context, digest string) (string, error | |||||
| 262 | return path, nil | 304 | return path, nil | ||
| 263 | } | 305 | } | ||
| 264 | 306 | ||||
| 307 | + | type manifestDescriptor struct { | |||
| 308 | + | Digest string `json:"digest"` | |||
| 309 | + | } | |||
| 310 | + | ||||
| 311 | + | type manifestReferences struct { | |||
| 312 | + | Config manifestDescriptor `json:"config"` | |||
| 313 | + | Layers []manifestDescriptor `json:"layers"` | |||
| 314 | + | Manifests []manifestDescriptor `json:"manifests"` | |||
| 315 | + | Blobs []manifestDescriptor `json:"blobs"` | |||
| 316 | + | Subject *manifestDescriptor `json:"subject"` | |||
| 317 | + | } | |||
| 318 | + | ||||
| 319 | + | const maxManifestTraversal = 10000 | |||
| 320 | + | ||||
| 321 | + | func (s *Service) repositoryReferencesDigest(name, target string) (bool, error) { | |||
| 322 | + | tags, err := s.manifestStorage.ListTags(name) | |||
| 323 | + | if err != nil { | |||
| 324 | + | if errors.Is(err, domain.ErrManifestNotFound) { | |||
| 325 | + | return false, nil | |||
| 326 | + | } | |||
| 327 | + | return false, fmt.Errorf("list tags for repository %s: %w", name, err) | |||
| 328 | + | } | |||
| 329 | + | ||||
| 330 | + | if len(tags) > maxManifestTraversal { | |||
| 331 | + | return false, fmt.Errorf("%w: repository %s exceeds manifest traversal limit", domain.ErrBlobNotFound, name) | |||
| 332 | + | } | |||
| 333 | + | queue := append([]string(nil), tags...) | |||
| 334 | + | seen := make(map[string]struct{}, len(queue)) | |||
| 335 | + | for len(queue) > 0 { | |||
| 336 | + | reference := queue[0] | |||
| 337 | + | queue = queue[1:] | |||
| 338 | + | if _, ok := seen[reference]; ok { | |||
| 339 | + | continue | |||
| 340 | + | } | |||
| 341 | + | seen[reference] = struct{}{} | |||
| 342 | + | if len(seen) > maxManifestTraversal { | |||
| 343 | + | return false, fmt.Errorf("%w: repository %s exceeds manifest traversal limit", domain.ErrBlobNotFound, name) | |||
| 344 | + | } | |||
| 345 | + | ||||
| 346 | + | data, _, err := s.manifestStorage.GetManifest(name, reference) | |||
| 347 | + | if err != nil { | |||
| 348 | + | return false, fmt.Errorf("get manifest %s for repository %s: %w", reference, name, err) | |||
| 349 | + | } | |||
| 350 | + | var refs manifestReferences | |||
| 351 | + | if err := json.Unmarshal(data, &refs); err != nil { | |||
| 352 | + | return false, fmt.Errorf("decode manifest %s: %w", reference, err) | |||
| 353 | + | } | |||
| 354 | + | ||||
| 355 | + | if manifestReferencesTarget(refs, target) { | |||
| 356 | + | return true, nil | |||
| 357 | + | } | |||
| 358 | + | nestedManifests := refs.Manifests | |||
| 359 | + | if refs.Subject != nil && refs.Subject.Digest != "" { | |||
| 360 | + | nestedManifests = append(append([]manifestDescriptor(nil), refs.Manifests...), *refs.Subject) | |||
| 361 | + | } | |||
| 362 | + | if len(seen)+len(queue)+len(nestedManifests) > maxManifestTraversal { | |||
| 363 | + | return false, fmt.Errorf("%w: repository %s exceeds manifest traversal limit", domain.ErrBlobNotFound, name) | |||
| 364 | + | } | |||
| 365 | + | queue = appendManifestDigests(queue, nestedManifests) | |||
| 366 | + | } | |||
| 367 | + | return false, nil | |||
| 368 | + | } | |||
| 369 | + | ||||
| 370 | + | func manifestReferencesTarget(refs manifestReferences, target string) bool { | |||
| 371 | + | return refs.Config.Digest == target || | |||
| 372 | + | descriptorListContains(refs.Layers, target) || | |||
| 373 | + | descriptorListContains(refs.Manifests, target) || | |||
| 374 | + | descriptorListContains(refs.Blobs, target) || | |||
| 375 | + | (refs.Subject != nil && refs.Subject.Digest == target) | |||
| 376 | + | } | |||
| 377 | + | ||||
| 378 | + | func appendManifestDigests(queue []string, descriptors []manifestDescriptor) []string { | |||
| 379 | + | for _, descriptor := range descriptors { | |||
| 380 | + | if descriptor.Digest != "" { | |||
| 381 | + | queue = append(queue, descriptor.Digest) | |||
| 382 | + | } | |||
| 383 | + | } | |||
| 384 | + | return queue | |||
| 385 | + | } | |||
| 386 | + | ||||
| 387 | + | func manifestReferencedDigests(data []byte) []string { | |||
| 388 | + | var refs manifestReferences | |||
| 389 | + | if json.Unmarshal(data, &refs) != nil { | |||
| 390 | + | return nil | |||
| 391 | + | } | |||
| 392 | + | digests := make([]string, 0, 1+len(refs.Layers)+len(refs.Manifests)+len(refs.Blobs)) | |||
| 393 | + | if refs.Config.Digest != "" { | |||
| 394 | + | digests = append(digests, refs.Config.Digest) | |||
| 395 | + | } | |||
| 396 | + | for _, descriptors := range [][]manifestDescriptor{refs.Layers, refs.Manifests, refs.Blobs} { | |||
| 397 | + | for _, descriptor := range descriptors { | |||
| 398 | + | if descriptor.Digest != "" { | |||
| 399 | + | digests = append(digests, descriptor.Digest) | |||
| 400 | + | } | |||
| 401 | + | } | |||
| 402 | + | } | |||
| 403 | + | if refs.Subject != nil && refs.Subject.Digest != "" { | |||
| 404 | + | digests = append(digests, refs.Subject.Digest) | |||
| 405 | + | } | |||
| 406 | + | return digests | |||
| 407 | + | } | |||
| 408 | + | ||||
| 409 | + | func descriptorListContains(descriptors []manifestDescriptor, digest string) bool { | |||
| 410 | + | for _, descriptor := range descriptors { | |||
| 411 | + | if descriptor.Digest == digest { | |||
| 412 | + | return true | |||
| 413 | + | } | |||
| 414 | + | } | |||
| 415 | + | return false | |||
| 416 | + | } | |||
| 417 | + | ||||
| 418 | + | func manifestDigestMatches(reference string, data []byte) (bool, error) { | |||
| 419 | + | algorithm, _, ok := strings.Cut(reference, ":") | |||
| 420 | + | if !ok { | |||
| 421 | + | return false, domain.ErrInvalidDigest | |||
| 422 | + | } | |||
| 423 | + | switch algorithm { | |||
| 424 | + | case "sha256": | |||
| 425 | + | return reference == fmt.Sprintf("sha256:%x", sha256.Sum256(data)), nil | |||
| 426 | + | case "sha512": | |||
| 427 | + | return reference == fmt.Sprintf("sha512:%x", sha512.Sum512(data)), nil | |||
| 428 | + | default: | |||
| 429 | + | return false, domain.ErrInvalidDigest | |||
| 430 | + | } | |||
| 431 | + | } | |||
| 432 | + | ||||
| 265 | // PutBlob stores a blob with the given digest. | 433 | // PutBlob stores a blob with the given digest. | ||
| 266 | func (s *Service) PutBlob(ctx context.Context, digest string, data io.Reader, size int64) error { | 434 | func (s *Service) PutBlob(ctx context.Context, digest string, data io.Reader, size int64) error { | ||
| 435 | + | s.mutationMu.RLock() | |||
| 436 | + | defer s.mutationMu.RUnlock() | |||
| 437 | + | ||||
| 267 | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | 438 | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | ||
| 268 | zerowrap.FieldLayer: "usecase", | 439 | zerowrap.FieldLayer: "usecase", | ||
| 269 | zerowrap.FieldUseCase: "PutBlob", | 440 | zerowrap.FieldUseCase: "PutBlob", | ||
| @@ -275,6 +446,7 @@ func (s *Service) PutBlob(ctx context.Context, digest string, data io.Reader, si | |||||
| 275 | if err := s.blobStorage.PutBlob(digest, data, size); err != nil { | 446 | if err := s.blobStorage.PutBlob(digest, data, size); err != nil { | ||
| 276 | return log.WrapErr(err, "failed to store blob") | 447 | return log.WrapErr(err, "failed to store blob") | ||
| 277 | } | 448 | } | ||
| 449 | + | s.registryState.AddPending(digest, time.Now().UTC()) | |||
| 278 | 450 | ||||
| 279 | log.Info().Msg("blob stored") | 451 | log.Info().Msg("blob stored") | ||
| 280 | return nil | 452 | return nil | ||
M internal/usecase/registry/service_test.go +103 -0
| @@ -3,10 +3,13 @@ package registry | |||
| 3 | 3 | import ( | |
| 4 | 4 | "bytes" | |
| 5 | 5 | "context" | |
| 6 | + | "crypto/sha512" | |
| 6 | 7 | "errors" | |
| 8 | + | "fmt" | |
| 7 | 9 | "io" | |
| 8 | 10 | "strings" | |
| 9 | 11 | "testing" | |
| 12 | + | "time" | |
| 10 | 13 | ||
| 11 | 14 | "github.com/bnema/zerowrap" | |
| 12 | 15 | "github.com/stretchr/testify/assert" | |
| @@ -15,6 +18,7 @@ import ( | |||
| 15 | 18 | ||
| 16 | 19 | "github.com/bnema/gordon/internal/boundaries/out/mocks" | |
| 17 | 20 | "github.com/bnema/gordon/internal/domain" | |
| 21 | + | "github.com/bnema/gordon/internal/usecase/registrystate" | |
| 18 | 22 | ) | |
| 19 | 23 | ||
| 20 | 24 | func testContext() context.Context { | |
| @@ -84,6 +88,105 @@ func TestService_PutManifest_Success(t *testing.T) { | |||
| 84 | 88 | assert.True(t, strings.HasPrefix(digest, "sha256:")) | |
| 85 | 89 | } | |
| 86 | 90 | ||
| 91 | + | func TestService_PutManifest_SHA512DigestDoesNotPublishEvent(t *testing.T) { | |
| 92 | + | blobStorage := mocks.NewMockBlobStorage(t) | |
| 93 | + | manifestStorage := mocks.NewMockManifestStorage(t) | |
| 94 | + | eventBus := mocks.NewMockEventPublisher(t) | |
| 95 | + | state := registrystate.New() | |
| 96 | + | state.AddPending("sha256:config", time.Now().UTC()) | |
| 97 | + | svc := NewService(blobStorage, manifestStorage, eventBus, state) | |
| 98 | + | data := []byte(`{"schemaVersion":2,"config":{"digest":"sha256:config"}}`) | |
| 99 | + | reference := fmt.Sprintf("sha512:%x", sha512.Sum512(data)) | |
| 100 | + | manifest := &domain.Manifest{Name: "myapp", Reference: reference, ContentType: "application/vnd.oci.image.manifest.v1+json", Data: data} | |
| 101 | + | ||
| 102 | + | manifestStorage.EXPECT().PutManifest("myapp", reference, manifest.ContentType, data).Return(nil) | |
| 103 | + | ||
| 104 | + | digest, err := svc.PutManifest(testContext(), manifest) | |
| 105 | + | ||
| 106 | + | require.NoError(t, err) | |
| 107 | + | assert.Equal(t, reference, digest) | |
| 108 | + | assert.Empty(t, state.PendingDigests(time.Now().UTC())) | |
| 109 | + | } | |
| 110 | + | ||
| 111 | + | func TestService_PutManifest_RejectsDigestMismatch(t *testing.T) { | |
| 112 | + | blobStorage := mocks.NewMockBlobStorage(t) | |
| 113 | + | manifestStorage := mocks.NewMockManifestStorage(t) | |
| 114 | + | eventBus := mocks.NewMockEventPublisher(t) | |
| 115 | + | svc := NewService(blobStorage, manifestStorage, eventBus) | |
| 116 | + | ||
| 117 | + | manifest := &domain.Manifest{ | |
| 118 | + | Name: "myapp", | |
| 119 | + | Reference: "sha256:0000000000000000000000000000000000000000000000000000000000000000", | |
| 120 | + | ContentType: "application/vnd.oci.image.manifest.v1+json", | |
| 121 | + | Data: []byte(`{"schemaVersion":2}`), | |
| 122 | + | } | |
| 123 | + | ||
| 124 | + | _, err := svc.PutManifest(testContext(), manifest) | |
| 125 | + | ||
| 126 | + | require.Error(t, err) | |
| 127 | + | assert.ErrorIs(t, err, domain.ErrDigestMismatch) | |
| 128 | + | } | |
| 129 | + | ||
| 130 | + | func TestService_GetBlobPath_RequiresRepositoryReference(t *testing.T) { | |
| 131 | + | const digest = "sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4" | |
| 132 | + | blobStorage := mocks.NewMockBlobStorage(t) | |
| 133 | + | manifestStorage := mocks.NewMockManifestStorage(t) | |
| 134 | + | svc := NewService(blobStorage, manifestStorage, nil) | |
| 135 | + | ||
| 136 | + | manifestStorage.EXPECT().ListTags("allowed").Return([]string{"latest"}, nil) | |
| 137 | + | manifestStorage.EXPECT().GetManifest("allowed", "latest").Return( | |
| 138 | + | []byte(`{"schemaVersion":2,"layers":[{"digest":"`+digest+`"}]}`), | |
| 139 | + | "application/vnd.oci.image.manifest.v1+json", nil, | |
| 140 | + | ) | |
| 141 | + | blobStorage.EXPECT().GetBlobPath(digest).Return("/registry/blob", nil) | |
| 142 | + | ||
| 143 | + | path, err := svc.GetBlobPath(testContext(), "allowed", digest) | |
| 144 | + | require.NoError(t, err) | |
| 145 | + | assert.Equal(t, "/registry/blob", path) | |
| 146 | + | ||
| 147 | + | manifestStorage.EXPECT().ListTags("denied").Return([]string{"latest"}, nil) | |
| 148 | + | manifestStorage.EXPECT().GetManifest("denied", "latest").Return( | |
| 149 | + | []byte(`{"schemaVersion":2,"layers":[]}`), | |
| 150 | + | "application/vnd.oci.image.manifest.v1+json", nil, | |
| 151 | + | ) | |
| 152 | + | ||
| 153 | + | _, err = svc.GetBlobPath(testContext(), "denied", digest) | |
| 154 | + | assert.ErrorIs(t, err, domain.ErrBlobNotFound) | |
| 155 | + | } | |
| 156 | + | ||
| 157 | + | func TestService_GetBlobPath_FollowsSubjectManifest(t *testing.T) { | |
| 158 | + | const target = "sha256:target" | |
| 159 | + | blobStorage := mocks.NewMockBlobStorage(t) | |
| 160 | + | manifestStorage := mocks.NewMockManifestStorage(t) | |
| 161 | + | svc := NewService(blobStorage, manifestStorage, nil) | |
| 162 | + | ||
| 163 | + | manifestStorage.EXPECT().ListTags("artifacts").Return([]string{"latest"}, nil) | |
| 164 | + | manifestStorage.EXPECT().GetManifest("artifacts", "latest").Return( | |
| 165 | + | []byte(`{"subject":{"digest":"sha256:subject"}}`), "application/vnd.oci.artifact.manifest.v1+json", nil, | |
| 166 | + | ) | |
| 167 | + | manifestStorage.EXPECT().GetManifest("artifacts", "sha256:subject").Return( | |
| 168 | + | []byte(`{"config":{"digest":"`+target+`"}}`), "application/vnd.oci.image.manifest.v1+json", nil, | |
| 169 | + | ) | |
| 170 | + | blobStorage.EXPECT().GetBlobPath(target).Return("/registry/target", nil) | |
| 171 | + | ||
| 172 | + | path, err := svc.GetBlobPath(testContext(), "artifacts", target) | |
| 173 | + | ||
| 174 | + | require.NoError(t, err) | |
| 175 | + | assert.Equal(t, "/registry/target", path) | |
| 176 | + | } | |
| 177 | + | ||
| 178 | + | func TestService_GetBlobPath_BoundsManifestTraversal(t *testing.T) { | |
| 179 | + | blobStorage := mocks.NewMockBlobStorage(t) | |
| 180 | + | manifestStorage := mocks.NewMockManifestStorage(t) | |
| 181 | + | svc := NewService(blobStorage, manifestStorage, nil) | |
| 182 | + | tags := make([]string, maxManifestTraversal+1) | |
| 183 | + | manifestStorage.EXPECT().ListTags("busy").Return(tags, nil) | |
| 184 | + | ||
| 185 | + | _, err := svc.GetBlobPath(testContext(), "busy", "sha256:target") | |
| 186 | + | ||
| 187 | + | assert.ErrorIs(t, err, domain.ErrBlobNotFound) | |
| 188 | + | } | |
| 189 | + | ||
| 87 | 190 | func TestService_PutManifest_StorageError(t *testing.T) { | |
| 88 | 191 | blobStorage := mocks.NewMockBlobStorage(t) | |
| 89 | 192 | manifestStorage := mocks.NewMockManifestStorage(t) | |
| @@ -3,10 +3,13 @@ package registry | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "bytes" | 4 | "bytes" | ||
| 5 | "context" | 5 | "context" | ||
| 6 | + | "crypto/sha512" | |||
| 6 | "errors" | 7 | "errors" | ||
| 8 | + | "fmt" | |||
| 7 | "io" | 9 | "io" | ||
| 8 | "strings" | 10 | "strings" | ||
| 9 | "testing" | 11 | "testing" | ||
| 12 | + | "time" | |||
| 10 | 13 | ||||
| 11 | "github.com/bnema/zerowrap" | 14 | "github.com/bnema/zerowrap" | ||
| 12 | "github.com/stretchr/testify/assert" | 15 | "github.com/stretchr/testify/assert" | ||
| @@ -15,6 +18,7 @@ import ( | |||||
| 15 | 18 | ||||
| 16 | "github.com/bnema/gordon/internal/boundaries/out/mocks" | 19 | "github.com/bnema/gordon/internal/boundaries/out/mocks" | ||
| 17 | "github.com/bnema/gordon/internal/domain" | 20 | "github.com/bnema/gordon/internal/domain" | ||
| 21 | + | "github.com/bnema/gordon/internal/usecase/registrystate" | |||
| 18 | ) | 22 | ) | ||
| 19 | 23 | ||||
| 20 | func testContext() context.Context { | 24 | func testContext() context.Context { | ||
| @@ -84,6 +88,105 @@ func TestService_PutManifest_Success(t *testing.T) { | |||||
| 84 | assert.True(t, strings.HasPrefix(digest, "sha256:")) | 88 | assert.True(t, strings.HasPrefix(digest, "sha256:")) | ||
| 85 | } | 89 | } | ||
| 86 | 90 | ||||
| 91 | + | func TestService_PutManifest_SHA512DigestDoesNotPublishEvent(t *testing.T) { | |||
| 92 | + | blobStorage := mocks.NewMockBlobStorage(t) | |||
| 93 | + | manifestStorage := mocks.NewMockManifestStorage(t) | |||
| 94 | + | eventBus := mocks.NewMockEventPublisher(t) | |||
| 95 | + | state := registrystate.New() | |||
| 96 | + | state.AddPending("sha256:config", time.Now().UTC()) | |||
| 97 | + | svc := NewService(blobStorage, manifestStorage, eventBus, state) | |||
| 98 | + | data := []byte(`{"schemaVersion":2,"config":{"digest":"sha256:config"}}`) | |||
| 99 | + | reference := fmt.Sprintf("sha512:%x", sha512.Sum512(data)) | |||
| 100 | + | manifest := &domain.Manifest{Name: "myapp", Reference: reference, ContentType: "application/vnd.oci.image.manifest.v1+json", Data: data} | |||
| 101 | + | ||||
| 102 | + | manifestStorage.EXPECT().PutManifest("myapp", reference, manifest.ContentType, data).Return(nil) | |||
| 103 | + | ||||
| 104 | + | digest, err := svc.PutManifest(testContext(), manifest) | |||
| 105 | + | ||||
| 106 | + | require.NoError(t, err) | |||
| 107 | + | assert.Equal(t, reference, digest) | |||
| 108 | + | assert.Empty(t, state.PendingDigests(time.Now().UTC())) | |||
| 109 | + | } | |||
| 110 | + | ||||
| 111 | + | func TestService_PutManifest_RejectsDigestMismatch(t *testing.T) { | |||
| 112 | + | blobStorage := mocks.NewMockBlobStorage(t) | |||
| 113 | + | manifestStorage := mocks.NewMockManifestStorage(t) | |||
| 114 | + | eventBus := mocks.NewMockEventPublisher(t) | |||
| 115 | + | svc := NewService(blobStorage, manifestStorage, eventBus) | |||
| 116 | + | ||||
| 117 | + | manifest := &domain.Manifest{ | |||
| 118 | + | Name: "myapp", | |||
| 119 | + | Reference: "sha256:0000000000000000000000000000000000000000000000000000000000000000", | |||
| 120 | + | ContentType: "application/vnd.oci.image.manifest.v1+json", | |||
| 121 | + | Data: []byte(`{"schemaVersion":2}`), | |||
| 122 | + | } | |||
| 123 | + | ||||
| 124 | + | _, err := svc.PutManifest(testContext(), manifest) | |||
| 125 | + | ||||
| 126 | + | require.Error(t, err) | |||
| 127 | + | assert.ErrorIs(t, err, domain.ErrDigestMismatch) | |||
| 128 | + | } | |||
| 129 | + | ||||
| 130 | + | func TestService_GetBlobPath_RequiresRepositoryReference(t *testing.T) { | |||
| 131 | + | const digest = "sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4" | |||
| 132 | + | blobStorage := mocks.NewMockBlobStorage(t) | |||
| 133 | + | manifestStorage := mocks.NewMockManifestStorage(t) | |||
| 134 | + | svc := NewService(blobStorage, manifestStorage, nil) | |||
| 135 | + | ||||
| 136 | + | manifestStorage.EXPECT().ListTags("allowed").Return([]string{"latest"}, nil) | |||
| 137 | + | manifestStorage.EXPECT().GetManifest("allowed", "latest").Return( | |||
| 138 | + | []byte(`{"schemaVersion":2,"layers":[{"digest":"`+digest+`"}]}`), | |||
| 139 | + | "application/vnd.oci.image.manifest.v1+json", nil, | |||
| 140 | + | ) | |||
| 141 | + | blobStorage.EXPECT().GetBlobPath(digest).Return("/registry/blob", nil) | |||
| 142 | + | ||||
| 143 | + | path, err := svc.GetBlobPath(testContext(), "allowed", digest) | |||
| 144 | + | require.NoError(t, err) | |||
| 145 | + | assert.Equal(t, "/registry/blob", path) | |||
| 146 | + | ||||
| 147 | + | manifestStorage.EXPECT().ListTags("denied").Return([]string{"latest"}, nil) | |||
| 148 | + | manifestStorage.EXPECT().GetManifest("denied", "latest").Return( | |||
| 149 | + | []byte(`{"schemaVersion":2,"layers":[]}`), | |||
| 150 | + | "application/vnd.oci.image.manifest.v1+json", nil, | |||
| 151 | + | ) | |||
| 152 | + | ||||
| 153 | + | _, err = svc.GetBlobPath(testContext(), "denied", digest) | |||
| 154 | + | assert.ErrorIs(t, err, domain.ErrBlobNotFound) | |||
| 155 | + | } | |||
| 156 | + | ||||
| 157 | + | func TestService_GetBlobPath_FollowsSubjectManifest(t *testing.T) { | |||
| 158 | + | const target = "sha256:target" | |||
| 159 | + | blobStorage := mocks.NewMockBlobStorage(t) | |||
| 160 | + | manifestStorage := mocks.NewMockManifestStorage(t) | |||
| 161 | + | svc := NewService(blobStorage, manifestStorage, nil) | |||
| 162 | + | ||||
| 163 | + | manifestStorage.EXPECT().ListTags("artifacts").Return([]string{"latest"}, nil) | |||
| 164 | + | manifestStorage.EXPECT().GetManifest("artifacts", "latest").Return( | |||
| 165 | + | []byte(`{"subject":{"digest":"sha256:subject"}}`), "application/vnd.oci.artifact.manifest.v1+json", nil, | |||
| 166 | + | ) | |||
| 167 | + | manifestStorage.EXPECT().GetManifest("artifacts", "sha256:subject").Return( | |||
| 168 | + | []byte(`{"config":{"digest":"`+target+`"}}`), "application/vnd.oci.image.manifest.v1+json", nil, | |||
| 169 | + | ) | |||
| 170 | + | blobStorage.EXPECT().GetBlobPath(target).Return("/registry/target", nil) | |||
| 171 | + | ||||
| 172 | + | path, err := svc.GetBlobPath(testContext(), "artifacts", target) | |||
| 173 | + | ||||
| 174 | + | require.NoError(t, err) | |||
| 175 | + | assert.Equal(t, "/registry/target", path) | |||
| 176 | + | } | |||
| 177 | + | ||||
| 178 | + | func TestService_GetBlobPath_BoundsManifestTraversal(t *testing.T) { | |||
| 179 | + | blobStorage := mocks.NewMockBlobStorage(t) | |||
| 180 | + | manifestStorage := mocks.NewMockManifestStorage(t) | |||
| 181 | + | svc := NewService(blobStorage, manifestStorage, nil) | |||
| 182 | + | tags := make([]string, maxManifestTraversal+1) | |||
| 183 | + | manifestStorage.EXPECT().ListTags("busy").Return(tags, nil) | |||
| 184 | + | ||||
| 185 | + | _, err := svc.GetBlobPath(testContext(), "busy", "sha256:target") | |||
| 186 | + | ||||
| 187 | + | assert.ErrorIs(t, err, domain.ErrBlobNotFound) | |||
| 188 | + | } | |||
| 189 | + | ||||
| 87 | func TestService_PutManifest_StorageError(t *testing.T) { | 190 | func TestService_PutManifest_StorageError(t *testing.T) { | ||
| 88 | blobStorage := mocks.NewMockBlobStorage(t) | 191 | blobStorage := mocks.NewMockBlobStorage(t) | ||
| 89 | manifestStorage := mocks.NewMockManifestStorage(t) | 192 | manifestStorage := mocks.NewMockManifestStorage(t) | ||
A internal/usecase/registrystate/state.go +57 -0
| @@ -0,0 +1,57 @@ | |||
| 1 | + | // Package registrystate coordinates registry mutations and garbage collection. | |
| 2 | + | package registrystate | |
| 3 | + | ||
| 4 | + | import ( | |
| 5 | + | "sync" | |
| 6 | + | "time" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | // PendingBlobTTL protects uploaded blobs while clients publish their manifest. | |
| 10 | + | const PendingBlobTTL = 24 * time.Hour | |
| 11 | + | ||
| 12 | + | // State is shared by registry writes and registry garbage collection. | |
| 13 | + | type State struct { | |
| 14 | + | MutationMu sync.RWMutex | |
| 15 | + | ||
| 16 | + | pendingMu sync.Mutex | |
| 17 | + | pending map[string]time.Time | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | // New creates empty registry coordination state. | |
| 21 | + | func New() *State { | |
| 22 | + | return &State{pending: make(map[string]time.Time)} | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | // AddPending records a blob that has been uploaded but not yet referenced by a manifest. | |
| 26 | + | func (s *State) AddPending(digest string, now time.Time) { | |
| 27 | + | s.pendingMu.Lock() | |
| 28 | + | defer s.pendingMu.Unlock() | |
| 29 | + | if s.pending == nil { | |
| 30 | + | s.pending = make(map[string]time.Time) | |
| 31 | + | } | |
| 32 | + | s.pending[digest] = now | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | // MarkPublished removes blobs now owned by a published manifest from the pending set. | |
| 36 | + | func (s *State) MarkPublished(digests []string) { | |
| 37 | + | s.pendingMu.Lock() | |
| 38 | + | defer s.pendingMu.Unlock() | |
| 39 | + | for _, digest := range digests { | |
| 40 | + | delete(s.pending, digest) | |
| 41 | + | } | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | // PendingDigests returns non-expired pending blobs and drops abandoned entries. | |
| 45 | + | func (s *State) PendingDigests(now time.Time) map[string]struct{} { | |
| 46 | + | s.pendingMu.Lock() | |
| 47 | + | defer s.pendingMu.Unlock() | |
| 48 | + | result := make(map[string]struct{}, len(s.pending)) | |
| 49 | + | for digest, createdAt := range s.pending { | |
| 50 | + | if now.Sub(createdAt) > PendingBlobTTL { | |
| 51 | + | delete(s.pending, digest) | |
| 52 | + | continue | |
| 53 | + | } | |
| 54 | + | result[digest] = struct{}{} | |
| 55 | + | } | |
| 56 | + | return result | |
| 57 | + | } | |
| @@ -0,0 +1,57 @@ | |||||
| 1 | + | // Package registrystate coordinates registry mutations and garbage collection. | |||
| 2 | + | package registrystate | |||
| 3 | + | ||||
| 4 | + | import ( | |||
| 5 | + | "sync" | |||
| 6 | + | "time" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | // PendingBlobTTL protects uploaded blobs while clients publish their manifest. | |||
| 10 | + | const PendingBlobTTL = 24 * time.Hour | |||
| 11 | + | ||||
| 12 | + | // State is shared by registry writes and registry garbage collection. | |||
| 13 | + | type State struct { | |||
| 14 | + | MutationMu sync.RWMutex | |||
| 15 | + | ||||
| 16 | + | pendingMu sync.Mutex | |||
| 17 | + | pending map[string]time.Time | |||
| 18 | + | } | |||
| 19 | + | ||||
| 20 | + | // New creates empty registry coordination state. | |||
| 21 | + | func New() *State { | |||
| 22 | + | return &State{pending: make(map[string]time.Time)} | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | // AddPending records a blob that has been uploaded but not yet referenced by a manifest. | |||
| 26 | + | func (s *State) AddPending(digest string, now time.Time) { | |||
| 27 | + | s.pendingMu.Lock() | |||
| 28 | + | defer s.pendingMu.Unlock() | |||
| 29 | + | if s.pending == nil { | |||
| 30 | + | s.pending = make(map[string]time.Time) | |||
| 31 | + | } | |||
| 32 | + | s.pending[digest] = now | |||
| 33 | + | } | |||
| 34 | + | ||||
| 35 | + | // MarkPublished removes blobs now owned by a published manifest from the pending set. | |||
| 36 | + | func (s *State) MarkPublished(digests []string) { | |||
| 37 | + | s.pendingMu.Lock() | |||
| 38 | + | defer s.pendingMu.Unlock() | |||
| 39 | + | for _, digest := range digests { | |||
| 40 | + | delete(s.pending, digest) | |||
| 41 | + | } | |||
| 42 | + | } | |||
| 43 | + | ||||
| 44 | + | // PendingDigests returns non-expired pending blobs and drops abandoned entries. | |||
| 45 | + | func (s *State) PendingDigests(now time.Time) map[string]struct{} { | |||
| 46 | + | s.pendingMu.Lock() | |||
| 47 | + | defer s.pendingMu.Unlock() | |||
| 48 | + | result := make(map[string]struct{}, len(s.pending)) | |||
| 49 | + | for digest, createdAt := range s.pending { | |||
| 50 | + | if now.Sub(createdAt) > PendingBlobTTL { | |||
| 51 | + | delete(s.pending, digest) | |||
| 52 | + | continue | |||
| 53 | + | } | |||
| 54 | + | result[digest] = struct{}{} | |||
| 55 | + | } | |||
| 56 | + | return result | |||
| 57 | + | } | |||
A internal/usecase/registrystate/state_test.go +21 -0
| @@ -0,0 +1,21 @@ | |||
| 1 | + | package registrystate | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | "time" | |
| 6 | + | ||
| 7 | + | "github.com/stretchr/testify/assert" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | func TestStateTracksPublishesAndExpiresPendingBlobs(t *testing.T) { | |
| 11 | + | now := time.Now().UTC() | |
| 12 | + | state := New() | |
| 13 | + | state.AddPending("sha256:published", now) | |
| 14 | + | state.AddPending("sha256:pending", now) | |
| 15 | + | state.AddPending("sha256:expired", now.Add(-PendingBlobTTL-time.Second)) | |
| 16 | + | ||
| 17 | + | state.MarkPublished([]string{"sha256:published"}) | |
| 18 | + | pending := state.PendingDigests(now) | |
| 19 | + | ||
| 20 | + | assert.Equal(t, map[string]struct{}{"sha256:pending": {}}, pending) | |
| 21 | + | } | |
| @@ -0,0 +1,21 @@ | |||||
| 1 | + | package registrystate | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | "time" | |||
| 6 | + | ||||
| 7 | + | "github.com/stretchr/testify/assert" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | func TestStateTracksPublishesAndExpiresPendingBlobs(t *testing.T) { | |||
| 11 | + | now := time.Now().UTC() | |||
| 12 | + | state := New() | |||
| 13 | + | state.AddPending("sha256:published", now) | |||
| 14 | + | state.AddPending("sha256:pending", now) | |||
| 15 | + | state.AddPending("sha256:expired", now.Add(-PendingBlobTTL-time.Second)) | |||
| 16 | + | ||||
| 17 | + | state.MarkPublished([]string{"sha256:published"}) | |||
| 18 | + | pending := state.PendingDigests(now) | |||
| 19 | + | ||||
| 20 | + | assert.Equal(t, map[string]struct{}{"sha256:pending": {}}, pending) | |||
| 21 | + | } | |||
M internal/usecase/services/service.go +41 -4
| @@ -2,10 +2,12 @@ package services | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "bufio" | |
| 5 | + | "bytes" | |
| 5 | 6 | "context" | |
| 6 | 7 | "crypto/sha256" | |
| 7 | 8 | "encoding/hex" | |
| 8 | 9 | "encoding/json" | |
| 10 | + | "errors" | |
| 9 | 11 | "fmt" | |
| 10 | 12 | "io" | |
| 11 | 13 | "maps" | |
| @@ -521,7 +523,7 @@ func tcpReadinessAddress(svc domain.StandaloneService) (string, error) { | |||
| 521 | 523 | } | |
| 522 | 524 | ||
| 523 | 525 | func (s *Service) waitLogReadiness(ctx context.Context, containerID string, svc domain.StandaloneService) error { | |
| 524 | - | ticker := time.NewTicker(10 * time.Millisecond) | |
| 526 | + | ticker := time.NewTicker(250 * time.Millisecond) | |
| 525 | 527 | defer ticker.Stop() | |
| 526 | 528 | var lastErr error | |
| 527 | 529 | for { | |
| @@ -532,6 +534,9 @@ func (s *Service) waitLogReadiness(ctx context.Context, containerID string, svc | |||
| 532 | 534 | if err == nil && found { | |
| 533 | 535 | return nil | |
| 534 | 536 | } | |
| 537 | + | if errors.Is(err, domain.ErrReadinessLogSizeExceeded) { | |
| 538 | + | return err | |
| 539 | + | } | |
| 535 | 540 | if err != nil { | |
| 536 | 541 | lastErr = err | |
| 537 | 542 | } | |
| @@ -551,17 +556,49 @@ func logReadinessTimeoutError(serviceName string, timeoutErr, lastErr error) err | |||
| 551 | 556 | return fmt.Errorf("standalone service %q log readiness timed out: %w", serviceName, timeoutErr) | |
| 552 | 557 | } | |
| 553 | 558 | ||
| 559 | + | const maxReadinessLogSize = 1 << 20 // 1 MiB | |
| 560 | + | ||
| 554 | 561 | func (s *Service) logContains(ctx context.Context, containerID, path, contains string) (bool, error) { | |
| 555 | 562 | reader, err := s.runtime.CopyFromContainer(ctx, containerID, path) | |
| 556 | 563 | if err != nil { | |
| 557 | 564 | return false, err | |
| 558 | 565 | } | |
| 559 | 566 | defer reader.Close() | |
| 560 | - | content, err := io.ReadAll(reader) | |
| 561 | - | if err != nil { | |
| 567 | + | if contains == "" { | |
| 568 | + | return true, nil | |
| 569 | + | } | |
| 570 | + | ||
| 571 | + | needle := []byte(contains) | |
| 572 | + | buffer := make([]byte, 32*1024) | |
| 573 | + | carry := make([]byte, 0, len(needle)-1) | |
| 574 | + | remaining := maxReadinessLogSize | |
| 575 | + | for remaining > 0 { | |
| 576 | + | readSize := min(len(buffer), remaining) | |
| 577 | + | n, readErr := reader.Read(buffer[:readSize]) | |
| 578 | + | if n > 0 { | |
| 579 | + | window := append(carry, buffer[:n]...) | |
| 580 | + | if bytes.Contains(window, needle) { | |
| 581 | + | return true, nil | |
| 582 | + | } | |
| 583 | + | overlap := min(len(needle)-1, len(window)) | |
| 584 | + | carry = append(carry[:0], window[len(window)-overlap:]...) | |
| 585 | + | remaining -= n | |
| 586 | + | } | |
| 587 | + | if errors.Is(readErr, io.EOF) { | |
| 588 | + | return false, nil | |
| 589 | + | } | |
| 590 | + | if readErr != nil { | |
| 591 | + | return false, readErr | |
| 592 | + | } | |
| 593 | + | } | |
| 594 | + | ||
| 595 | + | var extra [1]byte | |
| 596 | + | if _, err := reader.Read(extra[:]); errors.Is(err, io.EOF) { | |
| 597 | + | return false, nil | |
| 598 | + | } else if err != nil { | |
| 562 | 599 | return false, err | |
| 563 | 600 | } | |
| 564 | - | return strings.Contains(string(content), contains), nil | |
| 601 | + | return false, fmt.Errorf("%w: limit is %d bytes", domain.ErrReadinessLogSizeExceeded, maxReadinessLogSize) | |
| 565 | 602 | } | |
| 566 | 603 | ||
| 567 | 604 | func normalizeCleanup(cleanup domain.StandaloneServiceCleanup) domain.StandaloneServiceCleanup { | |
| @@ -2,10 +2,12 @@ package services | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "bufio" | 4 | "bufio" | ||
| 5 | + | "bytes" | |||
| 5 | "context" | 6 | "context" | ||
| 6 | "crypto/sha256" | 7 | "crypto/sha256" | ||
| 7 | "encoding/hex" | 8 | "encoding/hex" | ||
| 8 | "encoding/json" | 9 | "encoding/json" | ||
| 10 | + | "errors" | |||
| 9 | "fmt" | 11 | "fmt" | ||
| 10 | "io" | 12 | "io" | ||
| 11 | "maps" | 13 | "maps" | ||
| @@ -521,7 +523,7 @@ func tcpReadinessAddress(svc domain.StandaloneService) (string, error) { | |||||
| 521 | } | 523 | } | ||
| 522 | 524 | ||||
| 523 | func (s *Service) waitLogReadiness(ctx context.Context, containerID string, svc domain.StandaloneService) error { | 525 | func (s *Service) waitLogReadiness(ctx context.Context, containerID string, svc domain.StandaloneService) error { | ||
| 524 | - | ticker := time.NewTicker(10 * time.Millisecond) | 526 | + | ticker := time.NewTicker(250 * time.Millisecond) |
| 525 | defer ticker.Stop() | 527 | defer ticker.Stop() | ||
| 526 | var lastErr error | 528 | var lastErr error | ||
| 527 | for { | 529 | for { | ||
| @@ -532,6 +534,9 @@ func (s *Service) waitLogReadiness(ctx context.Context, containerID string, svc | |||||
| 532 | if err == nil && found { | 534 | if err == nil && found { | ||
| 533 | return nil | 535 | return nil | ||
| 534 | } | 536 | } | ||
| 537 | + | if errors.Is(err, domain.ErrReadinessLogSizeExceeded) { | |||
| 538 | + | return err | |||
| 539 | + | } | |||
| 535 | if err != nil { | 540 | if err != nil { | ||
| 536 | lastErr = err | 541 | lastErr = err | ||
| 537 | } | 542 | } | ||
| @@ -551,17 +556,49 @@ func logReadinessTimeoutError(serviceName string, timeoutErr, lastErr error) err | |||||
| 551 | return fmt.Errorf("standalone service %q log readiness timed out: %w", serviceName, timeoutErr) | 556 | return fmt.Errorf("standalone service %q log readiness timed out: %w", serviceName, timeoutErr) | ||
| 552 | } | 557 | } | ||
| 553 | 558 | ||||
| 559 | + | const maxReadinessLogSize = 1 << 20 // 1 MiB | |||
| 560 | + | ||||
| 554 | func (s *Service) logContains(ctx context.Context, containerID, path, contains string) (bool, error) { | 561 | func (s *Service) logContains(ctx context.Context, containerID, path, contains string) (bool, error) { | ||
| 555 | reader, err := s.runtime.CopyFromContainer(ctx, containerID, path) | 562 | reader, err := s.runtime.CopyFromContainer(ctx, containerID, path) | ||
| 556 | if err != nil { | 563 | if err != nil { | ||
| 557 | return false, err | 564 | return false, err | ||
| 558 | } | 565 | } | ||
| 559 | defer reader.Close() | 566 | defer reader.Close() | ||
| 560 | - | content, err := io.ReadAll(reader) | 567 | + | if contains == "" { |
| 561 | - | if err != nil { | 568 | + | return true, nil |
| 569 | + | } | |||
| 570 | + | ||||
| 571 | + | needle := []byte(contains) | |||
| 572 | + | buffer := make([]byte, 32*1024) | |||
| 573 | + | carry := make([]byte, 0, len(needle)-1) | |||
| 574 | + | remaining := maxReadinessLogSize | |||
| 575 | + | for remaining > 0 { | |||
| 576 | + | readSize := min(len(buffer), remaining) | |||
| 577 | + | n, readErr := reader.Read(buffer[:readSize]) | |||
| 578 | + | if n > 0 { | |||
| 579 | + | window := append(carry, buffer[:n]...) | |||
| 580 | + | if bytes.Contains(window, needle) { | |||
| 581 | + | return true, nil | |||
| 582 | + | } | |||
| 583 | + | overlap := min(len(needle)-1, len(window)) | |||
| 584 | + | carry = append(carry[:0], window[len(window)-overlap:]...) | |||
| 585 | + | remaining -= n | |||
| 586 | + | } | |||
| 587 | + | if errors.Is(readErr, io.EOF) { | |||
| 588 | + | return false, nil | |||
| 589 | + | } | |||
| 590 | + | if readErr != nil { | |||
| 591 | + | return false, readErr | |||
| 592 | + | } | |||
| 593 | + | } | |||
| 594 | + | ||||
| 595 | + | var extra [1]byte | |||
| 596 | + | if _, err := reader.Read(extra[:]); errors.Is(err, io.EOF) { | |||
| 597 | + | return false, nil | |||
| 598 | + | } else if err != nil { | |||
| 562 | return false, err | 599 | return false, err | ||
| 563 | } | 600 | } | ||
| 564 | - | return strings.Contains(string(content), contains), nil | 601 | + | return false, fmt.Errorf("%w: limit is %d bytes", domain.ErrReadinessLogSizeExceeded, maxReadinessLogSize) |
| 565 | } | 602 | } | ||
| 566 | 603 | ||||
| 567 | func normalizeCleanup(cleanup domain.StandaloneServiceCleanup) domain.StandaloneServiceCleanup { | 604 | func normalizeCleanup(cleanup domain.StandaloneServiceCleanup) domain.StandaloneServiceCleanup { | ||
M internal/usecase/services/service_test.go +26 -0
| @@ -387,6 +387,32 @@ func TestWaitLogReadinessReadsContainerFileUntilTextAppears(t *testing.T) { | |||
| 387 | 387 | require.NoError(t, err) | |
| 388 | 388 | } | |
| 389 | 389 | ||
| 390 | + | func TestLogContainsStopsAtMatchBeforeSizeLimit(t *testing.T) { | |
| 391 | + | runtime := outmocks.NewMockContainerRuntime(t) | |
| 392 | + | svc := NewService(runtime) | |
| 393 | + | runtime.EXPECT().CopyFromContainer(mock.Anything, "container-1", "/logs/server.log").Return( | |
| 394 | + | io.NopCloser(strings.NewReader("ready\n"+strings.Repeat("x", maxReadinessLogSize))), nil, | |
| 395 | + | ) | |
| 396 | + | ||
| 397 | + | found, err := svc.logContains(context.Background(), "container-1", "/logs/server.log", "ready") | |
| 398 | + | ||
| 399 | + | require.NoError(t, err) | |
| 400 | + | assert.True(t, found) | |
| 401 | + | } | |
| 402 | + | ||
| 403 | + | func TestLogContainsReturnsSentinelAtSizeLimit(t *testing.T) { | |
| 404 | + | runtime := outmocks.NewMockContainerRuntime(t) | |
| 405 | + | svc := NewService(runtime) | |
| 406 | + | runtime.EXPECT().CopyFromContainer(mock.Anything, "container-1", "/logs/server.log").Return( | |
| 407 | + | io.NopCloser(strings.NewReader(strings.Repeat("x", maxReadinessLogSize+1))), nil, | |
| 408 | + | ) | |
| 409 | + | ||
| 410 | + | found, err := svc.logContains(context.Background(), "container-1", "/logs/server.log", "ready") | |
| 411 | + | ||
| 412 | + | assert.False(t, found) | |
| 413 | + | assert.ErrorIs(t, err, domain.ErrReadinessLogSizeExceeded) | |
| 414 | + | } | |
| 415 | + | ||
| 390 | 416 | func TestWaitReadinessHonorsContextTimeout(t *testing.T) { | |
| 391 | 417 | svc := sampleService() | |
| 392 | 418 | svc.Readiness = domain.StandaloneServiceReadiness{Type: domain.StandaloneServiceReadinessTCP} | |
| @@ -387,6 +387,32 @@ func TestWaitLogReadinessReadsContainerFileUntilTextAppears(t *testing.T) { | |||||
| 387 | require.NoError(t, err) | 387 | require.NoError(t, err) | ||
| 388 | } | 388 | } | ||
| 389 | 389 | ||||
| 390 | + | func TestLogContainsStopsAtMatchBeforeSizeLimit(t *testing.T) { | |||
| 391 | + | runtime := outmocks.NewMockContainerRuntime(t) | |||
| 392 | + | svc := NewService(runtime) | |||
| 393 | + | runtime.EXPECT().CopyFromContainer(mock.Anything, "container-1", "/logs/server.log").Return( | |||
| 394 | + | io.NopCloser(strings.NewReader("ready\n"+strings.Repeat("x", maxReadinessLogSize))), nil, | |||
| 395 | + | ) | |||
| 396 | + | ||||
| 397 | + | found, err := svc.logContains(context.Background(), "container-1", "/logs/server.log", "ready") | |||
| 398 | + | ||||
| 399 | + | require.NoError(t, err) | |||
| 400 | + | assert.True(t, found) | |||
| 401 | + | } | |||
| 402 | + | ||||
| 403 | + | func TestLogContainsReturnsSentinelAtSizeLimit(t *testing.T) { | |||
| 404 | + | runtime := outmocks.NewMockContainerRuntime(t) | |||
| 405 | + | svc := NewService(runtime) | |||
| 406 | + | runtime.EXPECT().CopyFromContainer(mock.Anything, "container-1", "/logs/server.log").Return( | |||
| 407 | + | io.NopCloser(strings.NewReader(strings.Repeat("x", maxReadinessLogSize+1))), nil, | |||
| 408 | + | ) | |||
| 409 | + | ||||
| 410 | + | found, err := svc.logContains(context.Background(), "container-1", "/logs/server.log", "ready") | |||
| 411 | + | ||||
| 412 | + | assert.False(t, found) | |||
| 413 | + | assert.ErrorIs(t, err, domain.ErrReadinessLogSizeExceeded) | |||
| 414 | + | } | |||
| 415 | + | ||||
| 390 | func TestWaitReadinessHonorsContextTimeout(t *testing.T) { | 416 | func TestWaitReadinessHonorsContextTimeout(t *testing.T) { | ||
| 391 | svc := sampleService() | 417 | svc := sampleService() | ||
| 392 | svc.Readiness = domain.StandaloneServiceReadiness{Type: domain.StandaloneServiceReadinessTCP} | 418 | svc.Readiness = domain.StandaloneServiceReadiness{Type: domain.StandaloneServiceReadinessTCP} | ||