fix(security): remediate codex scan findings
- commit
- 271c36a4cc32fabd91b20f93cd006bdd2a01b181
- author
- brice · · 2026-08-10 20:50 UTC
- parent
- 58a92f3
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
31 files · +638 -217
M .github/actions/deploy/README.md +1 -13
| @@ -64,9 +64,6 @@ jobs: | |||
| 64 | 64 | image: myapp # Custom image name | |
| 65 | 65 | dockerfile: ./docker/Dockerfile # Custom Dockerfile path | |
| 66 | 66 | context: . # Build context | |
| 67 | - | build-args: | # Build arguments | |
| 68 | - | NODE_ENV=production | |
| 69 | - | API_URL=https://api.example.com | |
| 70 | 67 | platforms: linux/amd64,linux/arm64 # Multi-platform | |
| 71 | 68 | push-latest: 'true' # Also push :latest | |
| 72 | 69 | ||
| @@ -88,7 +85,6 @@ jobs: | |||
| 88 | 85 | | `tag` | Override image tag | No | Git tag or short SHA | | |
| 89 | 86 | | `dockerfile` | Path to Dockerfile | No | `./Dockerfile` | | |
| 90 | 87 | | `context` | Build context path | No | `.` | | |
| 91 | - | | `build-args` | Build arguments (one per line) | No | - | | |
| 92 | 88 | | `platforms` | Target platforms | No | - | | |
| 93 | 89 | | `push-latest` | Also push with `:latest` tag | No | `true` | | |
| 94 | 90 | | `cache-from` | Cache source | No | `type=gha` | | |
| @@ -204,15 +200,7 @@ jobs: | |||
| 204 | 200 | ||
| 205 | 201 | ### Build with Secrets (BuildKit) | |
| 206 | 202 | ||
| 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 | - | ``` | |
| 203 | + | 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 | 204 | ||
| 217 | 205 | ## Troubleshooting | |
| 218 | 206 | ||
| @@ -64,9 +64,6 @@ jobs: | |||||
| 64 | image: myapp # Custom image name | 64 | image: myapp # Custom image name | ||
| 65 | dockerfile: ./docker/Dockerfile # Custom Dockerfile path | 65 | dockerfile: ./docker/Dockerfile # Custom Dockerfile path | ||
| 66 | context: . # Build context | 66 | 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 | 67 | platforms: linux/amd64,linux/arm64 # Multi-platform | ||
| 71 | push-latest: 'true' # Also push :latest | 68 | push-latest: 'true' # Also push :latest | ||
| 72 | 69 | ||||
| @@ -88,7 +85,6 @@ jobs: | |||||
| 88 | | `tag` | Override image tag | No | Git tag or short SHA | | 85 | | `tag` | Override image tag | No | Git tag or short SHA | | ||
| 89 | | `dockerfile` | Path to Dockerfile | No | `./Dockerfile` | | 86 | | `dockerfile` | Path to Dockerfile | No | `./Dockerfile` | | ||
| 90 | | `context` | Build context path | No | `.` | | 87 | | `context` | Build context path | No | `.` | | ||
| 91 | - | | `build-args` | Build arguments (one per line) | No | - | | |||
| 92 | | `platforms` | Target platforms | No | - | | 88 | | `platforms` | Target platforms | No | - | | ||
| 93 | | `push-latest` | Also push with `:latest` tag | No | `true` | | 89 | | `push-latest` | Also push with `:latest` tag | No | `true` | | ||
| 94 | | `cache-from` | Cache source | No | `type=gha` | | 90 | | `cache-from` | Cache source | No | `type=gha` | | ||
| @@ -204,15 +200,7 @@ jobs: | |||||
| 204 | 200 | ||||
| 205 | ### Build with Secrets (BuildKit) | 201 | ### Build with Secrets (BuildKit) | ||
| 206 | 202 | ||||
| 207 | - | ```yaml | 203 | + | 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 | 204 | ||||
| 217 | ## Troubleshooting | 205 | ## Troubleshooting | ||
| 218 | 206 | ||||
M .github/actions/deploy/action.yml +34 -26
| @@ -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,25 +65,31 @@ 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 | 84 | # Determine image name | |
| 83 | - | IMAGE_NAME="${{ inputs.image }}" | |
| 85 | + | IMAGE_NAME="${INPUT_IMAGE}" | |
| 84 | 86 | if [[ -z "$IMAGE_NAME" ]]; then | |
| 85 | 87 | IMAGE_NAME="${GITHUB_REPOSITORY#*/}" | |
| 86 | 88 | fi | |
| 87 | 89 | ||
| 88 | 90 | # Extract tag from ref (or override) | |
| 89 | - | if [[ -n "${{ inputs.tag }}" ]]; then | |
| 90 | - | TAG="${{ inputs.tag }}" | |
| 91 | + | if [[ -n "${INPUT_TAG}" ]]; then | |
| 92 | + | TAG="${INPUT_TAG}" | |
| 91 | 93 | elif [[ "$GITHUB_REF" == refs/tags/* ]]; then | |
| 92 | 94 | TAG="${GITHUB_REF#refs/tags/}" | |
| 93 | 95 | else | |
| @@ -95,23 +97,21 @@ runs: | |||
| 95 | 97 | fi | |
| 96 | 98 | ||
| 97 | 99 | # Full image reference | |
| 98 | - | FULL_IMAGE="${{ inputs.registry }}/${IMAGE_NAME}:${TAG}" | |
| 100 | + | FULL_IMAGE="${INPUT_REGISTRY}/${IMAGE_NAME}:${TAG}" | |
| 99 | 101 | ||
| 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 | |
| 102 | + | printf 'image=%s\nimage_name=%s\ntag=%s\nregistry=%s\n' \ | |
| 103 | + | "${FULL_IMAGE}" "${IMAGE_NAME}" "${TAG}" "${INPUT_REGISTRY}" >> "${GITHUB_OUTPUT}" | |
| 104 | 104 | ||
| 105 | 105 | echo "Building: ${FULL_IMAGE}" | |
| 106 | 106 | ||
| 107 | 107 | - name: Set up QEMU | |
| 108 | - | uses: docker/setup-qemu-action@v3 | |
| 108 | + | uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3 | |
| 109 | 109 | ||
| 110 | 110 | - name: Set up Docker Buildx | |
| 111 | - | uses: docker/setup-buildx-action@v3 | |
| 111 | + | uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 | |
| 112 | 112 | ||
| 113 | 113 | - name: Log in to Gordon registry | |
| 114 | - | uses: docker/login-action@v3 | |
| 114 | + | uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3 | |
| 115 | 115 | with: | |
| 116 | 116 | registry: ${{ inputs.registry }} | |
| 117 | 117 | username: ${{ inputs.username }} | |
| @@ -120,16 +120,21 @@ runs: | |||
| 120 | 120 | - name: Build tags | |
| 121 | 121 | id: tags | |
| 122 | 122 | shell: bash | |
| 123 | + | env: | |
| 124 | + | META_IMAGE: ${{ steps.meta.outputs.image }} | |
| 125 | + | META_IMAGE_NAME: ${{ steps.meta.outputs.image_name }} | |
| 126 | + | INPUT_REGISTRY: ${{ inputs.registry }} | |
| 127 | + | INPUT_PUSH_LATEST: ${{ inputs.push-latest }} | |
| 123 | 128 | 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" | |
| 129 | + | TAGS="${META_IMAGE}" | |
| 130 | + | if [[ "${INPUT_PUSH_LATEST}" == "true" ]]; then | |
| 131 | + | TAGS="${TAGS}"$'\n'"${INPUT_REGISTRY}/${META_IMAGE_NAME}:latest" | |
| 127 | 132 | fi | |
| 128 | 133 | printf "tags<<EOF\n%s\nEOF\n" "$TAGS" >> "$GITHUB_OUTPUT" | |
| 129 | 134 | ||
| 130 | 135 | - name: Build and push | |
| 131 | 136 | id: push | |
| 132 | - | uses: docker/build-push-action@v6 | |
| 137 | + | uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6 | |
| 133 | 138 | env: | |
| 134 | 139 | DOCKER_BUILD_SUMMARY: false | |
| 135 | 140 | with: | |
| @@ -137,7 +142,6 @@ runs: | |||
| 137 | 142 | file: ${{ inputs.dockerfile }} | |
| 138 | 143 | push: true | |
| 139 | 144 | tags: ${{ steps.tags.outputs.tags }} | |
| 140 | - | build-args: ${{ inputs.build-args }} | |
| 141 | 145 | platforms: ${{ inputs.platforms }} | |
| 142 | 146 | cache-from: ${{ inputs.cache-from }} | |
| 143 | 147 | cache-to: ${{ inputs.cache-to }} | |
| @@ -145,13 +149,17 @@ runs: | |||
| 145 | 149 | ||
| 146 | 150 | - name: Summary | |
| 147 | 151 | shell: bash | |
| 152 | + | env: | |
| 153 | + | META_IMAGE: ${{ steps.meta.outputs.image }} | |
| 154 | + | META_TAG: ${{ steps.meta.outputs.tag }} | |
| 155 | + | PUSH_DIGEST: ${{ steps.push.outputs.digest }} | |
| 148 | 156 | run: | | |
| 149 | - | echo "## Gordon Deploy Summary" >> $GITHUB_STEP_SUMMARY | |
| 157 | + | echo "## Gordon Deploy Summary" >> "$GITHUB_STEP_SUMMARY" | |
| 150 | 158 | echo "" >> $GITHUB_STEP_SUMMARY | |
| 151 | 159 | echo "| Field | Value |" >> $GITHUB_STEP_SUMMARY | |
| 152 | 160 | 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 | |
| 161 | + | printf '| Image | `%s` |\n' "$META_IMAGE" >> "$GITHUB_STEP_SUMMARY" | |
| 162 | + | printf '| Tag | `%s` |\n' "$META_TAG" >> "$GITHUB_STEP_SUMMARY" | |
| 163 | + | printf '| Digest | `%s` |\n' "$PUSH_DIGEST" >> "$GITHUB_STEP_SUMMARY" | |
| 156 | 164 | echo "" >> $GITHUB_STEP_SUMMARY | |
| 157 | 165 | 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,25 +65,31 @@ 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 | # Determine image name | ||
| 83 | - | IMAGE_NAME="${{ inputs.image }}" | 85 | + | IMAGE_NAME="${INPUT_IMAGE}" |
| 84 | if [[ -z "$IMAGE_NAME" ]]; then | 86 | if [[ -z "$IMAGE_NAME" ]]; then | ||
| 85 | IMAGE_NAME="${GITHUB_REPOSITORY#*/}" | 87 | IMAGE_NAME="${GITHUB_REPOSITORY#*/}" | ||
| 86 | fi | 88 | fi | ||
| 87 | 89 | ||||
| 88 | # Extract tag from ref (or override) | 90 | # Extract tag from ref (or override) | ||
| 89 | - | if [[ -n "${{ inputs.tag }}" ]]; then | 91 | + | if [[ -n "${INPUT_TAG}" ]]; then |
| 90 | - | TAG="${{ inputs.tag }}" | 92 | + | TAG="${INPUT_TAG}" |
| 91 | elif [[ "$GITHUB_REF" == refs/tags/* ]]; then | 93 | elif [[ "$GITHUB_REF" == refs/tags/* ]]; then | ||
| 92 | TAG="${GITHUB_REF#refs/tags/}" | 94 | TAG="${GITHUB_REF#refs/tags/}" | ||
| 93 | else | 95 | else | ||
| @@ -95,23 +97,21 @@ runs: | |||||
| 95 | fi | 97 | fi | ||
| 96 | 98 | ||||
| 97 | # Full image reference | 99 | # Full image reference | ||
| 98 | - | FULL_IMAGE="${{ inputs.registry }}/${IMAGE_NAME}:${TAG}" | 100 | + | FULL_IMAGE="${INPUT_REGISTRY}/${IMAGE_NAME}:${TAG}" |
| 99 | 101 | ||||
| 100 | - | echo "image=${FULL_IMAGE}" >> $GITHUB_OUTPUT | 102 | + | printf 'image=%s\nimage_name=%s\ntag=%s\nregistry=%s\n' \ |
| 101 | - | echo "image_name=${IMAGE_NAME}" >> $GITHUB_OUTPUT | 103 | + | "${FULL_IMAGE}" "${IMAGE_NAME}" "${TAG}" "${INPUT_REGISTRY}" >> "${GITHUB_OUTPUT}" |
| 102 | - | echo "tag=${TAG}" >> $GITHUB_OUTPUT | |||
| 103 | - | echo "registry=${{ inputs.registry }}" >> $GITHUB_OUTPUT | |||
| 104 | 104 | ||||
| 105 | echo "Building: ${FULL_IMAGE}" | 105 | echo "Building: ${FULL_IMAGE}" | ||
| 106 | 106 | ||||
| 107 | - name: Set up QEMU | 107 | - name: Set up QEMU | ||
| 108 | - | uses: docker/setup-qemu-action@v3 | 108 | + | uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3 |
| 109 | 109 | ||||
| 110 | - name: Set up Docker Buildx | 110 | - name: Set up Docker Buildx | ||
| 111 | - | uses: docker/setup-buildx-action@v3 | 111 | + | uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 |
| 112 | 112 | ||||
| 113 | - name: Log in to Gordon registry | 113 | - name: Log in to Gordon registry | ||
| 114 | - | uses: docker/login-action@v3 | 114 | + | uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3 |
| 115 | with: | 115 | with: | ||
| 116 | registry: ${{ inputs.registry }} | 116 | registry: ${{ inputs.registry }} | ||
| 117 | username: ${{ inputs.username }} | 117 | username: ${{ inputs.username }} | ||
| @@ -120,16 +120,21 @@ runs: | |||||
| 120 | - name: Build tags | 120 | - name: Build tags | ||
| 121 | id: tags | 121 | id: tags | ||
| 122 | shell: bash | 122 | shell: bash | ||
| 123 | + | env: | |||
| 124 | + | META_IMAGE: ${{ steps.meta.outputs.image }} | |||
| 125 | + | META_IMAGE_NAME: ${{ steps.meta.outputs.image_name }} | |||
| 126 | + | INPUT_REGISTRY: ${{ inputs.registry }} | |||
| 127 | + | INPUT_PUSH_LATEST: ${{ inputs.push-latest }} | |||
| 123 | run: | | 128 | run: | | ||
| 124 | - | TAGS="${{ steps.meta.outputs.image }}" | 129 | + | TAGS="${META_IMAGE}" |
| 125 | - | if [[ "${{ inputs.push-latest }}" == "true" ]]; then | 130 | + | if [[ "${INPUT_PUSH_LATEST}" == "true" ]]; then |
| 126 | - | TAGS="${TAGS}"$'\n'"${{ inputs.registry }}/${{ steps.meta.outputs.image_name }}:latest" | 131 | + | TAGS="${TAGS}"$'\n'"${INPUT_REGISTRY}/${META_IMAGE_NAME}:latest" |
| 127 | fi | 132 | fi | ||
| 128 | printf "tags<<EOF\n%s\nEOF\n" "$TAGS" >> "$GITHUB_OUTPUT" | 133 | printf "tags<<EOF\n%s\nEOF\n" "$TAGS" >> "$GITHUB_OUTPUT" | ||
| 129 | 134 | ||||
| 130 | - name: Build and push | 135 | - name: Build and push | ||
| 131 | id: push | 136 | id: push | ||
| 132 | - | uses: docker/build-push-action@v6 | 137 | + | uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6 |
| 133 | env: | 138 | env: | ||
| 134 | DOCKER_BUILD_SUMMARY: false | 139 | DOCKER_BUILD_SUMMARY: false | ||
| 135 | with: | 140 | with: | ||
| @@ -137,7 +142,6 @@ runs: | |||||
| 137 | file: ${{ inputs.dockerfile }} | 142 | file: ${{ inputs.dockerfile }} | ||
| 138 | push: true | 143 | push: true | ||
| 139 | tags: ${{ steps.tags.outputs.tags }} | 144 | tags: ${{ steps.tags.outputs.tags }} | ||
| 140 | - | build-args: ${{ inputs.build-args }} | |||
| 141 | platforms: ${{ inputs.platforms }} | 145 | platforms: ${{ inputs.platforms }} | ||
| 142 | cache-from: ${{ inputs.cache-from }} | 146 | cache-from: ${{ inputs.cache-from }} | ||
| 143 | cache-to: ${{ inputs.cache-to }} | 147 | cache-to: ${{ inputs.cache-to }} | ||
| @@ -145,13 +149,17 @@ runs: | |||||
| 145 | 149 | ||||
| 146 | - name: Summary | 150 | - name: Summary | ||
| 147 | shell: bash | 151 | shell: bash | ||
| 152 | + | env: | |||
| 153 | + | META_IMAGE: ${{ steps.meta.outputs.image }} | |||
| 154 | + | META_TAG: ${{ steps.meta.outputs.tag }} | |||
| 155 | + | PUSH_DIGEST: ${{ steps.push.outputs.digest }} | |||
| 148 | run: | | 156 | run: | | ||
| 149 | - | echo "## Gordon Deploy Summary" >> $GITHUB_STEP_SUMMARY | 157 | + | echo "## Gordon Deploy Summary" >> "$GITHUB_STEP_SUMMARY" |
| 150 | echo "" >> $GITHUB_STEP_SUMMARY | 158 | echo "" >> $GITHUB_STEP_SUMMARY | ||
| 151 | echo "| Field | Value |" >> $GITHUB_STEP_SUMMARY | 159 | echo "| Field | Value |" >> $GITHUB_STEP_SUMMARY | ||
| 152 | echo "|-------|-------|" >> $GITHUB_STEP_SUMMARY | 160 | echo "|-------|-------|" >> $GITHUB_STEP_SUMMARY | ||
| 153 | - | echo "| Image | \`${{ steps.meta.outputs.image }}\` |" >> $GITHUB_STEP_SUMMARY | 161 | + | printf '| Image | `%s` |\n' "$META_IMAGE" >> "$GITHUB_STEP_SUMMARY" |
| 154 | - | echo "| Tag | \`${{ steps.meta.outputs.tag }}\` |" >> $GITHUB_STEP_SUMMARY | 162 | + | printf '| Tag | `%s` |\n' "$META_TAG" >> "$GITHUB_STEP_SUMMARY" |
| 155 | - | echo "| Digest | \`${{ steps.push.outputs.digest }}\` |" >> $GITHUB_STEP_SUMMARY | 163 | + | printf '| Digest | `%s` |\n' "$PUSH_DIGEST" >> "$GITHUB_STEP_SUMMARY" |
| 156 | echo "" >> $GITHUB_STEP_SUMMARY | 164 | echo "" >> $GITHUB_STEP_SUMMARY | ||
| 157 | echo "Container deployed to Gordon registry and will be automatically deployed." >> $GITHUB_STEP_SUMMARY | 165 | echo "Container deployed to Gordon registry and will be automatically deployed." >> $GITHUB_STEP_SUMMARY | ||
M .github/workflows/release.yml +3 -3
| @@ -13,16 +13,16 @@ 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 | 19 | ||
| 20 | - | - uses: actions/setup-go@v5 | |
| 20 | + | - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5 | |
| 21 | 21 | with: | |
| 22 | 22 | go-version: stable | |
| 23 | 23 | ||
| 24 | 24 | - name: Run GoReleaser | |
| 25 | - | uses: goreleaser/goreleaser-action@v6 | |
| 25 | + | uses: goreleaser/goreleaser-action@e435ccd777264be153ace6237001ef4d979d3a7a # v6 | |
| 26 | 26 | with: | |
| 27 | 27 | distribution: goreleaser | |
| 28 | 28 | version: "~> v2" | |
| @@ -13,16 +13,16 @@ 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 | 19 | ||||
| 20 | - | - uses: actions/setup-go@v5 | 20 | + | - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5 |
| 21 | with: | 21 | with: | ||
| 22 | go-version: stable | 22 | go-version: stable | ||
| 23 | 23 | ||||
| 24 | - name: Run GoReleaser | 24 | - name: Run GoReleaser | ||
| 25 | - | uses: goreleaser/goreleaser-action@v6 | 25 | + | uses: goreleaser/goreleaser-action@e435ccd777264be153ace6237001ef4d979d3a7a # v6 |
| 26 | with: | 26 | with: | ||
| 27 | distribution: goreleaser | 27 | distribution: goreleaser | ||
| 28 | version: "~> v2" | 28 | 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 +16 -9
| @@ -147,21 +147,28 @@ 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 | + | docker pull "$GORDON_REGISTRY/myapp:$VERSION" | |
| 163 | + | docker tag "$GORDON_REGISTRY/myapp:$VERSION" "$GORDON_REGISTRY/myapp:latest" | |
| 164 | + | docker push "$GORDON_REGISTRY/myapp:latest" | |
| 160 | 165 | ||
| 161 | 166 | - name: Summary | |
| 167 | + | env: | |
| 168 | + | VERSION: ${{ inputs.version }} | |
| 162 | 169 | run: | | |
| 163 | - | echo "## Rollback Complete" >> $GITHUB_STEP_SUMMARY | |
| 164 | - | echo "Rolled back to version: ${{ github.event.inputs.version }}" >> $GITHUB_STEP_SUMMARY | |
| 170 | + | echo "## Rollback Complete" >> "$GITHUB_STEP_SUMMARY" | |
| 171 | + | printf 'Rolled back to version: %s\n' "$VERSION" >> "$GITHUB_STEP_SUMMARY" | |
| 165 | 172 | ``` | |
| 166 | 173 | ||
| 167 | 174 | ### Rollback Script | |
| @@ -147,21 +147,28 @@ 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 | + | docker pull "$GORDON_REGISTRY/myapp:$VERSION" |
| 158 | - | ${{ secrets.GORDON_REGISTRY }}/myapp:latest | 163 | + | docker tag "$GORDON_REGISTRY/myapp:$VERSION" "$GORDON_REGISTRY/myapp:latest" |
| 159 | - | docker push ${{ secrets.GORDON_REGISTRY }}/myapp:latest | 164 | + | docker push "$GORDON_REGISTRY/myapp:latest" |
| 160 | 165 | ||||
| 161 | - name: Summary | 166 | - name: Summary | ||
| 167 | + | env: | |||
| 168 | + | VERSION: ${{ inputs.version }} | |||
| 162 | run: | | 169 | run: | | ||
| 163 | - | echo "## Rollback Complete" >> $GITHUB_STEP_SUMMARY | 170 | + | echo "## Rollback Complete" >> "$GITHUB_STEP_SUMMARY" |
| 164 | - | echo "Rolled back to version: ${{ github.event.inputs.version }}" >> $GITHUB_STEP_SUMMARY | 171 | + | printf 'Rolled back to version: %s\n' "$VERSION" >> "$GITHUB_STEP_SUMMARY" |
| 165 | ``` | 172 | ``` | ||
| 166 | 173 | ||||
| 167 | ### Rollback Script | 174 | ### Rollback Script | ||
M docs/installation.md +14 -4
| @@ -15,13 +15,22 @@ 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 | + | less "$installer" | |
| 24 | + | bash "$installer" | |
| 25 | + | ) | |
| 19 | 26 | ``` | |
| 20 | 27 | ||
| 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`. | |
| 28 | + | 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 | 29 | ||
| 23 | 30 | ### Manual Installation | |
| 24 | 31 | ||
| 32 | + | 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. | |
| 33 | + | ||
| 25 | 34 | **Linux (x86_64)** | |
| 26 | 35 | ```bash | |
| 27 | 36 | wget https://github.com/bnema/gordon/releases/latest/download/gordon_linux_amd64.tar.gz | |
| @@ -74,8 +83,9 @@ sudo mv gordon /usr/local/bin/ | |||
| 74 | 83 | ### Docker | |
| 75 | 84 | ||
| 76 | 85 | ```bash | |
| 77 | - | # Install Docker | |
| 78 | - | curl -fsSL https://get.docker.com | sh | |
| 86 | + | # Install Docker from your distribution's signed package repository. | |
| 87 | + | sudo apt update | |
| 88 | + | sudo apt install docker.io | |
| 79 | 89 | ||
| 80 | 90 | # Add user to docker group | |
| 81 | 91 | sudo usermod -aG docker $USER | |
| @@ -15,13 +15,22 @@ 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 | + | less "$installer" | |||
| 24 | + | bash "$installer" | |||
| 25 | + | ) | |||
| 19 | ``` | 26 | ``` | ||
| 20 | 27 | ||||
| 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`. | 28 | + | 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 | 29 | ||||
| 23 | ### Manual Installation | 30 | ### Manual Installation | ||
| 24 | 31 | ||||
| 32 | + | 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. | |||
| 33 | + | ||||
| 25 | **Linux (x86_64)** | 34 | **Linux (x86_64)** | ||
| 26 | ```bash | 35 | ```bash | ||
| 27 | wget https://github.com/bnema/gordon/releases/latest/download/gordon_linux_amd64.tar.gz | 36 | wget https://github.com/bnema/gordon/releases/latest/download/gordon_linux_amd64.tar.gz | ||
| @@ -74,8 +83,9 @@ sudo mv gordon /usr/local/bin/ | |||||
| 74 | ### Docker | 83 | ### Docker | ||
| 75 | 84 | ||||
| 76 | ```bash | 85 | ```bash | ||
| 77 | - | # Install Docker | 86 | + | # Install Docker from your distribution's signed package repository. |
| 78 | - | curl -fsSL https://get.docker.com | sh | 87 | + | sudo apt update |
| 88 | + | sudo apt install docker.io | |||
| 79 | 89 | ||||
| 80 | # Add user to docker group | 90 | # Add user to docker group | ||
| 81 | sudo usermod -aG docker $USER | 91 | sudo usermod -aG docker $USER | ||
M internal/adapters/in/cli/auth.go +12 -8
| @@ -206,31 +206,35 @@ 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 | 210 | ||
| 211 | 211 | cmd := &cobra.Command{ | |
| 212 | 212 | Use: "login", | |
| 213 | 213 | Short: "Authenticate with a Gordon server", | |
| 214 | 214 | Long: `Store or verify a token for a Gordon server remote. | |
| 215 | 215 | ||
| 216 | - | With --token: stores the token and verifies it. | |
| 217 | - | Without --token: verifies the existing stored token still works. | |
| 216 | + | With --token-file: reads, stores, and verifies a token without exposing it in argv. | |
| 217 | + | Without --token-file: verifies the existing stored token still works. | |
| 218 | 218 | ||
| 219 | 219 | Generate a token on the server with: gordon auth token generate | |
| 220 | 220 | ||
| 221 | 221 | 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 | |
| 222 | + | gordon auth login --token-file ./token Store token for active remote | |
| 223 | + | gordon auth login --remote prod --token-file ./token | |
| 224 | 224 | gordon auth login Verify existing token`, | |
| 225 | 225 | RunE: func(cmd *cobra.Command, args []string) error { | |
| 226 | - | if token != "" { | |
| 226 | + | if tokenFile != "" { | |
| 227 | + | token, err := readProtectedSecretFile(tokenFile) | |
| 228 | + | if err != nil { | |
| 229 | + | return fmt.Errorf("read token: %w", err) | |
| 230 | + | } | |
| 227 | 231 | return runAuthLoginWithToken(cmd.Context(), token, cmd.OutOrStdout()) | |
| 228 | 232 | } | |
| 229 | 233 | return runAuthLoginVerify(cmd.Context(), cmd.OutOrStdout()) | |
| 230 | 234 | }, | |
| 231 | 235 | } | |
| 232 | 236 | ||
| 233 | - | cmd.Flags().StringVarP(&token, "token", "t", "", "Authentication token to store for the remote") | |
| 237 | + | cmd.Flags().StringVar(&tokenFile, "token-file", "", "Read authentication token from a mode 0600 file") | |
| 234 | 238 | ||
| 235 | 239 | return cmd | |
| 236 | 240 | } | |
| @@ -291,7 +295,7 @@ func runAuthLoginVerify(ctx context.Context, out io.Writer) error { | |||
| 291 | 295 | return err | |
| 292 | 296 | } | |
| 293 | 297 | if resolved.Token == "" { | |
| 294 | - | return fmt.Errorf("no token stored for remote '%s'; use: gordon auth login --token <token>", resolved.DisplayName()) | |
| 298 | + | return fmt.Errorf("no token stored for remote '%s'; use: gordon auth login --token-file <path>", resolved.DisplayName()) | |
| 295 | 299 | } | |
| 296 | 300 | ||
| 297 | 301 | client := remote.NewClient(resolved.URL, remoteClientOptions(resolved.Token, resolved.InsecureTLS)...) | |
| @@ -206,31 +206,35 @@ 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 | 210 | ||||
| 211 | cmd := &cobra.Command{ | 211 | cmd := &cobra.Command{ | ||
| 212 | Use: "login", | 212 | Use: "login", | ||
| 213 | Short: "Authenticate with a Gordon server", | 213 | Short: "Authenticate with a Gordon server", | ||
| 214 | Long: `Store or verify a token for a Gordon server remote. | 214 | Long: `Store or verify a token for a Gordon server remote. | ||
| 215 | 215 | ||||
| 216 | - | With --token: stores the token and verifies it. | 216 | + | With --token-file: reads, stores, and verifies a token without exposing it in argv. |
| 217 | - | Without --token: verifies the existing stored token still works. | 217 | + | Without --token-file: verifies the existing stored token still works. |
| 218 | 218 | ||||
| 219 | Generate a token on the server with: gordon auth token generate | 219 | Generate a token on the server with: gordon auth token generate | ||
| 220 | 220 | ||||
| 221 | Examples: | 221 | Examples: | ||
| 222 | - | gordon auth login --token <token> Store token for active remote | 222 | + | gordon auth login --token-file ./token Store token for active remote |
| 223 | - | gordon auth login --remote prod --token <tok> Store token for specific remote | 223 | + | gordon auth login --remote prod --token-file ./token |
| 224 | gordon auth login Verify existing token`, | 224 | gordon auth login Verify existing token`, | ||
| 225 | RunE: func(cmd *cobra.Command, args []string) error { | 225 | RunE: func(cmd *cobra.Command, args []string) error { | ||
| 226 | - | if token != "" { | 226 | + | if tokenFile != "" { |
| 227 | + | token, err := readProtectedSecretFile(tokenFile) | |||
| 228 | + | if err != nil { | |||
| 229 | + | return fmt.Errorf("read token: %w", err) | |||
| 230 | + | } | |||
| 227 | return runAuthLoginWithToken(cmd.Context(), token, cmd.OutOrStdout()) | 231 | return runAuthLoginWithToken(cmd.Context(), token, cmd.OutOrStdout()) | ||
| 228 | } | 232 | } | ||
| 229 | return runAuthLoginVerify(cmd.Context(), cmd.OutOrStdout()) | 233 | return runAuthLoginVerify(cmd.Context(), cmd.OutOrStdout()) | ||
| 230 | }, | 234 | }, | ||
| 231 | } | 235 | } | ||
| 232 | 236 | ||||
| 233 | - | cmd.Flags().StringVarP(&token, "token", "t", "", "Authentication token to store for the remote") | 237 | + | cmd.Flags().StringVar(&tokenFile, "token-file", "", "Read authentication token from a mode 0600 file") |
| 234 | 238 | ||||
| 235 | return cmd | 239 | return cmd | ||
| 236 | } | 240 | } | ||
| @@ -291,7 +295,7 @@ func runAuthLoginVerify(ctx context.Context, out io.Writer) error { | |||||
| 291 | return err | 295 | return err | ||
| 292 | } | 296 | } | ||
| 293 | if resolved.Token == "" { | 297 | if resolved.Token == "" { | ||
| 294 | - | return fmt.Errorf("no token stored for remote '%s'; use: gordon auth login --token <token>", resolved.DisplayName()) | 298 | + | return fmt.Errorf("no token stored for remote '%s'; use: gordon auth login --token-file <path>", resolved.DisplayName()) |
| 295 | } | 299 | } | ||
| 296 | 300 | ||||
| 297 | client := remote.NewClient(resolved.URL, remoteClientOptions(resolved.Token, resolved.InsecureTLS)...) | 301 | client := remote.NewClient(resolved.URL, remoteClientOptions(resolved.Token, resolved.InsecureTLS)...) | ||
M internal/adapters/in/cli/remotes.go +25 -13
| @@ -154,7 +154,7 @@ 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 | 160 | ||
| @@ -165,7 +165,7 @@ func newRemotesAddCmd() *cobra.Command { | |||
| 165 | 165 | ||
| 166 | 166 | Examples: | |
| 167 | 167 | gordon remotes add prod https://gordon.mydomain.com | |
| 168 | - | gordon remotes add prod https://gordon.mydomain.com --token eyJ... | |
| 168 | + | gordon remotes add prod https://gordon.mydomain.com --token-file ./token | |
| 169 | 169 | gordon remotes add staging https://staging.mydomain.com --token-env STAGING_TOKEN | |
| 170 | 170 | gordon remotes add dev https://dev.internal --insecure`, | |
| 171 | 171 | Args: cobra.ExactArgs(2), | |
| @@ -173,8 +173,14 @@ Examples: | |||
| 173 | 173 | name := args[0] | |
| 174 | 174 | url := normalizeURL(args[1]) | |
| 175 | 175 | ||
| 176 | - | // If token-env is provided, use that instead of token | |
| 177 | - | finalToken := token | |
| 176 | + | var finalToken string | |
| 177 | + | if tokenFile != "" { | |
| 178 | + | var err error | |
| 179 | + | finalToken, err = readProtectedSecretFile(tokenFile) | |
| 180 | + | if err != nil { | |
| 181 | + | return fmt.Errorf("read token: %w", err) | |
| 182 | + | } | |
| 183 | + | } | |
| 178 | 184 | if tokenEnv != "" { | |
| 179 | 185 | // Store token_env reference instead of actual token | |
| 180 | 186 | if err := addRemoteWithEnv(name, url, tokenEnv, insecureTLS); err != nil { | |
| @@ -188,15 +194,15 @@ Examples: | |||
| 188 | 194 | ||
| 189 | 195 | fmt.Println(styles.RenderSuccess(fmt.Sprintf("Remote added: %s -> %s", name, url))) | |
| 190 | 196 | ||
| 191 | - | if token == "" && tokenEnv == "" { | |
| 192 | - | fmt.Println(styles.Theme.Muted.Render("Tip: Add a token with --token or --token-env for authenticated access")) | |
| 197 | + | if tokenFile == "" && tokenEnv == "" { | |
| 198 | + | fmt.Println(styles.Theme.Muted.Render("Tip: Add a token with --token-file or --token-env for authenticated access")) | |
| 193 | 199 | } | |
| 194 | 200 | ||
| 195 | 201 | return nil | |
| 196 | 202 | }, | |
| 197 | 203 | } | |
| 198 | 204 | ||
| 199 | - | cmd.Flags().StringVar(&token, "token", "", "Authentication token") | |
| 205 | + | cmd.Flags().StringVar(&tokenFile, "token-file", "", "Read authentication token from a mode 0600 file") | |
| 200 | 206 | cmd.Flags().StringVar(&tokenEnv, "token-env", "", "Environment variable containing token") | |
| 201 | 207 | cmd.Flags().BoolVar(&insecureTLS, "insecure", false, "Skip TLS certificate verification") | |
| 202 | 208 | ||
| @@ -313,8 +319,9 @@ Examples: | |||
| 313 | 319 | ||
| 314 | 320 | // newRemotesSetTokenCmd creates the remotes set-token command. | |
| 315 | 321 | func newRemotesSetTokenCmd() *cobra.Command { | |
| 316 | - | return &cobra.Command{ | |
| 317 | - | Use: "set-token <name> <token>", | |
| 322 | + | var tokenFile string | |
| 323 | + | cmd := &cobra.Command{ | |
| 324 | + | Use: "set-token <name> --token-file <path>", | |
| 318 | 325 | Short: "Set the token for a remote", | |
| 319 | 326 | Long: `Set or update the authentication token for a saved remote. | |
| 320 | 327 | ||
| @@ -326,12 +333,14 @@ This is useful when: | |||
| 326 | 333 | For servers with password authentication, use 'gordon auth login' instead. | |
| 327 | 334 | ||
| 328 | 335 | Examples: | |
| 329 | - | gordon remotes set-token prod eyJhbGciOiJIUzI1NiIs... | |
| 330 | - | gordon remotes set-token staging $(cat token.txt)`, | |
| 331 | - | Args: cobra.ExactArgs(2), | |
| 336 | + | gordon remotes set-token prod --token-file ./token`, | |
| 337 | + | Args: cobra.ExactArgs(1), | |
| 332 | 338 | RunE: func(cmd *cobra.Command, args []string) error { | |
| 333 | 339 | name := args[0] | |
| 334 | - | token := args[1] | |
| 340 | + | token, err := readProtectedSecretFile(tokenFile) | |
| 341 | + | if err != nil { | |
| 342 | + | return fmt.Errorf("read token: %w", err) | |
| 343 | + | } | |
| 335 | 344 | ||
| 336 | 345 | // Verify remote exists | |
| 337 | 346 | remotes, _, err := remote.ListRemotes() | |
| @@ -352,6 +361,9 @@ Examples: | |||
| 352 | 361 | return nil | |
| 353 | 362 | }, | |
| 354 | 363 | } | |
| 364 | + | cmd.Flags().StringVar(&tokenFile, "token-file", "", "Read authentication token from a mode 0600 file") | |
| 365 | + | _ = cmd.MarkFlagRequired("token-file") | |
| 366 | + | return cmd | |
| 355 | 367 | } | |
| 356 | 368 | ||
| 357 | 369 | // normalizeURL ensures the URL has a protocol scheme, defaulting to https. | |
| @@ -154,7 +154,7 @@ 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 | 160 | ||||
| @@ -165,7 +165,7 @@ func newRemotesAddCmd() *cobra.Command { | |||||
| 165 | 165 | ||||
| 166 | Examples: | 166 | Examples: | ||
| 167 | gordon remotes add prod https://gordon.mydomain.com | 167 | gordon remotes add prod https://gordon.mydomain.com | ||
| 168 | - | gordon remotes add prod https://gordon.mydomain.com --token eyJ... | 168 | + | gordon remotes add prod https://gordon.mydomain.com --token-file ./token |
| 169 | gordon remotes add staging https://staging.mydomain.com --token-env STAGING_TOKEN | 169 | gordon remotes add staging https://staging.mydomain.com --token-env STAGING_TOKEN | ||
| 170 | gordon remotes add dev https://dev.internal --insecure`, | 170 | gordon remotes add dev https://dev.internal --insecure`, | ||
| 171 | Args: cobra.ExactArgs(2), | 171 | Args: cobra.ExactArgs(2), | ||
| @@ -173,8 +173,14 @@ Examples: | |||||
| 173 | name := args[0] | 173 | name := args[0] | ||
| 174 | url := normalizeURL(args[1]) | 174 | url := normalizeURL(args[1]) | ||
| 175 | 175 | ||||
| 176 | - | // If token-env is provided, use that instead of token | 176 | + | var finalToken string |
| 177 | - | finalToken := token | 177 | + | if tokenFile != "" { |
| 178 | + | var err error | |||
| 179 | + | finalToken, err = readProtectedSecretFile(tokenFile) | |||
| 180 | + | if err != nil { | |||
| 181 | + | return fmt.Errorf("read token: %w", err) | |||
| 182 | + | } | |||
| 183 | + | } | |||
| 178 | if tokenEnv != "" { | 184 | if tokenEnv != "" { | ||
| 179 | // Store token_env reference instead of actual token | 185 | // Store token_env reference instead of actual token | ||
| 180 | if err := addRemoteWithEnv(name, url, tokenEnv, insecureTLS); err != nil { | 186 | if err := addRemoteWithEnv(name, url, tokenEnv, insecureTLS); err != nil { | ||
| @@ -188,15 +194,15 @@ Examples: | |||||
| 188 | 194 | ||||
| 189 | fmt.Println(styles.RenderSuccess(fmt.Sprintf("Remote added: %s -> %s", name, url))) | 195 | fmt.Println(styles.RenderSuccess(fmt.Sprintf("Remote added: %s -> %s", name, url))) | ||
| 190 | 196 | ||||
| 191 | - | if token == "" && tokenEnv == "" { | 197 | + | if tokenFile == "" && tokenEnv == "" { |
| 192 | - | fmt.Println(styles.Theme.Muted.Render("Tip: Add a token with --token or --token-env for authenticated access")) | 198 | + | fmt.Println(styles.Theme.Muted.Render("Tip: Add a token with --token-file or --token-env for authenticated access")) |
| 193 | } | 199 | } | ||
| 194 | 200 | ||||
| 195 | return nil | 201 | return nil | ||
| 196 | }, | 202 | }, | ||
| 197 | } | 203 | } | ||
| 198 | 204 | ||||
| 199 | - | cmd.Flags().StringVar(&token, "token", "", "Authentication token") | 205 | + | 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") | 206 | cmd.Flags().StringVar(&tokenEnv, "token-env", "", "Environment variable containing token") | ||
| 201 | cmd.Flags().BoolVar(&insecureTLS, "insecure", false, "Skip TLS certificate verification") | 207 | cmd.Flags().BoolVar(&insecureTLS, "insecure", false, "Skip TLS certificate verification") | ||
| 202 | 208 | ||||
| @@ -313,8 +319,9 @@ Examples: | |||||
| 313 | 319 | ||||
| 314 | // newRemotesSetTokenCmd creates the remotes set-token command. | 320 | // newRemotesSetTokenCmd creates the remotes set-token command. | ||
| 315 | func newRemotesSetTokenCmd() *cobra.Command { | 321 | func newRemotesSetTokenCmd() *cobra.Command { | ||
| 316 | - | return &cobra.Command{ | 322 | + | var tokenFile string |
| 317 | - | Use: "set-token <name> <token>", | 323 | + | cmd := &cobra.Command{ |
| 324 | + | Use: "set-token <name> --token-file <path>", | |||
| 318 | Short: "Set the token for a remote", | 325 | Short: "Set the token for a remote", | ||
| 319 | Long: `Set or update the authentication token for a saved remote. | 326 | Long: `Set or update the authentication token for a saved remote. | ||
| 320 | 327 | ||||
| @@ -326,12 +333,14 @@ This is useful when: | |||||
| 326 | For servers with password authentication, use 'gordon auth login' instead. | 333 | For servers with password authentication, use 'gordon auth login' instead. | ||
| 327 | 334 | ||||
| 328 | Examples: | 335 | Examples: | ||
| 329 | - | gordon remotes set-token prod eyJhbGciOiJIUzI1NiIs... | 336 | + | gordon remotes set-token prod --token-file ./token`, |
| 330 | - | gordon remotes set-token staging $(cat token.txt)`, | 337 | + | Args: cobra.ExactArgs(1), |
| 331 | - | Args: cobra.ExactArgs(2), | |||
| 332 | RunE: func(cmd *cobra.Command, args []string) error { | 338 | RunE: func(cmd *cobra.Command, args []string) error { | ||
| 333 | name := args[0] | 339 | name := args[0] | ||
| 334 | - | token := args[1] | 340 | + | token, err := readProtectedSecretFile(tokenFile) |
| 341 | + | if err != nil { | |||
| 342 | + | return fmt.Errorf("read token: %w", err) | |||
| 343 | + | } | |||
| 335 | 344 | ||||
| 336 | // Verify remote exists | 345 | // Verify remote exists | ||
| 337 | remotes, _, err := remote.ListRemotes() | 346 | remotes, _, err := remote.ListRemotes() | ||
| @@ -352,6 +361,9 @@ Examples: | |||||
| 352 | return nil | 361 | return nil | ||
| 353 | }, | 362 | }, | ||
| 354 | } | 363 | } | ||
| 364 | + | cmd.Flags().StringVar(&tokenFile, "token-file", "", "Read authentication token from a mode 0600 file") | |||
| 365 | + | _ = cmd.MarkFlagRequired("token-file") | |||
| 366 | + | return cmd | |||
| 355 | } | 367 | } | ||
| 356 | 368 | ||||
| 357 | // normalizeURL ensures the URL has a protocol scheme, defaulting to https. | 369 | // 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 +29 -0
| @@ -0,0 +1,29 @@ | |||
| 1 | + | package cli | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "fmt" | |
| 5 | + | "os" | |
| 6 | + | "strings" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | func readProtectedSecretFile(path string) (string, error) { | |
| 10 | + | info, err := os.Stat(path) | |
| 11 | + | if err != nil { | |
| 12 | + | return "", err | |
| 13 | + | } | |
| 14 | + | if !info.Mode().IsRegular() { | |
| 15 | + | return "", fmt.Errorf("%s is not a regular file", path) | |
| 16 | + | } | |
| 17 | + | if info.Mode().Perm()&0o077 != 0 { | |
| 18 | + | return "", fmt.Errorf("%s must not be accessible by group or others (use chmod 600)", path) | |
| 19 | + | } | |
| 20 | + | data, err := os.ReadFile(path) | |
| 21 | + | if err != nil { | |
| 22 | + | return "", err | |
| 23 | + | } | |
| 24 | + | value := strings.TrimSpace(string(data)) | |
| 25 | + | if value == "" { | |
| 26 | + | return "", fmt.Errorf("%s is empty", path) | |
| 27 | + | } | |
| 28 | + | return value, nil | |
| 29 | + | } | |
| @@ -0,0 +1,29 @@ | |||||
| 1 | + | package cli | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "fmt" | |||
| 5 | + | "os" | |||
| 6 | + | "strings" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | func readProtectedSecretFile(path string) (string, error) { | |||
| 10 | + | info, err := os.Stat(path) | |||
| 11 | + | if err != nil { | |||
| 12 | + | return "", err | |||
| 13 | + | } | |||
| 14 | + | if !info.Mode().IsRegular() { | |||
| 15 | + | return "", fmt.Errorf("%s is not a regular file", path) | |||
| 16 | + | } | |||
| 17 | + | if info.Mode().Perm()&0o077 != 0 { | |||
| 18 | + | return "", fmt.Errorf("%s must not be accessible by group or others (use chmod 600)", path) | |||
| 19 | + | } | |||
| 20 | + | data, err := os.ReadFile(path) | |||
| 21 | + | if err != nil { | |||
| 22 | + | return "", err | |||
| 23 | + | } | |||
| 24 | + | value := strings.TrimSpace(string(data)) | |||
| 25 | + | if value == "" { | |||
| 26 | + | return "", fmt.Errorf("%s is empty", path) | |||
| 27 | + | } | |||
| 28 | + | return value, nil | |||
| 29 | + | } | |||
M internal/adapters/in/cli/secrets.go +13 -11
| @@ -222,32 +222,32 @@ 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 | 226 | ||
| 227 | 227 | cmd := &cobra.Command{ | |
| 228 | - | Use: "set <domain> <KEY=value>...", | |
| 228 | + | Use: "set <domain> --from-file <path>", | |
| 229 | 229 | Short: "Set secrets for a domain or attachment", | |
| 230 | 230 | Long: `Set one or more secrets for a domain or an attachment container. | |
| 231 | 231 | ||
| 232 | - | Secrets are specified as KEY=value pairs. Multiple secrets can be set at once. | |
| 232 | + | Secrets are read from a mode 0600 file containing one KEY=value pair per line. | |
| 233 | 233 | ||
| 234 | 234 | Use --attachment to target an attachment service (e.g., postgres, redis) instead | |
| 235 | 235 | of the main domain container. | |
| 236 | 236 | ||
| 237 | 237 | 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), | |
| 238 | + | gordon secrets set app.mydomain.com --from-file ./app.env | |
| 239 | + | gordon secrets set app.mydomain.com --attachment postgres --from-file ./postgres.env`, | |
| 240 | + | Args: cobra.ExactArgs(1), | |
| 243 | 241 | RunE: func(cmd *cobra.Command, args []string) error { | |
| 244 | 242 | ctx := cmd.Context() | |
| 245 | 243 | secretDomain := args[0] | |
| 246 | - | pairs := args[1:] | |
| 244 | + | content, err := readProtectedSecretFile(fromFile) | |
| 245 | + | if err != nil { | |
| 246 | + | return fmt.Errorf("read secrets: %w", err) | |
| 247 | + | } | |
| 247 | 248 | ||
| 248 | - | // Parse KEY=value pairs | |
| 249 | 249 | secrets := make(map[string]string) | |
| 250 | - | for _, pair := range pairs { | |
| 250 | + | for _, pair := range strings.Split(content, "\n") { | |
| 251 | 251 | parts := strings.SplitN(pair, "=", 2) | |
| 252 | 252 | if len(parts) != 2 { | |
| 253 | 253 | return fmt.Errorf("invalid format: %s (expected KEY=value)", pair) | |
| @@ -288,6 +288,8 @@ Examples: | |||
| 288 | 288 | } | |
| 289 | 289 | ||
| 290 | 290 | cmd.Flags().StringVarP(&attachment, "attachment", "a", "", "Target an attachment service (e.g., postgres, redis)") | |
| 291 | + | cmd.Flags().StringVar(&fromFile, "from-file", "", "Read KEY=value lines from a mode 0600 file") | |
| 292 | + | _ = cmd.MarkFlagRequired("from-file") | |
| 291 | 293 | ||
| 292 | 294 | return cmd | |
| 293 | 295 | } | |
| @@ -222,32 +222,32 @@ 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 | 226 | ||||
| 227 | cmd := &cobra.Command{ | 227 | cmd := &cobra.Command{ | ||
| 228 | - | Use: "set <domain> <KEY=value>...", | 228 | + | Use: "set <domain> --from-file <path>", |
| 229 | Short: "Set secrets for a domain or attachment", | 229 | Short: "Set secrets for a domain or attachment", | ||
| 230 | Long: `Set one or more secrets for a domain or an attachment container. | 230 | Long: `Set one or more secrets for a domain or an attachment container. | ||
| 231 | 231 | ||||
| 232 | - | Secrets are specified as KEY=value pairs. Multiple secrets can be set at once. | 232 | + | Secrets are read from a mode 0600 file containing one KEY=value pair per line. |
| 233 | 233 | ||||
| 234 | Use --attachment to target an attachment service (e.g., postgres, redis) instead | 234 | Use --attachment to target an attachment service (e.g., postgres, redis) instead | ||
| 235 | of the main domain container. | 235 | of the main domain container. | ||
| 236 | 236 | ||||
| 237 | Examples: | 237 | Examples: | ||
| 238 | - | gordon secrets set app.mydomain.com DATABASE_URL=postgres://localhost/db | 238 | + | gordon secrets set app.mydomain.com --from-file ./app.env |
| 239 | - | gordon secrets set app.mydomain.com API_KEY=secret123 DEBUG=false | 239 | + | gordon secrets set app.mydomain.com --attachment postgres --from-file ./postgres.env`, |
| 240 | - | gordon secrets set app.mydomain.com --attachment postgres POSTGRES_PASSWORD=secret | 240 | + | 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 { | 241 | RunE: func(cmd *cobra.Command, args []string) error { | ||
| 244 | ctx := cmd.Context() | 242 | ctx := cmd.Context() | ||
| 245 | secretDomain := args[0] | 243 | secretDomain := args[0] | ||
| 246 | - | pairs := args[1:] | 244 | + | content, err := readProtectedSecretFile(fromFile) |
| 245 | + | if err != nil { | |||
| 246 | + | return fmt.Errorf("read secrets: %w", err) | |||
| 247 | + | } | |||
| 247 | 248 | ||||
| 248 | - | // Parse KEY=value pairs | |||
| 249 | secrets := make(map[string]string) | 249 | secrets := make(map[string]string) | ||
| 250 | - | for _, pair := range pairs { | 250 | + | for _, pair := range strings.Split(content, "\n") { |
| 251 | parts := strings.SplitN(pair, "=", 2) | 251 | parts := strings.SplitN(pair, "=", 2) | ||
| 252 | if len(parts) != 2 { | 252 | if len(parts) != 2 { | ||
| 253 | return fmt.Errorf("invalid format: %s (expected KEY=value)", pair) | 253 | return fmt.Errorf("invalid format: %s (expected KEY=value)", pair) | ||
| @@ -288,6 +288,8 @@ Examples: | |||||
| 288 | } | 288 | } | ||
| 289 | 289 | ||||
| 290 | cmd.Flags().StringVarP(&attachment, "attachment", "a", "", "Target an attachment service (e.g., postgres, redis)") | 290 | cmd.Flags().StringVarP(&attachment, "attachment", "a", "", "Target an attachment service (e.g., postgres, redis)") | ||
| 291 | + | cmd.Flags().StringVar(&fromFile, "from-file", "", "Read KEY=value lines from a mode 0600 file") | |||
| 292 | + | _ = cmd.MarkFlagRequired("from-file") | |||
| 291 | 293 | ||||
| 292 | return cmd | 294 | return cmd | ||
| 293 | } | 295 | } | ||
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 +2 -2
| @@ -214,7 +214,7 @@ func TestHandler_GetBlob_Success(t *testing.T) { | |||
| 214 | 214 | _, err = tmpFile.Write(blobContent) | |
| 215 | 215 | assert.NoError(t, err) | |
| 216 | 216 | ||
| 217 | - | registrySvc.EXPECT().GetBlobPath(mock.Anything, "sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4").Return(tmpFile.Name(), nil) | |
| 217 | + | registrySvc.EXPECT().GetBlobPath(mock.Anything, "myapp", "sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4").Return(tmpFile.Name(), nil) | |
| 218 | 218 | ||
| 219 | 219 | req := httptest.NewRequest("GET", "/v2/myapp/blobs/sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4", nil) | |
| 220 | 220 | rec := httptest.NewRecorder() | |
| @@ -230,7 +230,7 @@ func TestHandler_GetBlob_NotFound(t *testing.T) { | |||
| 230 | 230 | ||
| 231 | 231 | handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize) | |
| 232 | 232 | ||
| 233 | - | registrySvc.EXPECT().GetBlobPath(mock.Anything, "sha256:0000000000000000000000000000000000000000000000000000000000000000").Return("", assert.AnError) | |
| 233 | + | registrySvc.EXPECT().GetBlobPath(mock.Anything, "myapp", "sha256:0000000000000000000000000000000000000000000000000000000000000000").Return("", assert.AnError) | |
| 234 | 234 | ||
| 235 | 235 | req := httptest.NewRequest("GET", "/v2/myapp/blobs/sha256:0000000000000000000000000000000000000000000000000000000000000000", nil) | |
| 236 | 236 | rec := httptest.NewRecorder() | |
| @@ -214,7 +214,7 @@ func TestHandler_GetBlob_Success(t *testing.T) { | |||||
| 214 | _, err = tmpFile.Write(blobContent) | 214 | _, err = tmpFile.Write(blobContent) | ||
| 215 | assert.NoError(t, err) | 215 | assert.NoError(t, err) | ||
| 216 | 216 | ||||
| 217 | - | registrySvc.EXPECT().GetBlobPath(mock.Anything, "sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4").Return(tmpFile.Name(), nil) | 217 | + | registrySvc.EXPECT().GetBlobPath(mock.Anything, "myapp", "sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4").Return(tmpFile.Name(), nil) |
| 218 | 218 | ||||
| 219 | req := httptest.NewRequest("GET", "/v2/myapp/blobs/sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4", nil) | 219 | req := httptest.NewRequest("GET", "/v2/myapp/blobs/sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4", nil) | ||
| 220 | rec := httptest.NewRecorder() | 220 | rec := httptest.NewRecorder() | ||
| @@ -230,7 +230,7 @@ func TestHandler_GetBlob_NotFound(t *testing.T) { | |||||
| 230 | 230 | ||||
| 231 | handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize) | 231 | handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize) | ||
| 232 | 232 | ||||
| 233 | - | registrySvc.EXPECT().GetBlobPath(mock.Anything, "sha256:0000000000000000000000000000000000000000000000000000000000000000").Return("", assert.AnError) | 233 | + | registrySvc.EXPECT().GetBlobPath(mock.Anything, "myapp", "sha256:0000000000000000000000000000000000000000000000000000000000000000").Return("", assert.AnError) |
| 234 | 234 | ||||
| 235 | req := httptest.NewRequest("GET", "/v2/myapp/blobs/sha256:0000000000000000000000000000000000000000000000000000000000000000", nil) | 235 | req := httptest.NewRequest("GET", "/v2/myapp/blobs/sha256:0000000000000000000000000000000000000000000000000000000000000000", nil) | ||
| 236 | rec := httptest.NewRecorder() | 236 | rec := httptest.NewRecorder() | ||
M internal/adapters/out/docker/runtime.go +23 -2
| @@ -525,6 +525,7 @@ func (r *Runtime) GetContainerLogs(ctx context.Context, containerID string, foll | |||
| 525 | 525 | ShowStderr: true, | |
| 526 | 526 | Follow: follow, | |
| 527 | 527 | Timestamps: true, | |
| 528 | + | Tail: "10000", | |
| 528 | 529 | }) | |
| 529 | 530 | if err != nil { | |
| 530 | 531 | return nil, log.WrapErr(err, "failed to get container logs") | |
| @@ -747,6 +748,7 @@ func (r *Runtime) PruneImages(ctx context.Context, danglingOnly bool) (runtimepk | |||
| 747 | 748 | log := zerowrap.FromCtx(ctx) | |
| 748 | 749 | ||
| 749 | 750 | pruneFilters := filters.NewArgs() | |
| 751 | + | pruneFilters.Add("label", domain.LabelManaged+"=true") | |
| 750 | 752 | if danglingOnly { | |
| 751 | 753 | pruneFilters.Add("dangling", "true") | |
| 752 | 754 | } | |
| @@ -1724,9 +1726,28 @@ func (r *Runtime) ExecInContainer(ctx context.Context, containerID string, cmd [ | |||
| 1724 | 1726 | }, nil | |
| 1725 | 1727 | } | |
| 1726 | 1728 | ||
| 1729 | + | const maxExecOutputSize = 8 << 20 // 8 MiB per stream | |
| 1730 | + | ||
| 1731 | + | type boundedBuffer struct { | |
| 1732 | + | bytes.Buffer | |
| 1733 | + | limit int | |
| 1734 | + | } | |
| 1735 | + | ||
| 1736 | + | func (w *boundedBuffer) Write(p []byte) (int, error) { | |
| 1737 | + | remaining := w.limit - w.Len() | |
| 1738 | + | if remaining <= 0 { | |
| 1739 | + | return 0, fmt.Errorf("container exec output exceeds %d bytes", w.limit) | |
| 1740 | + | } | |
| 1741 | + | if len(p) > remaining { | |
| 1742 | + | _, _ = w.Buffer.Write(p[:remaining]) | |
| 1743 | + | return remaining, fmt.Errorf("container exec output exceeds %d bytes", w.limit) | |
| 1744 | + | } | |
| 1745 | + | return w.Buffer.Write(p) | |
| 1746 | + | } | |
| 1747 | + | ||
| 1727 | 1748 | func parseExecOutput(reader io.Reader) ([]byte, []byte, error) { | |
| 1728 | - | var stdout bytes.Buffer | |
| 1729 | - | var stderr bytes.Buffer | |
| 1749 | + | stdout := boundedBuffer{limit: maxExecOutputSize} | |
| 1750 | + | stderr := boundedBuffer{limit: maxExecOutputSize} | |
| 1730 | 1751 | ||
| 1731 | 1752 | if _, err := stdcopy.StdCopy(&stdout, &stderr, reader); err != nil { | |
| 1732 | 1753 | return nil, nil, err | |
| @@ -525,6 +525,7 @@ func (r *Runtime) GetContainerLogs(ctx context.Context, containerID string, foll | |||||
| 525 | ShowStderr: true, | 525 | ShowStderr: true, | ||
| 526 | Follow: follow, | 526 | Follow: follow, | ||
| 527 | Timestamps: true, | 527 | Timestamps: true, | ||
| 528 | + | Tail: "10000", | |||
| 528 | }) | 529 | }) | ||
| 529 | if err != nil { | 530 | if err != nil { | ||
| 530 | return nil, log.WrapErr(err, "failed to get container logs") | 531 | return nil, log.WrapErr(err, "failed to get container logs") | ||
| @@ -747,6 +748,7 @@ func (r *Runtime) PruneImages(ctx context.Context, danglingOnly bool) (runtimepk | |||||
| 747 | log := zerowrap.FromCtx(ctx) | 748 | log := zerowrap.FromCtx(ctx) | ||
| 748 | 749 | ||||
| 749 | pruneFilters := filters.NewArgs() | 750 | pruneFilters := filters.NewArgs() | ||
| 751 | + | pruneFilters.Add("label", domain.LabelManaged+"=true") | |||
| 750 | if danglingOnly { | 752 | if danglingOnly { | ||
| 751 | pruneFilters.Add("dangling", "true") | 753 | pruneFilters.Add("dangling", "true") | ||
| 752 | } | 754 | } | ||
| @@ -1724,9 +1726,28 @@ func (r *Runtime) ExecInContainer(ctx context.Context, containerID string, cmd [ | |||||
| 1724 | }, nil | 1726 | }, nil | ||
| 1725 | } | 1727 | } | ||
| 1726 | 1728 | ||||
| 1729 | + | const maxExecOutputSize = 8 << 20 // 8 MiB per stream | |||
| 1730 | + | ||||
| 1731 | + | type boundedBuffer struct { | |||
| 1732 | + | bytes.Buffer | |||
| 1733 | + | limit int | |||
| 1734 | + | } | |||
| 1735 | + | ||||
| 1736 | + | func (w *boundedBuffer) Write(p []byte) (int, error) { | |||
| 1737 | + | remaining := w.limit - w.Len() | |||
| 1738 | + | if remaining <= 0 { | |||
| 1739 | + | return 0, fmt.Errorf("container exec output exceeds %d bytes", w.limit) | |||
| 1740 | + | } | |||
| 1741 | + | if len(p) > remaining { | |||
| 1742 | + | _, _ = w.Buffer.Write(p[:remaining]) | |||
| 1743 | + | return remaining, fmt.Errorf("container exec output exceeds %d bytes", w.limit) | |||
| 1744 | + | } | |||
| 1745 | + | return w.Buffer.Write(p) | |||
| 1746 | + | } | |||
| 1747 | + | ||||
| 1727 | func parseExecOutput(reader io.Reader) ([]byte, []byte, error) { | 1748 | func parseExecOutput(reader io.Reader) ([]byte, []byte, error) { | ||
| 1728 | - | var stdout bytes.Buffer | 1749 | + | stdout := boundedBuffer{limit: maxExecOutputSize} |
| 1729 | - | var stderr bytes.Buffer | 1750 | + | stderr := boundedBuffer{limit: maxExecOutputSize} |
| 1730 | 1751 | ||||
| 1731 | if _, err := stdcopy.StdCopy(&stdout, &stderr, reader); err != nil { | 1752 | if _, err := stdcopy.StdCopy(&stdout, &stderr, reader); err != nil { | ||
| 1732 | return nil, nil, err | 1753 | return nil, nil, err | ||
M internal/adapters/out/ratelimit/memory.go +45 -15
| @@ -17,8 +17,18 @@ var _ out.RateLimiter = (*MemoryStore)(nil) | |||
| 17 | 17 | ||
| 18 | 18 | // MemoryStore is an in-memory rate limiter implementation using golang.org/x/time/rate. | |
| 19 | 19 | // Each unique key gets its own independent rate limiter. | |
| 20 | + | type limiterEntry struct { | |
| 21 | + | limiter *rate.Limiter | |
| 22 | + | lastSeen time.Time | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | const ( | |
| 26 | + | maxLimiterEntries = 10000 | |
| 27 | + | limiterIdleTTL = 10 * time.Minute | |
| 28 | + | ) | |
| 29 | + | ||
| 20 | 30 | type MemoryStore struct { | |
| 21 | - | limiters map[string]*rate.Limiter | |
| 31 | + | limiters map[string]limiterEntry | |
| 22 | 32 | mu sync.RWMutex | |
| 23 | 33 | rps float64 | |
| 24 | 34 | burst int | |
| @@ -28,7 +38,7 @@ type MemoryStore struct { | |||
| 28 | 38 | // NewMemoryStore creates a new in-memory rate limiter store. | |
| 29 | 39 | func NewMemoryStore(rps float64, burst int, log zerowrap.Logger) *MemoryStore { | |
| 30 | 40 | return &MemoryStore{ | |
| 31 | - | limiters: make(map[string]*rate.Limiter), | |
| 41 | + | limiters: make(map[string]limiterEntry), | |
| 32 | 42 | rps: rps, | |
| 33 | 43 | burst: burst, | |
| 34 | 44 | log: log, | |
| @@ -48,23 +58,43 @@ func (s *MemoryStore) AllowN(_ context.Context, key string, n int) bool { | |||
| 48 | 58 | ||
| 49 | 59 | // getLimiter returns the rate limiter for the given key, creating one if it doesn't exist. | |
| 50 | 60 | 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 | - | ||
| 61 | + | now := time.Now() | |
| 59 | 62 | s.mu.Lock() | |
| 60 | 63 | defer s.mu.Unlock() | |
| 61 | 64 | ||
| 62 | - | // Double-check after acquiring write lock | |
| 63 | - | if limiter, exists = s.limiters[key]; exists { | |
| 64 | - | return limiter | |
| 65 | + | if entry, exists := s.limiters[key]; exists { | |
| 66 | + | entry.lastSeen = now | |
| 67 | + | s.limiters[key] = entry | |
| 68 | + | return entry.limiter | |
| 65 | 69 | } | |
| 66 | 70 | ||
| 67 | - | limiter = rate.NewLimiter(rate.Limit(s.rps), s.burst) | |
| 68 | - | s.limiters[key] = limiter | |
| 71 | + | s.evictExpiredOrOldest(now) | |
| 72 | + | limiter := rate.NewLimiter(rate.Limit(s.rps), s.burst) | |
| 73 | + | s.limiters[key] = limiterEntry{limiter: limiter, lastSeen: now} | |
| 69 | 74 | return limiter | |
| 70 | 75 | } | |
| 76 | + | ||
| 77 | + | func (s *MemoryStore) evictExpiredOrOldest(now time.Time) { | |
| 78 | + | for key, entry := range s.limiters { | |
| 79 | + | if now.Sub(entry.lastSeen) > limiterIdleTTL { | |
| 80 | + | delete(s.limiters, key) | |
| 81 | + | } | |
| 82 | + | } | |
| 83 | + | if len(s.limiters) < maxLimiterEntries { | |
| 84 | + | return | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | var oldestKey string | |
| 88 | + | var oldest time.Time | |
| 89 | + | found := false | |
| 90 | + | for key, entry := range s.limiters { | |
| 91 | + | if !found || entry.lastSeen.Before(oldest) { | |
| 92 | + | oldestKey = key | |
| 93 | + | oldest = entry.lastSeen | |
| 94 | + | found = true | |
| 95 | + | } | |
| 96 | + | } | |
| 97 | + | if found { | |
| 98 | + | delete(s.limiters, oldestKey) | |
| 99 | + | } | |
| 100 | + | } | |
| @@ -17,8 +17,18 @@ var _ out.RateLimiter = (*MemoryStore)(nil) | |||||
| 17 | 17 | ||||
| 18 | // MemoryStore is an in-memory rate limiter implementation using golang.org/x/time/rate. | 18 | // MemoryStore is an in-memory rate limiter implementation using golang.org/x/time/rate. | ||
| 19 | // Each unique key gets its own independent rate limiter. | 19 | // Each unique key gets its own independent rate limiter. | ||
| 20 | + | type limiterEntry struct { | |||
| 21 | + | limiter *rate.Limiter | |||
| 22 | + | lastSeen time.Time | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | const ( | |||
| 26 | + | maxLimiterEntries = 10000 | |||
| 27 | + | limiterIdleTTL = 10 * time.Minute | |||
| 28 | + | ) | |||
| 29 | + | ||||
| 20 | type MemoryStore struct { | 30 | type MemoryStore struct { | ||
| 21 | - | limiters map[string]*rate.Limiter | 31 | + | limiters map[string]limiterEntry |
| 22 | mu sync.RWMutex | 32 | mu sync.RWMutex | ||
| 23 | rps float64 | 33 | rps float64 | ||
| 24 | burst int | 34 | burst int | ||
| @@ -28,7 +38,7 @@ type MemoryStore struct { | |||||
| 28 | // NewMemoryStore creates a new in-memory rate limiter store. | 38 | // NewMemoryStore creates a new in-memory rate limiter store. | ||
| 29 | func NewMemoryStore(rps float64, burst int, log zerowrap.Logger) *MemoryStore { | 39 | func NewMemoryStore(rps float64, burst int, log zerowrap.Logger) *MemoryStore { | ||
| 30 | return &MemoryStore{ | 40 | return &MemoryStore{ | ||
| 31 | - | limiters: make(map[string]*rate.Limiter), | 41 | + | limiters: make(map[string]limiterEntry), |
| 32 | rps: rps, | 42 | rps: rps, | ||
| 33 | burst: burst, | 43 | burst: burst, | ||
| 34 | log: log, | 44 | log: log, | ||
| @@ -48,23 +58,43 @@ func (s *MemoryStore) AllowN(_ context.Context, key string, n int) bool { | |||||
| 48 | 58 | ||||
| 49 | // getLimiter returns the rate limiter for the given key, creating one if it doesn't exist. | 59 | // 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 { | 60 | func (s *MemoryStore) getLimiter(key string) *rate.Limiter { | ||
| 51 | - | s.mu.RLock() | 61 | + | 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() | 62 | s.mu.Lock() | ||
| 60 | defer s.mu.Unlock() | 63 | defer s.mu.Unlock() | ||
| 61 | 64 | ||||
| 62 | - | // Double-check after acquiring write lock | 65 | + | if entry, exists := s.limiters[key]; exists { |
| 63 | - | if limiter, exists = s.limiters[key]; exists { | 66 | + | entry.lastSeen = now |
| 64 | - | return limiter | 67 | + | s.limiters[key] = entry |
| 68 | + | return entry.limiter | |||
| 65 | } | 69 | } | ||
| 66 | 70 | ||||
| 67 | - | limiter = rate.NewLimiter(rate.Limit(s.rps), s.burst) | 71 | + | s.evictExpiredOrOldest(now) |
| 68 | - | s.limiters[key] = limiter | 72 | + | limiter := rate.NewLimiter(rate.Limit(s.rps), s.burst) |
| 73 | + | s.limiters[key] = limiterEntry{limiter: limiter, lastSeen: now} | |||
| 69 | return limiter | 74 | return limiter | ||
| 70 | } | 75 | } | ||
| 76 | + | ||||
| 77 | + | func (s *MemoryStore) evictExpiredOrOldest(now time.Time) { | |||
| 78 | + | for key, entry := range s.limiters { | |||
| 79 | + | if now.Sub(entry.lastSeen) > limiterIdleTTL { | |||
| 80 | + | delete(s.limiters, key) | |||
| 81 | + | } | |||
| 82 | + | } | |||
| 83 | + | if len(s.limiters) < maxLimiterEntries { | |||
| 84 | + | return | |||
| 85 | + | } | |||
| 86 | + | ||||
| 87 | + | var oldestKey string | |||
| 88 | + | var oldest time.Time | |||
| 89 | + | found := false | |||
| 90 | + | for key, entry := range s.limiters { | |||
| 91 | + | if !found || entry.lastSeen.Before(oldest) { | |||
| 92 | + | oldestKey = key | |||
| 93 | + | oldest = entry.lastSeen | |||
| 94 | + | found = true | |||
| 95 | + | } | |||
| 96 | + | } | |||
| 97 | + | if found { | |||
| 98 | + | delete(s.limiters, oldestKey) | |||
| 99 | + | } | |||
| 100 | + | } | |||
M internal/app/run.go +3 -2
| @@ -747,8 +747,9 @@ func (si *serviceInit) initRuntimeAndProxy() error { | |||
| 747 | 747 | return err | |
| 748 | 748 | } | |
| 749 | 749 | ||
| 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) | |
| 750 | + | registryMutationMu := &sync.RWMutex{} | |
| 751 | + | si.svc.registrySvc = registrySvc.NewService(si.svc.blobStorage, si.svc.manifestStorage, si.svc.eventBus, registryMutationMu) | |
| 752 | + | si.svc.imageSvc = images.NewService(si.svc.runtime, si.svc.manifestStorage, si.svc.blobStorage, si.log, registryMutationMu) | |
| 752 | 753 | si.svc.volumeSvc = volumesSvc.NewService(si.svc.runtime) | |
| 753 | 754 | ||
| 754 | 755 | injectTelemetryMetrics(si.cfg, si.svc, si.log) | |
| @@ -747,8 +747,9 @@ func (si *serviceInit) initRuntimeAndProxy() error { | |||||
| 747 | return err | 747 | return err | ||
| 748 | } | 748 | } | ||
| 749 | 749 | ||||
| 750 | - | si.svc.registrySvc = registrySvc.NewService(si.svc.blobStorage, si.svc.manifestStorage, si.svc.eventBus) | 750 | + | registryMutationMu := &sync.RWMutex{} |
| 751 | - | si.svc.imageSvc = images.NewService(si.svc.runtime, si.svc.manifestStorage, si.svc.blobStorage, si.log) | 751 | + | si.svc.registrySvc = registrySvc.NewService(si.svc.blobStorage, si.svc.manifestStorage, si.svc.eventBus, registryMutationMu) |
| 752 | + | si.svc.imageSvc = images.NewService(si.svc.runtime, si.svc.manifestStorage, si.svc.blobStorage, si.log, registryMutationMu) | |||
| 752 | si.svc.volumeSvc = volumesSvc.NewService(si.svc.runtime) | 753 | si.svc.volumeSvc = volumesSvc.NewService(si.svc.runtime) | ||
| 753 | 754 | ||||
| 754 | injectTelemetryMetrics(si.cfg, si.svc, si.log) | 755 | 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/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 +21 -13
| @@ -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) | |
| @@ -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) | ||
M internal/usecase/auth/service_test.go +14 -0
| @@ -939,6 +939,20 @@ func TestIsEphemeralAccessTokenRejectsFutureIat(t *testing.T) { | |||
| 939 | 939 | } | |
| 940 | 940 | } | |
| 941 | 941 | ||
| 942 | + | func TestIsEphemeralAccessTokenRequiresExplicitTokenType(t *testing.T) { | |
| 943 | + | svc, _ := newTestAuthService(t) | |
| 944 | + | now := time.Now().UTC() | |
| 945 | + | ||
| 946 | + | claims := &domain.TokenClaims{ | |
| 947 | + | IssuedAt: now.Unix(), | |
| 948 | + | ExpiresAt: now.Add(5 * time.Minute).Unix(), | |
| 949 | + | } | |
| 950 | + | ||
| 951 | + | assert.False(t, svc.isEphemeralAccessToken(claims)) | |
| 952 | + | claims.TokenType = "access" | |
| 953 | + | assert.True(t, svc.isEphemeralAccessToken(claims)) | |
| 954 | + | } | |
| 955 | + | ||
| 942 | 956 | func TestExtendTokenSkipsServiceToken(t *testing.T) { | |
| 943 | 957 | tokenStore := mocks.NewMockTokenStore(t) | |
| 944 | 958 | ||
| @@ -939,6 +939,20 @@ func TestIsEphemeralAccessTokenRejectsFutureIat(t *testing.T) { | |||||
| 939 | } | 939 | } | ||
| 940 | } | 940 | } | ||
| 941 | 941 | ||||
| 942 | + | func TestIsEphemeralAccessTokenRequiresExplicitTokenType(t *testing.T) { | |||
| 943 | + | svc, _ := newTestAuthService(t) | |||
| 944 | + | now := time.Now().UTC() | |||
| 945 | + | ||||
| 946 | + | claims := &domain.TokenClaims{ | |||
| 947 | + | IssuedAt: now.Unix(), | |||
| 948 | + | ExpiresAt: now.Add(5 * time.Minute).Unix(), | |||
| 949 | + | } | |||
| 950 | + | ||||
| 951 | + | assert.False(t, svc.isEphemeralAccessToken(claims)) | |||
| 952 | + | claims.TokenType = "access" | |||
| 953 | + | assert.True(t, svc.isEphemeralAccessToken(claims)) | |||
| 954 | + | } | |||
| 955 | + | ||||
| 942 | func TestExtendTokenSkipsServiceToken(t *testing.T) { | 956 | func TestExtendTokenSkipsServiceToken(t *testing.T) { | ||
| 943 | tokenStore := mocks.NewMockTokenStore(t) | 957 | tokenStore := mocks.NewMockTokenStore(t) | ||
| 944 | 958 | ||||
M internal/usecase/auto/preview/handler.go +13 -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,18 @@ 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 <-h.serviceCtx.Done(): | |
| 83 | + | return h.serviceCtx.Err() | |
| 84 | + | default: | |
| 85 | + | log.Warn().Str("preview", previewName).Str("base_route", baseRoute.Domain).Msg("preview worker queue is full, skipping event") | |
| 86 | + | continue | |
| 87 | + | } | |
| 77 | 88 | go func(baseRoute baseRouteInfo, previewDomain string) { | |
| 89 | + | defer func() { <-h.jobs }() | |
| 78 | 90 | if err := h.previewService.CreatePreview(h.serviceCtx, CreatePreviewRequest{ | |
| 79 | 91 | Name: previewName, | |
| 80 | 92 | 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,18 @@ 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 <-h.serviceCtx.Done(): | |||
| 83 | + | return h.serviceCtx.Err() | |||
| 84 | + | default: | |||
| 85 | + | log.Warn().Str("preview", previewName).Str("base_route", baseRoute.Domain).Msg("preview worker queue is full, skipping event") | |||
| 86 | + | continue | |||
| 87 | + | } | |||
| 77 | go func(baseRoute baseRouteInfo, previewDomain string) { | 88 | go func(baseRoute baseRouteInfo, previewDomain string) { | ||
| 89 | + | defer func() { <-h.jobs }() | |||
| 78 | if err := h.previewService.CreatePreview(h.serviceCtx, CreatePreviewRequest{ | 90 | if err := h.previewService.CreatePreview(h.serviceCtx, CreatePreviewRequest{ | ||
| 79 | Name: previewName, | 91 | Name: previewName, | ||
| 80 | Domain: previewDomain, | 92 | Domain: previewDomain, | ||
M internal/usecase/auto/preview/service.go +17 -7
| @@ -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,7 @@ 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) + "-" | |
| 332 | 342 | ||
| 333 | 343 | vols, err := s.volumeCloner.ListVolumes(ctx) | |
| 334 | 344 | if err != nil { | |
| @@ -370,11 +380,11 @@ type CreatePreviewRequest struct { | |||
| 370 | 380 | ||
| 371 | 381 | // CreatePreview orchestrates the full preview creation: lock, clone volumes, start containers, register. | |
| 372 | 382 | func (s *Service) CreatePreview(ctx context.Context, req CreatePreviewRequest) error { | |
| 373 | - | lock := s.AcquireNameLock(req.Name) | |
| 383 | + | lock := s.AcquireNameLock(req.Domain) | |
| 374 | 384 | lock.Lock() | |
| 375 | 385 | defer func() { | |
| 376 | 386 | lock.Unlock() | |
| 377 | - | s.ReleaseNameLock(req.Name) | |
| 387 | + | s.ReleaseNameLock(req.Domain) | |
| 378 | 388 | }() | |
| 379 | 389 | ||
| 380 | 390 | 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,7 @@ 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) + "-" |
| 332 | 342 | ||||
| 333 | vols, err := s.volumeCloner.ListVolumes(ctx) | 343 | vols, err := s.volumeCloner.ListVolumes(ctx) | ||
| 334 | if err != nil { | 344 | if err != nil { | ||
| @@ -370,11 +380,11 @@ type CreatePreviewRequest struct { | |||||
| 370 | 380 | ||||
| 371 | // CreatePreview orchestrates the full preview creation: lock, clone volumes, start containers, register. | 381 | // CreatePreview orchestrates the full preview creation: lock, clone volumes, start containers, register. | ||
| 372 | func (s *Service) CreatePreview(ctx context.Context, req CreatePreviewRequest) error { | 382 | func (s *Service) CreatePreview(ctx context.Context, req CreatePreviewRequest) error { | ||
| 373 | - | lock := s.AcquireNameLock(req.Name) | 383 | + | lock := s.AcquireNameLock(req.Domain) |
| 374 | lock.Lock() | 384 | lock.Lock() | ||
| 375 | defer func() { | 385 | defer func() { | ||
| 376 | lock.Unlock() | 386 | lock.Unlock() | ||
| 377 | - | s.ReleaseNameLock(req.Name) | 387 | + | s.ReleaseNameLock(req.Domain) |
| 378 | }() | 388 | }() | ||
| 379 | 389 | ||||
| 380 | log := zerowrap.FromCtx(ctx) | 390 | log := zerowrap.FromCtx(ctx) | ||
M internal/usecase/container/service.go +41 -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,16 @@ 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 | + | volumes[path] = legacyName | |
| 2544 | + | continue | |
| 2545 | + | } | |
| 2540 | 2546 | ||
| 2541 | 2547 | exists, err := s.runtime.VolumeExists(ctx, name) | |
| 2542 | 2548 | if err != nil { | |
| @@ -2593,7 +2599,7 @@ func (s *Service) generateNetworkName(identifier string) string { | |||
| 2593 | 2599 | cfg := s.config | |
| 2594 | 2600 | s.mu.RUnlock() | |
| 2595 | 2601 | ||
| 2596 | - | return fmt.Sprintf("%s-%s", cfg.NetworkPrefix, strings.ReplaceAll(identifier, ".", "-")) | |
| 2602 | + | return fmt.Sprintf("%s-%s", cfg.NetworkPrefix, domain.StableResourceName(identifier)) | |
| 2597 | 2603 | } | |
| 2598 | 2604 | ||
| 2599 | 2605 | func (s *Service) createNetworkIfNeeded(ctx context.Context, networkName string) error { | |
| @@ -2613,7 +2619,11 @@ func (s *Service) createNetworkIfNeeded(ctx context.Context, networkName string) | |||
| 2613 | 2619 | s.mu.RLock() | |
| 2614 | 2620 | internal := s.config.NetworkInternal | |
| 2615 | 2621 | s.mu.RUnlock() | |
| 2616 | - | if err := s.runtime.CreateNetwork(ctx, networkName, domain.NetworkConfig{Driver: "bridge", Internal: internal}); err != nil { | |
| 2622 | + | if err := s.runtime.CreateNetwork(ctx, networkName, domain.NetworkConfig{ | |
| 2623 | + | Driver: "bridge", | |
| 2624 | + | Internal: internal, | |
| 2625 | + | Labels: map[string]string{domain.LabelManaged: "true"}, | |
| 2626 | + | }); err != nil { | |
| 2617 | 2627 | return log.WrapErr(err, "failed to create network") | |
| 2618 | 2628 | } | |
| 2619 | 2629 | log.Info().Msg("created network for app isolation") | |
| @@ -2624,7 +2634,7 @@ func (s *Service) createNetworkIfNeeded(ctx context.Context, networkName string) | |||
| 2624 | 2634 | ||
| 2625 | 2635 | func (s *Service) cleanupOrphanedContainers(ctx context.Context, domainName string, skipContainerID string) error { | |
| 2626 | 2636 | log := zerowrap.FromCtx(ctx) | |
| 2627 | - | expectedName := fmt.Sprintf("gordon-%s", domainName) | |
| 2637 | + | expectedName := managedContainerName(domainName) | |
| 2628 | 2638 | expectedNewName := expectedName + "-new" | |
| 2629 | 2639 | expectedNextName := expectedName + "-next" | |
| 2630 | 2640 | ||
| @@ -2681,7 +2691,7 @@ func (s *Service) cleanupNetworkIfEmpty(ctx context.Context, networkName string) | |||
| 2681 | 2691 | } | |
| 2682 | 2692 | ||
| 2683 | 2693 | for _, n := range networks { | |
| 2684 | - | if n.Name == networkName && len(n.Containers) == 0 { | |
| 2694 | + | if n.Name == networkName && len(n.Containers) == 0 && n.Labels[domain.LabelManaged] == "true" { | |
| 2685 | 2695 | if err := s.runtime.RemoveNetwork(ctx, networkName); err != nil { | |
| 2686 | 2696 | return err | |
| 2687 | 2697 | } | |
| @@ -3125,6 +3135,11 @@ func (s *Service) resolveExistingAttachment(ctx context.Context, ownerDomain, se | |||
| 3125 | 3135 | return nil, nil | |
| 3126 | 3136 | } | |
| 3127 | 3137 | ||
| 3138 | + | if existingContainer.Labels[domain.LabelManaged] != "true" || | |
| 3139 | + | existingContainer.Labels[domain.LabelAttachment] != "true" || | |
| 3140 | + | existingContainer.Labels[domain.LabelAttachedTo] != ownerDomain { | |
| 3141 | + | return nil, fmt.Errorf("legacy attachment name %q is owned by another workload", containerNameLegacy) | |
| 3142 | + | } | |
| 3128 | 3143 | log.Info().Str("container_name", containerNameLegacy).Msg("found attachment with legacy naming, will be replaced") | |
| 3129 | 3144 | if err := s.runtime.StopContainer(ctx, existingContainer.ID); err != nil { | |
| 3130 | 3145 | return nil, log.WrapErr(err, "failed to stop legacy attachment container") | |
| @@ -3138,6 +3153,10 @@ func (s *Service) resolveExistingAttachment(ctx context.Context, ownerDomain, se | |||
| 3138 | 3153 | ||
| 3139 | 3154 | // Utility functions | |
| 3140 | 3155 | ||
| 3156 | + | func managedContainerName(domainName string) string { | |
| 3157 | + | return "gordon-" + domainName | |
| 3158 | + | } | |
| 3159 | + | ||
| 3141 | 3160 | func normalizeImageRef(image string) string { | |
| 3142 | 3161 | if isDigestRef(image) { | |
| 3143 | 3162 | return image | |
| @@ -3179,6 +3198,13 @@ func normalizeImageRef(image string) string { | |||
| 3179 | 3198 | } | |
| 3180 | 3199 | ||
| 3181 | 3200 | func generateVolumeName(prefix, domainName, volumePath string) string { | |
| 3201 | + | return fmt.Sprintf("%s-%s-%s", | |
| 3202 | + | prefix, | |
| 3203 | + | domain.StableResourceName(domainName), | |
| 3204 | + | domain.StableResourceName(strings.Trim(volumePath, "/"))) | |
| 3205 | + | } | |
| 3206 | + | ||
| 3207 | + | func legacyVolumeName(prefix, domainName, volumePath string) string { | |
| 3182 | 3208 | return fmt.Sprintf("%s-%s-%s", | |
| 3183 | 3209 | prefix, | |
| 3184 | 3210 | strings.ReplaceAll(domainName, ".", "-"), | |
| @@ -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,16 @@ 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 | + | volumes[path] = legacyName | |||
| 2544 | + | continue | |||
| 2545 | + | } | |||
| 2540 | 2546 | ||||
| 2541 | exists, err := s.runtime.VolumeExists(ctx, name) | 2547 | exists, err := s.runtime.VolumeExists(ctx, name) | ||
| 2542 | if err != nil { | 2548 | if err != nil { | ||
| @@ -2593,7 +2599,7 @@ func (s *Service) generateNetworkName(identifier string) string { | |||||
| 2593 | cfg := s.config | 2599 | cfg := s.config | ||
| 2594 | s.mu.RUnlock() | 2600 | s.mu.RUnlock() | ||
| 2595 | 2601 | ||||
| 2596 | - | return fmt.Sprintf("%s-%s", cfg.NetworkPrefix, strings.ReplaceAll(identifier, ".", "-")) | 2602 | + | return fmt.Sprintf("%s-%s", cfg.NetworkPrefix, domain.StableResourceName(identifier)) |
| 2597 | } | 2603 | } | ||
| 2598 | 2604 | ||||
| 2599 | func (s *Service) createNetworkIfNeeded(ctx context.Context, networkName string) error { | 2605 | func (s *Service) createNetworkIfNeeded(ctx context.Context, networkName string) error { | ||
| @@ -2613,7 +2619,11 @@ func (s *Service) createNetworkIfNeeded(ctx context.Context, networkName string) | |||||
| 2613 | s.mu.RLock() | 2619 | s.mu.RLock() | ||
| 2614 | internal := s.config.NetworkInternal | 2620 | internal := s.config.NetworkInternal | ||
| 2615 | s.mu.RUnlock() | 2621 | s.mu.RUnlock() | ||
| 2616 | - | if err := s.runtime.CreateNetwork(ctx, networkName, domain.NetworkConfig{Driver: "bridge", Internal: internal}); err != nil { | 2622 | + | if err := s.runtime.CreateNetwork(ctx, networkName, domain.NetworkConfig{ |
| 2623 | + | Driver: "bridge", | |||
| 2624 | + | Internal: internal, | |||
| 2625 | + | Labels: map[string]string{domain.LabelManaged: "true"}, | |||
| 2626 | + | }); err != nil { | |||
| 2617 | return log.WrapErr(err, "failed to create network") | 2627 | return log.WrapErr(err, "failed to create network") | ||
| 2618 | } | 2628 | } | ||
| 2619 | log.Info().Msg("created network for app isolation") | 2629 | log.Info().Msg("created network for app isolation") | ||
| @@ -2624,7 +2634,7 @@ func (s *Service) createNetworkIfNeeded(ctx context.Context, networkName string) | |||||
| 2624 | 2634 | ||||
| 2625 | func (s *Service) cleanupOrphanedContainers(ctx context.Context, domainName string, skipContainerID string) error { | 2635 | func (s *Service) cleanupOrphanedContainers(ctx context.Context, domainName string, skipContainerID string) error { | ||
| 2626 | log := zerowrap.FromCtx(ctx) | 2636 | log := zerowrap.FromCtx(ctx) | ||
| 2627 | - | expectedName := fmt.Sprintf("gordon-%s", domainName) | 2637 | + | expectedName := managedContainerName(domainName) |
| 2628 | expectedNewName := expectedName + "-new" | 2638 | expectedNewName := expectedName + "-new" | ||
| 2629 | expectedNextName := expectedName + "-next" | 2639 | expectedNextName := expectedName + "-next" | ||
| 2630 | 2640 | ||||
| @@ -2681,7 +2691,7 @@ func (s *Service) cleanupNetworkIfEmpty(ctx context.Context, networkName string) | |||||
| 2681 | } | 2691 | } | ||
| 2682 | 2692 | ||||
| 2683 | for _, n := range networks { | 2693 | for _, n := range networks { | ||
| 2684 | - | if n.Name == networkName && len(n.Containers) == 0 { | 2694 | + | if n.Name == networkName && len(n.Containers) == 0 && n.Labels[domain.LabelManaged] == "true" { |
| 2685 | if err := s.runtime.RemoveNetwork(ctx, networkName); err != nil { | 2695 | if err := s.runtime.RemoveNetwork(ctx, networkName); err != nil { | ||
| 2686 | return err | 2696 | return err | ||
| 2687 | } | 2697 | } | ||
| @@ -3125,6 +3135,11 @@ func (s *Service) resolveExistingAttachment(ctx context.Context, ownerDomain, se | |||||
| 3125 | return nil, nil | 3135 | return nil, nil | ||
| 3126 | } | 3136 | } | ||
| 3127 | 3137 | ||||
| 3138 | + | if existingContainer.Labels[domain.LabelManaged] != "true" || | |||
| 3139 | + | existingContainer.Labels[domain.LabelAttachment] != "true" || | |||
| 3140 | + | existingContainer.Labels[domain.LabelAttachedTo] != ownerDomain { | |||
| 3141 | + | return nil, fmt.Errorf("legacy attachment name %q is owned by another workload", containerNameLegacy) | |||
| 3142 | + | } | |||
| 3128 | log.Info().Str("container_name", containerNameLegacy).Msg("found attachment with legacy naming, will be replaced") | 3143 | 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 { | 3144 | if err := s.runtime.StopContainer(ctx, existingContainer.ID); err != nil { | ||
| 3130 | return nil, log.WrapErr(err, "failed to stop legacy attachment container") | 3145 | return nil, log.WrapErr(err, "failed to stop legacy attachment container") | ||
| @@ -3138,6 +3153,10 @@ func (s *Service) resolveExistingAttachment(ctx context.Context, ownerDomain, se | |||||
| 3138 | 3153 | ||||
| 3139 | // Utility functions | 3154 | // Utility functions | ||
| 3140 | 3155 | ||||
| 3156 | + | func managedContainerName(domainName string) string { | |||
| 3157 | + | return "gordon-" + domainName | |||
| 3158 | + | } | |||
| 3159 | + | ||||
| 3141 | func normalizeImageRef(image string) string { | 3160 | func normalizeImageRef(image string) string { | ||
| 3142 | if isDigestRef(image) { | 3161 | if isDigestRef(image) { | ||
| 3143 | return image | 3162 | return image | ||
| @@ -3179,6 +3198,13 @@ func normalizeImageRef(image string) string { | |||||
| 3179 | } | 3198 | } | ||
| 3180 | 3199 | ||||
| 3181 | func generateVolumeName(prefix, domainName, volumePath string) string { | 3200 | func generateVolumeName(prefix, domainName, volumePath string) string { | ||
| 3201 | + | return fmt.Sprintf("%s-%s-%s", | |||
| 3202 | + | prefix, | |||
| 3203 | + | domain.StableResourceName(domainName), | |||
| 3204 | + | domain.StableResourceName(strings.Trim(volumePath, "/"))) | |||
| 3205 | + | } | |||
| 3206 | + | ||||
| 3207 | + | func legacyVolumeName(prefix, domainName, volumePath string) string { | |||
| 3182 | return fmt.Sprintf("%s-%s-%s", | 3208 | return fmt.Sprintf("%s-%s-%s", | ||
| 3183 | prefix, | 3209 | prefix, | ||
| 3184 | strings.ReplaceAll(domainName, ".", "-"), | 3210 | strings.ReplaceAll(domainName, ".", "-"), | ||
M internal/usecase/container/service_test.go +45 -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,20 @@ 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 | + | ||
| 1798 | + | volumes, err := svc.setupVolumes(testContext(), "app.example.com", "myapp:latest") | |
| 1799 | + | ||
| 1800 | + | require.NoError(t, err) | |
| 1801 | + | assert.Equal(t, map[string]string{"/data": legacyName}, volumes) | |
| 1802 | + | } | |
| 1803 | + | ||
| 1799 | 1804 | func TestMergeEnvironmentVariables(t *testing.T) { | |
| 1800 | 1805 | dockerEnv := []string{"FOO=docker", "BAR=docker"} | |
| 1801 | 1806 | userEnv := []string{"FOO=user", "BAZ=user"} | |
| @@ -4480,7 +4485,11 @@ func TestService_CreateNetworkIfNeeded_UsesInternalOptionWhenConfigured(t *testi | |||
| 4480 | 4485 | svc := NewService(runtime, nil, nil, nil, Config{NetworkInternal: true}, nil) | |
| 4481 | 4486 | ||
| 4482 | 4487 | 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) | |
| 4488 | + | runtime.EXPECT().CreateNetwork(mock.Anything, "gordon-app", domain.NetworkConfig{ | |
| 4489 | + | Driver: "bridge", | |
| 4490 | + | Internal: true, | |
| 4491 | + | Labels: map[string]string{domain.LabelManaged: "true"}, | |
| 4492 | + | }).Return(nil) | |
| 4484 | 4493 | ||
| 4485 | 4494 | require.NoError(t, svc.createNetworkIfNeeded(testContext(), "gordon-app")) | |
| 4486 | 4495 | } | |
| @@ -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,20 @@ 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 | + | ||||
| 1798 | + | volumes, err := svc.setupVolumes(testContext(), "app.example.com", "myapp:latest") | |||
| 1799 | + | ||||
| 1800 | + | require.NoError(t, err) | |||
| 1801 | + | assert.Equal(t, map[string]string{"/data": legacyName}, volumes) | |||
| 1802 | + | } | |||
| 1803 | + | ||||
| 1799 | func TestMergeEnvironmentVariables(t *testing.T) { | 1804 | func TestMergeEnvironmentVariables(t *testing.T) { | ||
| 1800 | dockerEnv := []string{"FOO=docker", "BAR=docker"} | 1805 | dockerEnv := []string{"FOO=docker", "BAR=docker"} | ||
| 1801 | userEnv := []string{"FOO=user", "BAZ=user"} | 1806 | userEnv := []string{"FOO=user", "BAZ=user"} | ||
| @@ -4480,7 +4485,11 @@ func TestService_CreateNetworkIfNeeded_UsesInternalOptionWhenConfigured(t *testi | |||||
| 4480 | svc := NewService(runtime, nil, nil, nil, Config{NetworkInternal: true}, nil) | 4485 | svc := NewService(runtime, nil, nil, nil, Config{NetworkInternal: true}, nil) | ||
| 4481 | 4486 | ||||
| 4482 | runtime.EXPECT().NetworkExists(mock.Anything, "gordon-app").Return(false, nil) | 4487 | 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) | 4488 | + | runtime.EXPECT().CreateNetwork(mock.Anything, "gordon-app", domain.NetworkConfig{ |
| 4489 | + | Driver: "bridge", | |||
| 4490 | + | Internal: true, | |||
| 4491 | + | Labels: map[string]string{domain.LabelManaged: "true"}, | |||
| 4492 | + | }).Return(nil) | |||
| 4484 | 4493 | ||||
| 4485 | require.NoError(t, svc.createNetworkIfNeeded(testContext(), "gordon-app")) | 4494 | require.NoError(t, svc.createNetworkIfNeeded(testContext(), "gordon-app")) | ||
| 4486 | } | 4495 | } | ||
M internal/usecase/images/service.go +11 -0
| @@ -7,6 +7,7 @@ import ( | |||
| 7 | 7 | "fmt" | |
| 8 | 8 | "sort" | |
| 9 | 9 | "strings" | |
| 10 | + | "sync" | |
| 10 | 11 | "time" | |
| 11 | 12 | ||
| 12 | 13 | "github.com/bnema/zerowrap" | |
| @@ -23,6 +24,7 @@ type Service struct { | |||
| 23 | 24 | manifestStorage out.ManifestStorage | |
| 24 | 25 | blobStorage out.BlobStorage | |
| 25 | 26 | log zerowrap.Logger | |
| 27 | + | mutationMu *sync.RWMutex | |
| 26 | 28 | } | |
| 27 | 29 | ||
| 28 | 30 | type imageRuntime interface { | |
| @@ -36,12 +38,18 @@ func NewService( | |||
| 36 | 38 | manifestStorage out.ManifestStorage, | |
| 37 | 39 | blobStorage out.BlobStorage, | |
| 38 | 40 | log zerowrap.Logger, | |
| 41 | + | mutationLocks ...*sync.RWMutex, | |
| 39 | 42 | ) *Service { | |
| 43 | + | mutationMu := &sync.RWMutex{} | |
| 44 | + | if len(mutationLocks) > 0 && mutationLocks[0] != nil { | |
| 45 | + | mutationMu = mutationLocks[0] | |
| 46 | + | } | |
| 40 | 47 | return &Service{ | |
| 41 | 48 | runtime: rt, | |
| 42 | 49 | manifestStorage: manifestStorage, | |
| 43 | 50 | blobStorage: blobStorage, | |
| 44 | 51 | log: log, | |
| 52 | + | mutationMu: mutationMu, | |
| 45 | 53 | } | |
| 46 | 54 | } | |
| 47 | 55 | ||
| @@ -194,6 +202,9 @@ func (s *Service) PruneRuntime(ctx context.Context) (domain.ImagePruneReport, er | |||
| 194 | 202 | // It keeps the "latest" tag when present and keeps keepLast most-recent | |
| 195 | 203 | // non-latest tags from tagInfos. | |
| 196 | 204 | func (s *Service) PruneRegistry(ctx context.Context, keepLast int) (domain.ImagePruneReport, error) { | |
| 205 | + | s.mutationMu.Lock() | |
| 206 | + | defer s.mutationMu.Unlock() | |
| 207 | + | ||
| 197 | 208 | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | |
| 198 | 209 | zerowrap.FieldLayer: "usecase", | |
| 199 | 210 | zerowrap.FieldUseCase: "PruneRegistry", | |
| @@ -7,6 +7,7 @@ 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" | ||
| @@ -23,6 +24,7 @@ type Service struct { | |||||
| 23 | manifestStorage out.ManifestStorage | 24 | manifestStorage out.ManifestStorage | ||
| 24 | blobStorage out.BlobStorage | 25 | blobStorage out.BlobStorage | ||
| 25 | log zerowrap.Logger | 26 | log zerowrap.Logger | ||
| 27 | + | mutationMu *sync.RWMutex | |||
| 26 | } | 28 | } | ||
| 27 | 29 | ||||
| 28 | type imageRuntime interface { | 30 | type imageRuntime interface { | ||
| @@ -36,12 +38,18 @@ func NewService( | |||||
| 36 | manifestStorage out.ManifestStorage, | 38 | manifestStorage out.ManifestStorage, | ||
| 37 | blobStorage out.BlobStorage, | 39 | blobStorage out.BlobStorage, | ||
| 38 | log zerowrap.Logger, | 40 | log zerowrap.Logger, | ||
| 41 | + | mutationLocks ...*sync.RWMutex, | |||
| 39 | ) *Service { | 42 | ) *Service { | ||
| 43 | + | mutationMu := &sync.RWMutex{} | |||
| 44 | + | if len(mutationLocks) > 0 && mutationLocks[0] != nil { | |||
| 45 | + | mutationMu = mutationLocks[0] | |||
| 46 | + | } | |||
| 40 | return &Service{ | 47 | return &Service{ | ||
| 41 | runtime: rt, | 48 | runtime: rt, | ||
| 42 | manifestStorage: manifestStorage, | 49 | manifestStorage: manifestStorage, | ||
| 43 | blobStorage: blobStorage, | 50 | blobStorage: blobStorage, | ||
| 44 | log: log, | 51 | log: log, | ||
| 52 | + | mutationMu: mutationMu, | |||
| 45 | } | 53 | } | ||
| 46 | } | 54 | } | ||
| 47 | 55 | ||||
| @@ -194,6 +202,9 @@ func (s *Service) PruneRuntime(ctx context.Context) (domain.ImagePruneReport, er | |||||
| 194 | // It keeps the "latest" tag when present and keeps keepLast most-recent | 202 | // It keeps the "latest" tag when present and keeps keepLast most-recent | ||
| 195 | // non-latest tags from tagInfos. | 203 | // non-latest tags from tagInfos. | ||
| 196 | func (s *Service) PruneRegistry(ctx context.Context, keepLast int) (domain.ImagePruneReport, error) { | 204 | func (s *Service) PruneRegistry(ctx context.Context, keepLast int) (domain.ImagePruneReport, error) { | ||
| 205 | + | s.mutationMu.Lock() | |||
| 206 | + | defer s.mutationMu.Unlock() | |||
| 207 | + | ||||
| 197 | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | 208 | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | ||
| 198 | zerowrap.FieldLayer: "usecase", | 209 | zerowrap.FieldLayer: "usecase", | ||
| 199 | zerowrap.FieldUseCase: "PruneRegistry", | 210 | zerowrap.FieldUseCase: "PruneRegistry", | ||
M internal/usecase/registry/service.go +122 -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" | |
| @@ -31,6 +34,7 @@ type Service struct { | |||
| 31 | 34 | eventBus out.EventPublisher | |
| 32 | 35 | metrics *telemetry.Metrics | |
| 33 | 36 | suppressedImages sync.Map // imageName -> *time.Timer | |
| 37 | + | mutationMu *sync.RWMutex | |
| 34 | 38 | } | |
| 35 | 39 | ||
| 36 | 40 | // SetMetrics sets the telemetry metrics for the registry service. | |
| @@ -43,11 +47,17 @@ func NewService( | |||
| 43 | 47 | blobStorage out.BlobStorage, | |
| 44 | 48 | manifestStorage out.ManifestStorage, | |
| 45 | 49 | eventBus out.EventPublisher, | |
| 50 | + | mutationLocks ...*sync.RWMutex, | |
| 46 | 51 | ) *Service { | |
| 52 | + | mutationMu := &sync.RWMutex{} | |
| 53 | + | if len(mutationLocks) > 0 && mutationLocks[0] != nil { | |
| 54 | + | mutationMu = mutationLocks[0] | |
| 55 | + | } | |
| 47 | 56 | return &Service{ | |
| 48 | 57 | blobStorage: blobStorage, | |
| 49 | 58 | manifestStorage: manifestStorage, | |
| 50 | 59 | eventBus: eventBus, | |
| 60 | + | mutationMu: mutationMu, | |
| 51 | 61 | } | |
| 52 | 62 | } | |
| 53 | 63 | ||
| @@ -155,6 +165,9 @@ func (s *Service) GetManifest(ctx context.Context, name, reference string) (*dom | |||
| 155 | 165 | ||
| 156 | 166 | // PutManifest stores a manifest and returns the calculated digest. | |
| 157 | 167 | func (s *Service) PutManifest(ctx context.Context, manifest *domain.Manifest) (string, error) { | |
| 168 | + | s.mutationMu.RLock() | |
| 169 | + | defer s.mutationMu.RUnlock() | |
| 170 | + | ||
| 158 | 171 | ctx, span := registryTracer.Start(ctx, "registry.put_manifest", | |
| 159 | 172 | trace.WithAttributes( | |
| 160 | 173 | attribute.String("name", manifest.Name), | |
| @@ -171,8 +184,20 @@ func (s *Service) PutManifest(ctx context.Context, manifest *domain.Manifest) (s | |||
| 171 | 184 | }) | |
| 172 | 185 | log := zerowrap.FromCtx(ctx) | |
| 173 | 186 | ||
| 174 | - | // Calculate digest | |
| 187 | + | // Calculate and verify the digest before mutating storage. A digest-addressed | |
| 188 | + | // manifest must match its URL reference or clients could later retrieve | |
| 189 | + | // attacker-controlled bytes under a trusted content address. | |
| 175 | 190 | digest := fmt.Sprintf("sha256:%x", sha256.Sum256(manifest.Data)) | |
| 191 | + | if validation.IsDigest(manifest.Reference) { | |
| 192 | + | matches, err := manifestDigestMatches(manifest.Reference, manifest.Data) | |
| 193 | + | if err != nil { | |
| 194 | + | return "", err | |
| 195 | + | } | |
| 196 | + | if !matches { | |
| 197 | + | return "", fmt.Errorf("%w: manifest content does not match %s", domain.ErrDigestMismatch, manifest.Reference) | |
| 198 | + | } | |
| 199 | + | digest = manifest.Reference | |
| 200 | + | } | |
| 176 | 201 | ||
| 177 | 202 | if err := s.manifestStorage.PutManifest(manifest.Name, manifest.Reference, manifest.ContentType, manifest.Data); err != nil { | |
| 178 | 203 | return "", log.WrapErr(err, "failed to store manifest") | |
| @@ -191,7 +216,7 @@ func (s *Service) PutManifest(ctx context.Context, manifest *domain.Manifest) (s | |||
| 191 | 216 | // Publish image pushed event only for tag references (not digests). | |
| 192 | 217 | // A docker push sends manifests by both digest and tag; firing only on | |
| 193 | 218 | // tag prevents duplicate deploy triggers for the same push. | |
| 194 | - | if s.eventBus != nil && !strings.HasPrefix(manifest.Reference, "sha256:") { | |
| 219 | + | if s.eventBus != nil && !validation.IsDigest(manifest.Reference) { | |
| 195 | 220 | if s.IsDeployEventSuppressed(manifest.Name) { | |
| 196 | 221 | log.Info().Str("image", manifest.Name).Msg("skipping image.pushed event: CLI deploy intent active") | |
| 197 | 222 | } else { | |
| @@ -245,15 +270,25 @@ func (s *Service) GetBlob(ctx context.Context, digest string) (io.ReadCloser, er | |||
| 245 | 270 | return reader, nil | |
| 246 | 271 | } | |
| 247 | 272 | ||
| 248 | - | // GetBlobPath returns the filesystem path to a blob for direct serving. | |
| 249 | - | func (s *Service) GetBlobPath(ctx context.Context, digest string) (string, error) { | |
| 273 | + | // GetBlobPath returns the filesystem path to a blob only when a manifest in | |
| 274 | + | // the requested repository references it. | |
| 275 | + | func (s *Service) GetBlobPath(ctx context.Context, name, digest string) (string, error) { | |
| 250 | 276 | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | |
| 251 | 277 | zerowrap.FieldLayer: "usecase", | |
| 252 | 278 | zerowrap.FieldUseCase: "GetBlobPath", | |
| 279 | + | "name": name, | |
| 253 | 280 | "digest": digest, | |
| 254 | 281 | }) | |
| 255 | 282 | log := zerowrap.FromCtx(ctx) | |
| 256 | 283 | ||
| 284 | + | referenced, err := s.repositoryReferencesDigest(name, digest) | |
| 285 | + | if err != nil { | |
| 286 | + | return "", log.WrapErr(err, "failed to verify blob ownership") | |
| 287 | + | } | |
| 288 | + | if !referenced { | |
| 289 | + | return "", domain.ErrBlobNotFound | |
| 290 | + | } | |
| 291 | + | ||
| 257 | 292 | path, err := s.blobStorage.GetBlobPath(digest) | |
| 258 | 293 | if err != nil { | |
| 259 | 294 | return "", log.WrapErr(err, "failed to get blob path") | |
| @@ -262,8 +297,91 @@ func (s *Service) GetBlobPath(ctx context.Context, digest string) (string, error | |||
| 262 | 297 | return path, nil | |
| 263 | 298 | } | |
| 264 | 299 | ||
| 300 | + | type manifestDescriptor struct { | |
| 301 | + | Digest string `json:"digest"` | |
| 302 | + | } | |
| 303 | + | ||
| 304 | + | type manifestReferences struct { | |
| 305 | + | Config manifestDescriptor `json:"config"` | |
| 306 | + | Layers []manifestDescriptor `json:"layers"` | |
| 307 | + | Manifests []manifestDescriptor `json:"manifests"` | |
| 308 | + | Blobs []manifestDescriptor `json:"blobs"` | |
| 309 | + | Subject *manifestDescriptor `json:"subject"` | |
| 310 | + | } | |
| 311 | + | ||
| 312 | + | func (s *Service) repositoryReferencesDigest(name, target string) (bool, error) { | |
| 313 | + | tags, err := s.manifestStorage.ListTags(name) | |
| 314 | + | if err != nil { | |
| 315 | + | if errors.Is(err, domain.ErrManifestNotFound) { | |
| 316 | + | return false, nil | |
| 317 | + | } | |
| 318 | + | return false, err | |
| 319 | + | } | |
| 320 | + | ||
| 321 | + | queue := append([]string(nil), tags...) | |
| 322 | + | seen := make(map[string]struct{}, len(queue)) | |
| 323 | + | for len(queue) > 0 { | |
| 324 | + | reference := queue[0] | |
| 325 | + | queue = queue[1:] | |
| 326 | + | if _, ok := seen[reference]; ok { | |
| 327 | + | continue | |
| 328 | + | } | |
| 329 | + | seen[reference] = struct{}{} | |
| 330 | + | ||
| 331 | + | data, _, err := s.manifestStorage.GetManifest(name, reference) | |
| 332 | + | if err != nil { | |
| 333 | + | return false, err | |
| 334 | + | } | |
| 335 | + | var refs manifestReferences | |
| 336 | + | if err := json.Unmarshal(data, &refs); err != nil { | |
| 337 | + | return false, fmt.Errorf("decode manifest %s: %w", reference, err) | |
| 338 | + | } | |
| 339 | + | ||
| 340 | + | if refs.Config.Digest == target || descriptorListContains(refs.Layers, target) || | |
| 341 | + | descriptorListContains(refs.Blobs, target) || (refs.Subject != nil && refs.Subject.Digest == target) { | |
| 342 | + | return true, nil | |
| 343 | + | } | |
| 344 | + | for _, child := range refs.Manifests { | |
| 345 | + | if child.Digest == target { | |
| 346 | + | return true, nil | |
| 347 | + | } | |
| 348 | + | if child.Digest != "" { | |
| 349 | + | queue = append(queue, child.Digest) | |
| 350 | + | } | |
| 351 | + | } | |
| 352 | + | } | |
| 353 | + | return false, nil | |
| 354 | + | } | |
| 355 | + | ||
| 356 | + | func descriptorListContains(descriptors []manifestDescriptor, digest string) bool { | |
| 357 | + | for _, descriptor := range descriptors { | |
| 358 | + | if descriptor.Digest == digest { | |
| 359 | + | return true | |
| 360 | + | } | |
| 361 | + | } | |
| 362 | + | return false | |
| 363 | + | } | |
| 364 | + | ||
| 365 | + | func manifestDigestMatches(reference string, data []byte) (bool, error) { | |
| 366 | + | algorithm, _, ok := strings.Cut(reference, ":") | |
| 367 | + | if !ok { | |
| 368 | + | return false, domain.ErrInvalidDigest | |
| 369 | + | } | |
| 370 | + | switch algorithm { | |
| 371 | + | case "sha256": | |
| 372 | + | return reference == fmt.Sprintf("sha256:%x", sha256.Sum256(data)), nil | |
| 373 | + | case "sha512": | |
| 374 | + | return reference == fmt.Sprintf("sha512:%x", sha512.Sum512(data)), nil | |
| 375 | + | default: | |
| 376 | + | return false, domain.ErrInvalidDigest | |
| 377 | + | } | |
| 378 | + | } | |
| 379 | + | ||
| 265 | 380 | // PutBlob stores a blob with the given digest. | |
| 266 | 381 | func (s *Service) PutBlob(ctx context.Context, digest string, data io.Reader, size int64) error { | |
| 382 | + | s.mutationMu.RLock() | |
| 383 | + | defer s.mutationMu.RUnlock() | |
| 384 | + | ||
| 267 | 385 | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | |
| 268 | 386 | zerowrap.FieldLayer: "usecase", | |
| 269 | 387 | zerowrap.FieldUseCase: "PutBlob", | |
| @@ -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" | ||
| @@ -31,6 +34,7 @@ type Service struct { | |||||
| 31 | eventBus out.EventPublisher | 34 | eventBus out.EventPublisher | ||
| 32 | metrics *telemetry.Metrics | 35 | metrics *telemetry.Metrics | ||
| 33 | suppressedImages sync.Map // imageName -> *time.Timer | 36 | suppressedImages sync.Map // imageName -> *time.Timer | ||
| 37 | + | mutationMu *sync.RWMutex | |||
| 34 | } | 38 | } | ||
| 35 | 39 | ||||
| 36 | // SetMetrics sets the telemetry metrics for the registry service. | 40 | // SetMetrics sets the telemetry metrics for the registry service. | ||
| @@ -43,11 +47,17 @@ func NewService( | |||||
| 43 | blobStorage out.BlobStorage, | 47 | blobStorage out.BlobStorage, | ||
| 44 | manifestStorage out.ManifestStorage, | 48 | manifestStorage out.ManifestStorage, | ||
| 45 | eventBus out.EventPublisher, | 49 | eventBus out.EventPublisher, | ||
| 50 | + | mutationLocks ...*sync.RWMutex, | |||
| 46 | ) *Service { | 51 | ) *Service { | ||
| 52 | + | mutationMu := &sync.RWMutex{} | |||
| 53 | + | if len(mutationLocks) > 0 && mutationLocks[0] != nil { | |||
| 54 | + | mutationMu = mutationLocks[0] | |||
| 55 | + | } | |||
| 47 | return &Service{ | 56 | return &Service{ | ||
| 48 | blobStorage: blobStorage, | 57 | blobStorage: blobStorage, | ||
| 49 | manifestStorage: manifestStorage, | 58 | manifestStorage: manifestStorage, | ||
| 50 | eventBus: eventBus, | 59 | eventBus: eventBus, | ||
| 60 | + | mutationMu: mutationMu, | |||
| 51 | } | 61 | } | ||
| 52 | } | 62 | } | ||
| 53 | 63 | ||||
| @@ -155,6 +165,9 @@ func (s *Service) GetManifest(ctx context.Context, name, reference string) (*dom | |||||
| 155 | 165 | ||||
| 156 | // PutManifest stores a manifest and returns the calculated digest. | 166 | // PutManifest stores a manifest and returns the calculated digest. | ||
| 157 | func (s *Service) PutManifest(ctx context.Context, manifest *domain.Manifest) (string, error) { | 167 | func (s *Service) PutManifest(ctx context.Context, manifest *domain.Manifest) (string, error) { | ||
| 168 | + | s.mutationMu.RLock() | |||
| 169 | + | defer s.mutationMu.RUnlock() | |||
| 170 | + | ||||
| 158 | ctx, span := registryTracer.Start(ctx, "registry.put_manifest", | 171 | ctx, span := registryTracer.Start(ctx, "registry.put_manifest", | ||
| 159 | trace.WithAttributes( | 172 | trace.WithAttributes( | ||
| 160 | attribute.String("name", manifest.Name), | 173 | attribute.String("name", manifest.Name), | ||
| @@ -171,8 +184,20 @@ func (s *Service) PutManifest(ctx context.Context, manifest *domain.Manifest) (s | |||||
| 171 | }) | 184 | }) | ||
| 172 | log := zerowrap.FromCtx(ctx) | 185 | log := zerowrap.FromCtx(ctx) | ||
| 173 | 186 | ||||
| 174 | - | // Calculate digest | 187 | + | // Calculate and verify the digest before mutating storage. A digest-addressed |
| 188 | + | // manifest must match its URL reference or clients could later retrieve | |||
| 189 | + | // attacker-controlled bytes under a trusted content address. | |||
| 175 | digest := fmt.Sprintf("sha256:%x", sha256.Sum256(manifest.Data)) | 190 | digest := fmt.Sprintf("sha256:%x", sha256.Sum256(manifest.Data)) | ||
| 191 | + | if validation.IsDigest(manifest.Reference) { | |||
| 192 | + | matches, err := manifestDigestMatches(manifest.Reference, manifest.Data) | |||
| 193 | + | if err != nil { | |||
| 194 | + | return "", err | |||
| 195 | + | } | |||
| 196 | + | if !matches { | |||
| 197 | + | return "", fmt.Errorf("%w: manifest content does not match %s", domain.ErrDigestMismatch, manifest.Reference) | |||
| 198 | + | } | |||
| 199 | + | digest = manifest.Reference | |||
| 200 | + | } | |||
| 176 | 201 | ||||
| 177 | if err := s.manifestStorage.PutManifest(manifest.Name, manifest.Reference, manifest.ContentType, manifest.Data); err != nil { | 202 | if err := s.manifestStorage.PutManifest(manifest.Name, manifest.Reference, manifest.ContentType, manifest.Data); err != nil { | ||
| 178 | return "", log.WrapErr(err, "failed to store manifest") | 203 | return "", log.WrapErr(err, "failed to store manifest") | ||
| @@ -191,7 +216,7 @@ func (s *Service) PutManifest(ctx context.Context, manifest *domain.Manifest) (s | |||||
| 191 | // Publish image pushed event only for tag references (not digests). | 216 | // Publish image pushed event only for tag references (not digests). | ||
| 192 | // A docker push sends manifests by both digest and tag; firing only on | 217 | // A docker push sends manifests by both digest and tag; firing only on | ||
| 193 | // tag prevents duplicate deploy triggers for the same push. | 218 | // tag prevents duplicate deploy triggers for the same push. | ||
| 194 | - | if s.eventBus != nil && !strings.HasPrefix(manifest.Reference, "sha256:") { | 219 | + | if s.eventBus != nil && !validation.IsDigest(manifest.Reference) { |
| 195 | if s.IsDeployEventSuppressed(manifest.Name) { | 220 | if s.IsDeployEventSuppressed(manifest.Name) { | ||
| 196 | log.Info().Str("image", manifest.Name).Msg("skipping image.pushed event: CLI deploy intent active") | 221 | log.Info().Str("image", manifest.Name).Msg("skipping image.pushed event: CLI deploy intent active") | ||
| 197 | } else { | 222 | } else { | ||
| @@ -245,15 +270,25 @@ func (s *Service) GetBlob(ctx context.Context, digest string) (io.ReadCloser, er | |||||
| 245 | return reader, nil | 270 | return reader, nil | ||
| 246 | } | 271 | } | ||
| 247 | 272 | ||||
| 248 | - | // GetBlobPath returns the filesystem path to a blob for direct serving. | 273 | + | // 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) { | 274 | + | // the requested repository references it. |
| 275 | + | func (s *Service) GetBlobPath(ctx context.Context, name, digest string) (string, error) { | |||
| 250 | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | 276 | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | ||
| 251 | zerowrap.FieldLayer: "usecase", | 277 | zerowrap.FieldLayer: "usecase", | ||
| 252 | zerowrap.FieldUseCase: "GetBlobPath", | 278 | zerowrap.FieldUseCase: "GetBlobPath", | ||
| 279 | + | "name": name, | |||
| 253 | "digest": digest, | 280 | "digest": digest, | ||
| 254 | }) | 281 | }) | ||
| 255 | log := zerowrap.FromCtx(ctx) | 282 | log := zerowrap.FromCtx(ctx) | ||
| 256 | 283 | ||||
| 284 | + | referenced, err := s.repositoryReferencesDigest(name, digest) | |||
| 285 | + | if err != nil { | |||
| 286 | + | return "", log.WrapErr(err, "failed to verify blob ownership") | |||
| 287 | + | } | |||
| 288 | + | if !referenced { | |||
| 289 | + | return "", domain.ErrBlobNotFound | |||
| 290 | + | } | |||
| 291 | + | ||||
| 257 | path, err := s.blobStorage.GetBlobPath(digest) | 292 | path, err := s.blobStorage.GetBlobPath(digest) | ||
| 258 | if err != nil { | 293 | if err != nil { | ||
| 259 | return "", log.WrapErr(err, "failed to get blob path") | 294 | return "", log.WrapErr(err, "failed to get blob path") | ||
| @@ -262,8 +297,91 @@ func (s *Service) GetBlobPath(ctx context.Context, digest string) (string, error | |||||
| 262 | return path, nil | 297 | return path, nil | ||
| 263 | } | 298 | } | ||
| 264 | 299 | ||||
| 300 | + | type manifestDescriptor struct { | |||
| 301 | + | Digest string `json:"digest"` | |||
| 302 | + | } | |||
| 303 | + | ||||
| 304 | + | type manifestReferences struct { | |||
| 305 | + | Config manifestDescriptor `json:"config"` | |||
| 306 | + | Layers []manifestDescriptor `json:"layers"` | |||
| 307 | + | Manifests []manifestDescriptor `json:"manifests"` | |||
| 308 | + | Blobs []manifestDescriptor `json:"blobs"` | |||
| 309 | + | Subject *manifestDescriptor `json:"subject"` | |||
| 310 | + | } | |||
| 311 | + | ||||
| 312 | + | func (s *Service) repositoryReferencesDigest(name, target string) (bool, error) { | |||
| 313 | + | tags, err := s.manifestStorage.ListTags(name) | |||
| 314 | + | if err != nil { | |||
| 315 | + | if errors.Is(err, domain.ErrManifestNotFound) { | |||
| 316 | + | return false, nil | |||
| 317 | + | } | |||
| 318 | + | return false, err | |||
| 319 | + | } | |||
| 320 | + | ||||
| 321 | + | queue := append([]string(nil), tags...) | |||
| 322 | + | seen := make(map[string]struct{}, len(queue)) | |||
| 323 | + | for len(queue) > 0 { | |||
| 324 | + | reference := queue[0] | |||
| 325 | + | queue = queue[1:] | |||
| 326 | + | if _, ok := seen[reference]; ok { | |||
| 327 | + | continue | |||
| 328 | + | } | |||
| 329 | + | seen[reference] = struct{}{} | |||
| 330 | + | ||||
| 331 | + | data, _, err := s.manifestStorage.GetManifest(name, reference) | |||
| 332 | + | if err != nil { | |||
| 333 | + | return false, err | |||
| 334 | + | } | |||
| 335 | + | var refs manifestReferences | |||
| 336 | + | if err := json.Unmarshal(data, &refs); err != nil { | |||
| 337 | + | return false, fmt.Errorf("decode manifest %s: %w", reference, err) | |||
| 338 | + | } | |||
| 339 | + | ||||
| 340 | + | if refs.Config.Digest == target || descriptorListContains(refs.Layers, target) || | |||
| 341 | + | descriptorListContains(refs.Blobs, target) || (refs.Subject != nil && refs.Subject.Digest == target) { | |||
| 342 | + | return true, nil | |||
| 343 | + | } | |||
| 344 | + | for _, child := range refs.Manifests { | |||
| 345 | + | if child.Digest == target { | |||
| 346 | + | return true, nil | |||
| 347 | + | } | |||
| 348 | + | if child.Digest != "" { | |||
| 349 | + | queue = append(queue, child.Digest) | |||
| 350 | + | } | |||
| 351 | + | } | |||
| 352 | + | } | |||
| 353 | + | return false, nil | |||
| 354 | + | } | |||
| 355 | + | ||||
| 356 | + | func descriptorListContains(descriptors []manifestDescriptor, digest string) bool { | |||
| 357 | + | for _, descriptor := range descriptors { | |||
| 358 | + | if descriptor.Digest == digest { | |||
| 359 | + | return true | |||
| 360 | + | } | |||
| 361 | + | } | |||
| 362 | + | return false | |||
| 363 | + | } | |||
| 364 | + | ||||
| 365 | + | func manifestDigestMatches(reference string, data []byte) (bool, error) { | |||
| 366 | + | algorithm, _, ok := strings.Cut(reference, ":") | |||
| 367 | + | if !ok { | |||
| 368 | + | return false, domain.ErrInvalidDigest | |||
| 369 | + | } | |||
| 370 | + | switch algorithm { | |||
| 371 | + | case "sha256": | |||
| 372 | + | return reference == fmt.Sprintf("sha256:%x", sha256.Sum256(data)), nil | |||
| 373 | + | case "sha512": | |||
| 374 | + | return reference == fmt.Sprintf("sha512:%x", sha512.Sum512(data)), nil | |||
| 375 | + | default: | |||
| 376 | + | return false, domain.ErrInvalidDigest | |||
| 377 | + | } | |||
| 378 | + | } | |||
| 379 | + | ||||
| 265 | // PutBlob stores a blob with the given digest. | 380 | // PutBlob stores a blob with the given digest. | ||
| 266 | func (s *Service) PutBlob(ctx context.Context, digest string, data io.Reader, size int64) error { | 381 | func (s *Service) PutBlob(ctx context.Context, digest string, data io.Reader, size int64) error { | ||
| 382 | + | s.mutationMu.RLock() | |||
| 383 | + | defer s.mutationMu.RUnlock() | |||
| 384 | + | ||||
| 267 | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | 385 | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | ||
| 268 | zerowrap.FieldLayer: "usecase", | 386 | zerowrap.FieldLayer: "usecase", | ||
| 269 | zerowrap.FieldUseCase: "PutBlob", | 387 | zerowrap.FieldUseCase: "PutBlob", | ||
M internal/usecase/registry/service_test.go +65 -0
| @@ -3,7 +3,9 @@ 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" | |
| @@ -84,6 +86,69 @@ func TestService_PutManifest_Success(t *testing.T) { | |||
| 84 | 86 | assert.True(t, strings.HasPrefix(digest, "sha256:")) | |
| 85 | 87 | } | |
| 86 | 88 | ||
| 89 | + | func TestService_PutManifest_SHA512DigestDoesNotPublishEvent(t *testing.T) { | |
| 90 | + | blobStorage := mocks.NewMockBlobStorage(t) | |
| 91 | + | manifestStorage := mocks.NewMockManifestStorage(t) | |
| 92 | + | eventBus := mocks.NewMockEventPublisher(t) | |
| 93 | + | svc := NewService(blobStorage, manifestStorage, eventBus) | |
| 94 | + | data := []byte(`{"schemaVersion":2}`) | |
| 95 | + | reference := fmt.Sprintf("sha512:%x", sha512.Sum512(data)) | |
| 96 | + | manifest := &domain.Manifest{Name: "myapp", Reference: reference, ContentType: "application/vnd.oci.image.manifest.v1+json", Data: data} | |
| 97 | + | ||
| 98 | + | manifestStorage.EXPECT().PutManifest("myapp", reference, manifest.ContentType, data).Return(nil) | |
| 99 | + | ||
| 100 | + | digest, err := svc.PutManifest(testContext(), manifest) | |
| 101 | + | ||
| 102 | + | require.NoError(t, err) | |
| 103 | + | assert.Equal(t, reference, digest) | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | func TestService_PutManifest_RejectsDigestMismatch(t *testing.T) { | |
| 107 | + | blobStorage := mocks.NewMockBlobStorage(t) | |
| 108 | + | manifestStorage := mocks.NewMockManifestStorage(t) | |
| 109 | + | eventBus := mocks.NewMockEventPublisher(t) | |
| 110 | + | svc := NewService(blobStorage, manifestStorage, eventBus) | |
| 111 | + | ||
| 112 | + | manifest := &domain.Manifest{ | |
| 113 | + | Name: "myapp", | |
| 114 | + | Reference: "sha256:0000000000000000000000000000000000000000000000000000000000000000", | |
| 115 | + | ContentType: "application/vnd.oci.image.manifest.v1+json", | |
| 116 | + | Data: []byte(`{"schemaVersion":2}`), | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | _, err := svc.PutManifest(testContext(), manifest) | |
| 120 | + | ||
| 121 | + | require.Error(t, err) | |
| 122 | + | assert.ErrorIs(t, err, domain.ErrDigestMismatch) | |
| 123 | + | } | |
| 124 | + | ||
| 125 | + | func TestService_GetBlobPath_RequiresRepositoryReference(t *testing.T) { | |
| 126 | + | const digest = "sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4" | |
| 127 | + | blobStorage := mocks.NewMockBlobStorage(t) | |
| 128 | + | manifestStorage := mocks.NewMockManifestStorage(t) | |
| 129 | + | svc := NewService(blobStorage, manifestStorage, nil) | |
| 130 | + | ||
| 131 | + | manifestStorage.EXPECT().ListTags("allowed").Return([]string{"latest"}, nil) | |
| 132 | + | manifestStorage.EXPECT().GetManifest("allowed", "latest").Return( | |
| 133 | + | []byte(`{"schemaVersion":2,"layers":[{"digest":"`+digest+`"}]}`), | |
| 134 | + | "application/vnd.oci.image.manifest.v1+json", nil, | |
| 135 | + | ) | |
| 136 | + | blobStorage.EXPECT().GetBlobPath(digest).Return("/registry/blob", nil) | |
| 137 | + | ||
| 138 | + | path, err := svc.GetBlobPath(testContext(), "allowed", digest) | |
| 139 | + | require.NoError(t, err) | |
| 140 | + | assert.Equal(t, "/registry/blob", path) | |
| 141 | + | ||
| 142 | + | manifestStorage.EXPECT().ListTags("denied").Return([]string{"latest"}, nil) | |
| 143 | + | manifestStorage.EXPECT().GetManifest("denied", "latest").Return( | |
| 144 | + | []byte(`{"schemaVersion":2,"layers":[]}`), | |
| 145 | + | "application/vnd.oci.image.manifest.v1+json", nil, | |
| 146 | + | ) | |
| 147 | + | ||
| 148 | + | _, err = svc.GetBlobPath(testContext(), "denied", digest) | |
| 149 | + | assert.ErrorIs(t, err, domain.ErrBlobNotFound) | |
| 150 | + | } | |
| 151 | + | ||
| 87 | 152 | func TestService_PutManifest_StorageError(t *testing.T) { | |
| 88 | 153 | blobStorage := mocks.NewMockBlobStorage(t) | |
| 89 | 154 | manifestStorage := mocks.NewMockManifestStorage(t) | |
| @@ -3,7 +3,9 @@ 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" | ||
| @@ -84,6 +86,69 @@ func TestService_PutManifest_Success(t *testing.T) { | |||||
| 84 | assert.True(t, strings.HasPrefix(digest, "sha256:")) | 86 | assert.True(t, strings.HasPrefix(digest, "sha256:")) | ||
| 85 | } | 87 | } | ||
| 86 | 88 | ||||
| 89 | + | func TestService_PutManifest_SHA512DigestDoesNotPublishEvent(t *testing.T) { | |||
| 90 | + | blobStorage := mocks.NewMockBlobStorage(t) | |||
| 91 | + | manifestStorage := mocks.NewMockManifestStorage(t) | |||
| 92 | + | eventBus := mocks.NewMockEventPublisher(t) | |||
| 93 | + | svc := NewService(blobStorage, manifestStorage, eventBus) | |||
| 94 | + | data := []byte(`{"schemaVersion":2}`) | |||
| 95 | + | reference := fmt.Sprintf("sha512:%x", sha512.Sum512(data)) | |||
| 96 | + | manifest := &domain.Manifest{Name: "myapp", Reference: reference, ContentType: "application/vnd.oci.image.manifest.v1+json", Data: data} | |||
| 97 | + | ||||
| 98 | + | manifestStorage.EXPECT().PutManifest("myapp", reference, manifest.ContentType, data).Return(nil) | |||
| 99 | + | ||||
| 100 | + | digest, err := svc.PutManifest(testContext(), manifest) | |||
| 101 | + | ||||
| 102 | + | require.NoError(t, err) | |||
| 103 | + | assert.Equal(t, reference, digest) | |||
| 104 | + | } | |||
| 105 | + | ||||
| 106 | + | func TestService_PutManifest_RejectsDigestMismatch(t *testing.T) { | |||
| 107 | + | blobStorage := mocks.NewMockBlobStorage(t) | |||
| 108 | + | manifestStorage := mocks.NewMockManifestStorage(t) | |||
| 109 | + | eventBus := mocks.NewMockEventPublisher(t) | |||
| 110 | + | svc := NewService(blobStorage, manifestStorage, eventBus) | |||
| 111 | + | ||||
| 112 | + | manifest := &domain.Manifest{ | |||
| 113 | + | Name: "myapp", | |||
| 114 | + | Reference: "sha256:0000000000000000000000000000000000000000000000000000000000000000", | |||
| 115 | + | ContentType: "application/vnd.oci.image.manifest.v1+json", | |||
| 116 | + | Data: []byte(`{"schemaVersion":2}`), | |||
| 117 | + | } | |||
| 118 | + | ||||
| 119 | + | _, err := svc.PutManifest(testContext(), manifest) | |||
| 120 | + | ||||
| 121 | + | require.Error(t, err) | |||
| 122 | + | assert.ErrorIs(t, err, domain.ErrDigestMismatch) | |||
| 123 | + | } | |||
| 124 | + | ||||
| 125 | + | func TestService_GetBlobPath_RequiresRepositoryReference(t *testing.T) { | |||
| 126 | + | const digest = "sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4" | |||
| 127 | + | blobStorage := mocks.NewMockBlobStorage(t) | |||
| 128 | + | manifestStorage := mocks.NewMockManifestStorage(t) | |||
| 129 | + | svc := NewService(blobStorage, manifestStorage, nil) | |||
| 130 | + | ||||
| 131 | + | manifestStorage.EXPECT().ListTags("allowed").Return([]string{"latest"}, nil) | |||
| 132 | + | manifestStorage.EXPECT().GetManifest("allowed", "latest").Return( | |||
| 133 | + | []byte(`{"schemaVersion":2,"layers":[{"digest":"`+digest+`"}]}`), | |||
| 134 | + | "application/vnd.oci.image.manifest.v1+json", nil, | |||
| 135 | + | ) | |||
| 136 | + | blobStorage.EXPECT().GetBlobPath(digest).Return("/registry/blob", nil) | |||
| 137 | + | ||||
| 138 | + | path, err := svc.GetBlobPath(testContext(), "allowed", digest) | |||
| 139 | + | require.NoError(t, err) | |||
| 140 | + | assert.Equal(t, "/registry/blob", path) | |||
| 141 | + | ||||
| 142 | + | manifestStorage.EXPECT().ListTags("denied").Return([]string{"latest"}, nil) | |||
| 143 | + | manifestStorage.EXPECT().GetManifest("denied", "latest").Return( | |||
| 144 | + | []byte(`{"schemaVersion":2,"layers":[]}`), | |||
| 145 | + | "application/vnd.oci.image.manifest.v1+json", nil, | |||
| 146 | + | ) | |||
| 147 | + | ||||
| 148 | + | _, err = svc.GetBlobPath(testContext(), "denied", digest) | |||
| 149 | + | assert.ErrorIs(t, err, domain.ErrBlobNotFound) | |||
| 150 | + | } | |||
| 151 | + | ||||
| 87 | func TestService_PutManifest_StorageError(t *testing.T) { | 152 | func TestService_PutManifest_StorageError(t *testing.T) { | ||
| 88 | blobStorage := mocks.NewMockBlobStorage(t) | 153 | blobStorage := mocks.NewMockBlobStorage(t) | ||
| 89 | manifestStorage := mocks.NewMockManifestStorage(t) | 154 | manifestStorage := mocks.NewMockManifestStorage(t) | ||
M internal/usecase/services/service.go +7 -2
| @@ -521,7 +521,7 @@ func tcpReadinessAddress(svc domain.StandaloneService) (string, error) { | |||
| 521 | 521 | } | |
| 522 | 522 | ||
| 523 | 523 | func (s *Service) waitLogReadiness(ctx context.Context, containerID string, svc domain.StandaloneService) error { | |
| 524 | - | ticker := time.NewTicker(10 * time.Millisecond) | |
| 524 | + | ticker := time.NewTicker(250 * time.Millisecond) | |
| 525 | 525 | defer ticker.Stop() | |
| 526 | 526 | var lastErr error | |
| 527 | 527 | for { | |
| @@ -551,16 +551,21 @@ func logReadinessTimeoutError(serviceName string, timeoutErr, lastErr error) err | |||
| 551 | 551 | return fmt.Errorf("standalone service %q log readiness timed out: %w", serviceName, timeoutErr) | |
| 552 | 552 | } | |
| 553 | 553 | ||
| 554 | + | const maxReadinessLogSize = 1 << 20 // 1 MiB | |
| 555 | + | ||
| 554 | 556 | func (s *Service) logContains(ctx context.Context, containerID, path, contains string) (bool, error) { | |
| 555 | 557 | reader, err := s.runtime.CopyFromContainer(ctx, containerID, path) | |
| 556 | 558 | if err != nil { | |
| 557 | 559 | return false, err | |
| 558 | 560 | } | |
| 559 | 561 | defer reader.Close() | |
| 560 | - | content, err := io.ReadAll(reader) | |
| 562 | + | content, err := io.ReadAll(io.LimitReader(reader, maxReadinessLogSize+1)) | |
| 561 | 563 | if err != nil { | |
| 562 | 564 | return false, err | |
| 563 | 565 | } | |
| 566 | + | if len(content) > maxReadinessLogSize { | |
| 567 | + | return false, fmt.Errorf("readiness log exceeds %d bytes", maxReadinessLogSize) | |
| 568 | + | } | |
| 564 | 569 | return strings.Contains(string(content), contains), nil | |
| 565 | 570 | } | |
| 566 | 571 | ||
| @@ -521,7 +521,7 @@ func tcpReadinessAddress(svc domain.StandaloneService) (string, error) { | |||||
| 521 | } | 521 | } | ||
| 522 | 522 | ||||
| 523 | func (s *Service) waitLogReadiness(ctx context.Context, containerID string, svc domain.StandaloneService) error { | 523 | func (s *Service) waitLogReadiness(ctx context.Context, containerID string, svc domain.StandaloneService) error { | ||
| 524 | - | ticker := time.NewTicker(10 * time.Millisecond) | 524 | + | ticker := time.NewTicker(250 * time.Millisecond) |
| 525 | defer ticker.Stop() | 525 | defer ticker.Stop() | ||
| 526 | var lastErr error | 526 | var lastErr error | ||
| 527 | for { | 527 | for { | ||
| @@ -551,16 +551,21 @@ func logReadinessTimeoutError(serviceName string, timeoutErr, lastErr error) err | |||||
| 551 | return fmt.Errorf("standalone service %q log readiness timed out: %w", serviceName, timeoutErr) | 551 | return fmt.Errorf("standalone service %q log readiness timed out: %w", serviceName, timeoutErr) | ||
| 552 | } | 552 | } | ||
| 553 | 553 | ||||
| 554 | + | const maxReadinessLogSize = 1 << 20 // 1 MiB | |||
| 555 | + | ||||
| 554 | func (s *Service) logContains(ctx context.Context, containerID, path, contains string) (bool, error) { | 556 | func (s *Service) logContains(ctx context.Context, containerID, path, contains string) (bool, error) { | ||
| 555 | reader, err := s.runtime.CopyFromContainer(ctx, containerID, path) | 557 | reader, err := s.runtime.CopyFromContainer(ctx, containerID, path) | ||
| 556 | if err != nil { | 558 | if err != nil { | ||
| 557 | return false, err | 559 | return false, err | ||
| 558 | } | 560 | } | ||
| 559 | defer reader.Close() | 561 | defer reader.Close() | ||
| 560 | - | content, err := io.ReadAll(reader) | 562 | + | content, err := io.ReadAll(io.LimitReader(reader, maxReadinessLogSize+1)) |
| 561 | if err != nil { | 563 | if err != nil { | ||
| 562 | return false, err | 564 | return false, err | ||
| 563 | } | 565 | } | ||
| 566 | + | if len(content) > maxReadinessLogSize { | |||
| 567 | + | return false, fmt.Errorf("readiness log exceeds %d bytes", maxReadinessLogSize) | |||
| 568 | + | } | |||
| 564 | return strings.Contains(string(content), contains), nil | 569 | return strings.Contains(string(content), contains), nil | ||
| 565 | } | 570 | } | ||
| 566 | 571 | ||||