fix(security): address verified review findings
- commit
- 378d7cf4e7e5ff398f4d9b1c0450b09b069766a6
- author
- brice · · 2026-08-10 22:44 UTC
- parent
- 6e78011
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
27 files · +562 -110
M .github/actions/deploy/README.md +9 -0
| @@ -28,6 +28,8 @@ jobs: | |||
| 28 | 28 | runs-on: ubuntu-latest | |
| 29 | 29 | steps: | |
| 30 | 30 | - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 | |
| 31 | + | with: | |
| 32 | + | persist-credentials: false | |
| 31 | 33 | ||
| 32 | 34 | - name: Deploy to Gordon | |
| 33 | 35 | uses: bnema/gordon/.github/actions/deploy@271c36a4cc32fabd91b20f93cd006bdd2a01b181 | |
| @@ -53,6 +55,8 @@ jobs: | |||
| 53 | 55 | runs-on: ubuntu-latest | |
| 54 | 56 | steps: | |
| 55 | 57 | - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 | |
| 58 | + | with: | |
| 59 | + | persist-credentials: false | |
| 56 | 60 | ||
| 57 | 61 | - name: Deploy to Gordon | |
| 58 | 62 | id: deploy | |
| @@ -151,6 +155,7 @@ jobs: | |||
| 151 | 155 | - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 | |
| 152 | 156 | with: | |
| 153 | 157 | ref: ${{ github.event.inputs.tag || github.ref }} | |
| 158 | + | persist-credentials: false | |
| 154 | 159 | ||
| 155 | 160 | - uses: bnema/gordon/.github/actions/deploy@271c36a4cc32fabd91b20f93cd006bdd2a01b181 | |
| 156 | 161 | with: | |
| @@ -175,6 +180,8 @@ jobs: | |||
| 175 | 180 | runs-on: ubuntu-latest | |
| 176 | 181 | steps: | |
| 177 | 182 | - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 | |
| 183 | + | with: | |
| 184 | + | persist-credentials: false | |
| 178 | 185 | - uses: bnema/gordon/.github/actions/deploy@271c36a4cc32fabd91b20f93cd006bdd2a01b181 | |
| 179 | 186 | with: | |
| 180 | 187 | registry: registry.mydomain.com | |
| @@ -188,6 +195,8 @@ jobs: | |||
| 188 | 195 | runs-on: ubuntu-latest | |
| 189 | 196 | steps: | |
| 190 | 197 | - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 | |
| 198 | + | with: | |
| 199 | + | persist-credentials: false | |
| 191 | 200 | - uses: bnema/gordon/.github/actions/deploy@271c36a4cc32fabd91b20f93cd006bdd2a01b181 | |
| 192 | 201 | with: | |
| 193 | 202 | registry: registry.mydomain.com | |
| @@ -28,6 +28,8 @@ jobs: | |||||
| 28 | runs-on: ubuntu-latest | 28 | runs-on: ubuntu-latest | ||
| 29 | steps: | 29 | steps: | ||
| 30 | - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 | 30 | - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 | ||
| 31 | + | with: | |||
| 32 | + | persist-credentials: false | |||
| 31 | 33 | ||||
| 32 | - name: Deploy to Gordon | 34 | - name: Deploy to Gordon | ||
| 33 | uses: bnema/gordon/.github/actions/deploy@271c36a4cc32fabd91b20f93cd006bdd2a01b181 | 35 | uses: bnema/gordon/.github/actions/deploy@271c36a4cc32fabd91b20f93cd006bdd2a01b181 | ||
| @@ -53,6 +55,8 @@ jobs: | |||||
| 53 | runs-on: ubuntu-latest | 55 | runs-on: ubuntu-latest | ||
| 54 | steps: | 56 | steps: | ||
| 55 | - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 | 57 | - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 | ||
| 58 | + | with: | |||
| 59 | + | persist-credentials: false | |||
| 56 | 60 | ||||
| 57 | - name: Deploy to Gordon | 61 | - name: Deploy to Gordon | ||
| 58 | id: deploy | 62 | id: deploy | ||
| @@ -151,6 +155,7 @@ jobs: | |||||
| 151 | - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 | 155 | - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 | ||
| 152 | with: | 156 | with: | ||
| 153 | ref: ${{ github.event.inputs.tag || github.ref }} | 157 | ref: ${{ github.event.inputs.tag || github.ref }} | ||
| 158 | + | persist-credentials: false | |||
| 154 | 159 | ||||
| 155 | - uses: bnema/gordon/.github/actions/deploy@271c36a4cc32fabd91b20f93cd006bdd2a01b181 | 160 | - uses: bnema/gordon/.github/actions/deploy@271c36a4cc32fabd91b20f93cd006bdd2a01b181 | ||
| 156 | with: | 161 | with: | ||
| @@ -175,6 +180,8 @@ jobs: | |||||
| 175 | runs-on: ubuntu-latest | 180 | runs-on: ubuntu-latest | ||
| 176 | steps: | 181 | steps: | ||
| 177 | - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 | 182 | - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 | ||
| 183 | + | with: | |||
| 184 | + | persist-credentials: false | |||
| 178 | - uses: bnema/gordon/.github/actions/deploy@271c36a4cc32fabd91b20f93cd006bdd2a01b181 | 185 | - uses: bnema/gordon/.github/actions/deploy@271c36a4cc32fabd91b20f93cd006bdd2a01b181 | ||
| 179 | with: | 186 | with: | ||
| 180 | registry: registry.mydomain.com | 187 | registry: registry.mydomain.com | ||
| @@ -188,6 +195,8 @@ jobs: | |||||
| 188 | runs-on: ubuntu-latest | 195 | runs-on: ubuntu-latest | ||
| 189 | steps: | 196 | steps: | ||
| 190 | - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 | 197 | - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 | ||
| 198 | + | with: | |||
| 199 | + | persist-credentials: false | |||
| 191 | - uses: bnema/gordon/.github/actions/deploy@271c36a4cc32fabd91b20f93cd006bdd2a01b181 | 200 | - uses: bnema/gordon/.github/actions/deploy@271c36a4cc32fabd91b20f93cd006bdd2a01b181 | ||
| 192 | with: | 201 | with: | ||
| 193 | registry: registry.mydomain.com | 202 | registry: registry.mydomain.com | ||
M .github/actions/deploy/action.yml +36 -9
| @@ -81,22 +81,45 @@ runs: | |||
| 81 | 81 | INPUT_TAG: ${{ inputs.tag }} | |
| 82 | 82 | INPUT_REGISTRY: ${{ inputs.registry }} | |
| 83 | 83 | run: | | |
| 84 | - | # Determine image name | |
| 85 | - | IMAGE_NAME="${INPUT_IMAGE}" | |
| 86 | - | if [[ -z "$IMAGE_NAME" ]]; then | |
| 87 | - | IMAGE_NAME="${GITHUB_REPOSITORY#*/}" | |
| 88 | - | fi | |
| 84 | + | reject_line_breaks() { | |
| 85 | + | [[ "$2" != *$'\n'* && "$2" != *$'\r'* ]] || { | |
| 86 | + | echo "::error::$1 must not contain line breaks" | |
| 87 | + | exit 1 | |
| 88 | + | } | |
| 89 | + | } | |
| 90 | + | reject_line_breaks "registry" "$INPUT_REGISTRY" | |
| 91 | + | reject_line_breaks "image" "$INPUT_IMAGE" | |
| 92 | + | reject_line_breaks "tag" "$INPUT_TAG" | |
| 89 | 93 | ||
| 90 | - | # Extract tag from ref (or override) | |
| 94 | + | IMAGE_NAME="${INPUT_IMAGE:-${GITHUB_REPOSITORY#*/}}" | |
| 91 | 95 | if [[ -n "${INPUT_TAG}" ]]; then | |
| 92 | 96 | TAG="${INPUT_TAG}" | |
| 93 | 97 | elif [[ "$GITHUB_REF" == refs/tags/* ]]; then | |
| 94 | 98 | TAG="${GITHUB_REF#refs/tags/}" | |
| 95 | 99 | else | |
| 96 | - | TAG="$(echo "$GITHUB_SHA" | head -c 7)" | |
| 100 | + | TAG="${GITHUB_SHA:0:7}" | |
| 101 | + | fi | |
| 102 | + | ||
| 103 | + | [[ "$INPUT_REGISTRY" =~ ^[a-zA-Z0-9.-]+(:[0-9]{1,5})?$ ]] || { | |
| 104 | + | echo "::error::registry must be a hostname with an optional port" | |
| 105 | + | exit 1 | |
| 106 | + | } | |
| 107 | + | if [[ "$INPUT_REGISTRY" =~ :([0-9]+)$ ]]; then | |
| 108 | + | PORT="${BASH_REMATCH[1]}" | |
| 109 | + | [[ "$PORT" != 0* && $((10#$PORT)) -ge 1 && $((10#$PORT)) -le 65535 ]] || { | |
| 110 | + | echo "::error::registry port must be between 1 and 65535 without leading zeros" | |
| 111 | + | exit 1 | |
| 112 | + | } | |
| 97 | 113 | fi | |
| 114 | + | [[ "$IMAGE_NAME" =~ ^[a-z0-9]+([._-][a-z0-9]+)*(/[a-z0-9]+([._-][a-z0-9]+)*)*$ ]] || { | |
| 115 | + | echo "::error::image contains invalid Docker repository components" | |
| 116 | + | exit 1 | |
| 117 | + | } | |
| 118 | + | [[ "$TAG" =~ ^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$ ]] || { | |
| 119 | + | echo "::error::tag is not a valid Docker tag" | |
| 120 | + | exit 1 | |
| 121 | + | } | |
| 98 | 122 | ||
| 99 | - | # Full image reference | |
| 100 | 123 | FULL_IMAGE="${INPUT_REGISTRY}/${IMAGE_NAME}:${TAG}" | |
| 101 | 124 | ||
| 102 | 125 | printf 'image=%s\nimage_name=%s\ntag=%s\nregistry=%s\n' \ | |
| @@ -130,7 +153,11 @@ runs: | |||
| 130 | 153 | if [[ "${INPUT_PUSH_LATEST}" == "true" ]]; then | |
| 131 | 154 | TAGS="${TAGS}"$'\n'"${INPUT_REGISTRY}/${META_IMAGE_NAME}:latest" | |
| 132 | 155 | fi | |
| 133 | - | printf "tags<<EOF\n%s\nEOF\n" "$TAGS" >> "$GITHUB_OUTPUT" | |
| 156 | + | DELIMITER="gordon_$(openssl rand -hex 16)" | |
| 157 | + | while grep -Fqx "$DELIMITER" <<< "$TAGS"; do | |
| 158 | + | DELIMITER="gordon_$(openssl rand -hex 16)" | |
| 159 | + | done | |
| 160 | + | printf 'tags<<%s\n%s\n%s\n' "$DELIMITER" "$TAGS" "$DELIMITER" >> "$GITHUB_OUTPUT" | |
| 134 | 161 | ||
| 135 | 162 | - name: Build and push | |
| 136 | 163 | id: push | |
| @@ -81,22 +81,45 @@ runs: | |||||
| 81 | INPUT_TAG: ${{ inputs.tag }} | 81 | INPUT_TAG: ${{ inputs.tag }} | ||
| 82 | INPUT_REGISTRY: ${{ inputs.registry }} | 82 | INPUT_REGISTRY: ${{ inputs.registry }} | ||
| 83 | run: | | 83 | run: | | ||
| 84 | - | # Determine image name | 84 | + | reject_line_breaks() { |
| 85 | - | IMAGE_NAME="${INPUT_IMAGE}" | 85 | + | [[ "$2" != *$'\n'* && "$2" != *$'\r'* ]] || { |
| 86 | - | if [[ -z "$IMAGE_NAME" ]]; then | 86 | + | echo "::error::$1 must not contain line breaks" |
| 87 | - | IMAGE_NAME="${GITHUB_REPOSITORY#*/}" | 87 | + | exit 1 |
| 88 | - | fi | 88 | + | } |
| 89 | + | } | |||
| 90 | + | reject_line_breaks "registry" "$INPUT_REGISTRY" | |||
| 91 | + | reject_line_breaks "image" "$INPUT_IMAGE" | |||
| 92 | + | reject_line_breaks "tag" "$INPUT_TAG" | |||
| 89 | 93 | ||||
| 90 | - | # Extract tag from ref (or override) | 94 | + | IMAGE_NAME="${INPUT_IMAGE:-${GITHUB_REPOSITORY#*/}}" |
| 91 | if [[ -n "${INPUT_TAG}" ]]; then | 95 | if [[ -n "${INPUT_TAG}" ]]; then | ||
| 92 | TAG="${INPUT_TAG}" | 96 | TAG="${INPUT_TAG}" | ||
| 93 | elif [[ "$GITHUB_REF" == refs/tags/* ]]; then | 97 | elif [[ "$GITHUB_REF" == refs/tags/* ]]; then | ||
| 94 | TAG="${GITHUB_REF#refs/tags/}" | 98 | TAG="${GITHUB_REF#refs/tags/}" | ||
| 95 | else | 99 | else | ||
| 96 | - | TAG="$(echo "$GITHUB_SHA" | head -c 7)" | 100 | + | TAG="${GITHUB_SHA:0:7}" |
| 101 | + | fi | |||
| 102 | + | ||||
| 103 | + | [[ "$INPUT_REGISTRY" =~ ^[a-zA-Z0-9.-]+(:[0-9]{1,5})?$ ]] || { | |||
| 104 | + | echo "::error::registry must be a hostname with an optional port" | |||
| 105 | + | exit 1 | |||
| 106 | + | } | |||
| 107 | + | if [[ "$INPUT_REGISTRY" =~ :([0-9]+)$ ]]; then | |||
| 108 | + | PORT="${BASH_REMATCH[1]}" | |||
| 109 | + | [[ "$PORT" != 0* && $((10#$PORT)) -ge 1 && $((10#$PORT)) -le 65535 ]] || { | |||
| 110 | + | echo "::error::registry port must be between 1 and 65535 without leading zeros" | |||
| 111 | + | exit 1 | |||
| 112 | + | } | |||
| 97 | fi | 113 | fi | ||
| 114 | + | [[ "$IMAGE_NAME" =~ ^[a-z0-9]+([._-][a-z0-9]+)*(/[a-z0-9]+([._-][a-z0-9]+)*)*$ ]] || { | |||
| 115 | + | echo "::error::image contains invalid Docker repository components" | |||
| 116 | + | exit 1 | |||
| 117 | + | } | |||
| 118 | + | [[ "$TAG" =~ ^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$ ]] || { | |||
| 119 | + | echo "::error::tag is not a valid Docker tag" | |||
| 120 | + | exit 1 | |||
| 121 | + | } | |||
| 98 | 122 | ||||
| 99 | - | # Full image reference | |||
| 100 | FULL_IMAGE="${INPUT_REGISTRY}/${IMAGE_NAME}:${TAG}" | 123 | FULL_IMAGE="${INPUT_REGISTRY}/${IMAGE_NAME}:${TAG}" | ||
| 101 | 124 | ||||
| 102 | printf 'image=%s\nimage_name=%s\ntag=%s\nregistry=%s\n' \ | 125 | printf 'image=%s\nimage_name=%s\ntag=%s\nregistry=%s\n' \ | ||
| @@ -130,7 +153,11 @@ runs: | |||||
| 130 | if [[ "${INPUT_PUSH_LATEST}" == "true" ]]; then | 153 | if [[ "${INPUT_PUSH_LATEST}" == "true" ]]; then | ||
| 131 | TAGS="${TAGS}"$'\n'"${INPUT_REGISTRY}/${META_IMAGE_NAME}:latest" | 154 | TAGS="${TAGS}"$'\n'"${INPUT_REGISTRY}/${META_IMAGE_NAME}:latest" | ||
| 132 | fi | 155 | fi | ||
| 133 | - | printf "tags<<EOF\n%s\nEOF\n" "$TAGS" >> "$GITHUB_OUTPUT" | 156 | + | DELIMITER="gordon_$(openssl rand -hex 16)" |
| 157 | + | while grep -Fqx "$DELIMITER" <<< "$TAGS"; do | |||
| 158 | + | DELIMITER="gordon_$(openssl rand -hex 16)" | |||
| 159 | + | done | |||
| 160 | + | printf 'tags<<%s\n%s\n%s\n' "$DELIMITER" "$TAGS" "$DELIMITER" >> "$GITHUB_OUTPUT" | |||
| 134 | 161 | ||||
| 135 | - name: Build and push | 162 | - name: Build and push | ||
| 136 | id: push | 163 | id: push | ||
M .github/workflows/release.yml +1 -0
| @@ -16,6 +16,7 @@ jobs: | |||
| 16 | 16 | - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 | |
| 17 | 17 | with: | |
| 18 | 18 | fetch-depth: 0 | |
| 19 | + | persist-credentials: false | |
| 19 | 20 | ||
| 20 | 21 | - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5 | |
| 21 | 22 | with: | |
| @@ -16,6 +16,7 @@ jobs: | |||||
| 16 | - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 | 16 | - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 | ||
| 17 | with: | 17 | with: | ||
| 18 | fetch-depth: 0 | 18 | fetch-depth: 0 | ||
| 19 | + | persist-credentials: false | |||
| 19 | 20 | ||||
| 20 | - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5 | 21 | - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5 | ||
| 21 | with: | 22 | with: | ||
M docs/deployment/rollback.md +11 -3
| @@ -159,9 +159,17 @@ jobs: | |||
| 159 | 159 | VERSION: ${{ inputs.version }} | |
| 160 | 160 | run: | | |
| 161 | 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" | |
| 162 | + | REPOSITORY="$GORDON_REGISTRY/myapp" | |
| 163 | + | SOURCE_IMAGE="$REPOSITORY:$VERSION" | |
| 164 | + | LATEST_IMAGE="$REPOSITORY:latest" | |
| 165 | + | SOURCE_DIGEST="$(docker buildx imagetools inspect "$SOURCE_IMAGE" --format '{{json .Manifest}}' | jq -er '.digest')" | |
| 166 | + | IMMUTABLE_IMAGE="$REPOSITORY@$SOURCE_DIGEST" | |
| 167 | + | docker buildx imagetools create --tag "$LATEST_IMAGE" "$IMMUTABLE_IMAGE" | |
| 168 | + | LATEST_DIGEST="$(docker buildx imagetools inspect "$LATEST_IMAGE" --format '{{json .Manifest}}' | jq -er '.digest')" | |
| 169 | + | [[ "$LATEST_DIGEST" == "$SOURCE_DIGEST" ]] || { | |
| 170 | + | echo "rollback verification failed: latest resolved to $LATEST_DIGEST, expected $SOURCE_DIGEST" >&2 | |
| 171 | + | exit 1 | |
| 172 | + | } | |
| 165 | 173 | ||
| 166 | 174 | - name: Summary | |
| 167 | 175 | env: | |
| @@ -159,9 +159,17 @@ jobs: | |||||
| 159 | VERSION: ${{ inputs.version }} | 159 | VERSION: ${{ inputs.version }} | ||
| 160 | run: | | 160 | run: | | ||
| 161 | [[ "$VERSION" =~ ^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$ ]] || exit 1 | 161 | [[ "$VERSION" =~ ^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$ ]] || exit 1 | ||
| 162 | - | docker pull "$GORDON_REGISTRY/myapp:$VERSION" | 162 | + | REPOSITORY="$GORDON_REGISTRY/myapp" |
| 163 | - | docker tag "$GORDON_REGISTRY/myapp:$VERSION" "$GORDON_REGISTRY/myapp:latest" | 163 | + | SOURCE_IMAGE="$REPOSITORY:$VERSION" |
| 164 | - | docker push "$GORDON_REGISTRY/myapp:latest" | 164 | + | LATEST_IMAGE="$REPOSITORY:latest" |
| 165 | + | SOURCE_DIGEST="$(docker buildx imagetools inspect "$SOURCE_IMAGE" --format '{{json .Manifest}}' | jq -er '.digest')" | |||
| 166 | + | IMMUTABLE_IMAGE="$REPOSITORY@$SOURCE_DIGEST" | |||
| 167 | + | docker buildx imagetools create --tag "$LATEST_IMAGE" "$IMMUTABLE_IMAGE" | |||
| 168 | + | LATEST_DIGEST="$(docker buildx imagetools inspect "$LATEST_IMAGE" --format '{{json .Manifest}}' | jq -er '.digest')" | |||
| 169 | + | [[ "$LATEST_DIGEST" == "$SOURCE_DIGEST" ]] || { | |||
| 170 | + | echo "rollback verification failed: latest resolved to $LATEST_DIGEST, expected $SOURCE_DIGEST" >&2 | |||
| 171 | + | exit 1 | |||
| 172 | + | } | |||
| 165 | 173 | ||||
| 166 | - name: Summary | 174 | - name: Summary | ||
| 167 | env: | 175 | env: | ||
M docs/installation.md +14 -5
| @@ -20,7 +20,11 @@ Detailed installation guide for production environments. | |||
| 20 | 20 | installer="$(mktemp)" | |
| 21 | 21 | trap 'rm -f "$installer"' EXIT | |
| 22 | 22 | curl -fsSL --output "$installer" https://gordon.bnema.dev/install | |
| 23 | - | less "$installer" | |
| 23 | + | if command -v less >/dev/null 2>&1; then | |
| 24 | + | less "$installer" | |
| 25 | + | else | |
| 26 | + | cat "$installer" | |
| 27 | + | fi | |
| 24 | 28 | bash "$installer" | |
| 25 | 29 | ) | |
| 26 | 30 | ``` | |
| @@ -32,42 +36,47 @@ Download and inspect the installer before executing it. The installer verifies t | |||
| 32 | 36 | Manual archive installation requires downloading the matching `checksums.txt` release asset and verifying the archive's SHA-256 checksum before extraction. The checksum-verifying installer above is recommended. | |
| 33 | 37 | ||
| 34 | 38 | **Linux (x86_64)** | |
| 39 | + | ||
| 35 | 40 | ```bash | |
| 36 | 41 | wget https://github.com/bnema/gordon/releases/latest/download/{gordon_linux_amd64.tar.gz,checksums.txt} | |
| 37 | - | grep ' gordon_linux_amd64.tar.gz$' checksums.txt | sha256sum --check --strict | |
| 42 | + | grep -E '^[[:xdigit:]]{64} ([* ]?)gordon_linux_amd64\.tar\.gz$' checksums.txt | sha256sum --check --strict | |
| 38 | 43 | tar -xzf gordon_linux_amd64.tar.gz | |
| 39 | 44 | chmod +x gordon | |
| 40 | 45 | sudo mv gordon /usr/local/bin/ | |
| 41 | 46 | ``` | |
| 42 | 47 | ||
| 43 | 48 | **Linux (ARM64)** - for Raspberry Pi 4, AWS Graviton, Oracle Ampere, etc. | |
| 49 | + | ||
| 44 | 50 | ```bash | |
| 45 | 51 | wget https://github.com/bnema/gordon/releases/latest/download/{gordon_linux_arm64.tar.gz,checksums.txt} | |
| 46 | - | grep ' gordon_linux_arm64.tar.gz$' checksums.txt | sha256sum --check --strict | |
| 52 | + | grep -E '^[[:xdigit:]]{64} ([* ]?)gordon_linux_arm64\.tar\.gz$' checksums.txt | sha256sum --check --strict | |
| 47 | 53 | tar -xzf gordon_linux_arm64.tar.gz | |
| 48 | 54 | chmod +x gordon | |
| 49 | 55 | sudo mv gordon /usr/local/bin/ | |
| 50 | 56 | ``` | |
| 51 | 57 | ||
| 52 | 58 | **macOS (Apple Silicon)** | |
| 59 | + | ||
| 53 | 60 | ```bash | |
| 54 | 61 | curl -LO https://github.com/bnema/gordon/releases/latest/download/{gordon_darwin_arm64.tar.gz,checksums.txt} | |
| 55 | - | grep ' gordon_darwin_arm64.tar.gz$' checksums.txt | shasum -a 256 --check | |
| 62 | + | grep -E '^[[:xdigit:]]{64} ([* ]?)gordon_darwin_arm64\.tar\.gz$' checksums.txt | shasum -a 256 --check | |
| 56 | 63 | tar -xzf gordon_darwin_arm64.tar.gz | |
| 57 | 64 | chmod +x gordon | |
| 58 | 65 | sudo mv gordon /usr/local/bin/ | |
| 59 | 66 | ``` | |
| 60 | 67 | ||
| 61 | 68 | **macOS (Intel)** | |
| 69 | + | ||
| 62 | 70 | ```bash | |
| 63 | 71 | curl -LO https://github.com/bnema/gordon/releases/latest/download/{gordon_darwin_amd64.tar.gz,checksums.txt} | |
| 64 | - | grep ' gordon_darwin_amd64.tar.gz$' checksums.txt | shasum -a 256 --check | |
| 72 | + | grep -E '^[[:xdigit:]]{64} ([* ]?)gordon_darwin_amd64\.tar\.gz$' checksums.txt | shasum -a 256 --check | |
| 65 | 73 | tar -xzf gordon_darwin_amd64.tar.gz | |
| 66 | 74 | chmod +x gordon | |
| 67 | 75 | sudo mv gordon /usr/local/bin/ | |
| 68 | 76 | ``` | |
| 69 | 77 | ||
| 70 | 78 | Verify installation: | |
| 79 | + | ||
| 71 | 80 | ```bash | |
| 72 | 81 | gordon version | |
| 73 | 82 | ``` | |
| @@ -20,7 +20,11 @@ Detailed installation guide for production environments. | |||||
| 20 | installer="$(mktemp)" | 20 | installer="$(mktemp)" | ||
| 21 | trap 'rm -f "$installer"' EXIT | 21 | trap 'rm -f "$installer"' EXIT | ||
| 22 | curl -fsSL --output "$installer" https://gordon.bnema.dev/install | 22 | curl -fsSL --output "$installer" https://gordon.bnema.dev/install | ||
| 23 | - | less "$installer" | 23 | + | if command -v less >/dev/null 2>&1; then |
| 24 | + | less "$installer" | |||
| 25 | + | else | |||
| 26 | + | cat "$installer" | |||
| 27 | + | fi | |||
| 24 | bash "$installer" | 28 | bash "$installer" | ||
| 25 | ) | 29 | ) | ||
| 26 | ``` | 30 | ``` | ||
| @@ -32,42 +36,47 @@ Download and inspect the installer before executing it. The installer verifies t | |||||
| 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. | 36 | Manual archive installation requires downloading the matching `checksums.txt` release asset and verifying the archive's SHA-256 checksum before extraction. The checksum-verifying installer above is recommended. | ||
| 33 | 37 | ||||
| 34 | **Linux (x86_64)** | 38 | **Linux (x86_64)** | ||
| 39 | + | ||||
| 35 | ```bash | 40 | ```bash | ||
| 36 | wget https://github.com/bnema/gordon/releases/latest/download/{gordon_linux_amd64.tar.gz,checksums.txt} | 41 | wget https://github.com/bnema/gordon/releases/latest/download/{gordon_linux_amd64.tar.gz,checksums.txt} | ||
| 37 | - | grep ' gordon_linux_amd64.tar.gz$' checksums.txt | sha256sum --check --strict | 42 | + | grep -E '^[[:xdigit:]]{64} ([* ]?)gordon_linux_amd64\.tar\.gz$' checksums.txt | sha256sum --check --strict |
| 38 | tar -xzf gordon_linux_amd64.tar.gz | 43 | tar -xzf gordon_linux_amd64.tar.gz | ||
| 39 | chmod +x gordon | 44 | chmod +x gordon | ||
| 40 | sudo mv gordon /usr/local/bin/ | 45 | sudo mv gordon /usr/local/bin/ | ||
| 41 | ``` | 46 | ``` | ||
| 42 | 47 | ||||
| 43 | **Linux (ARM64)** - for Raspberry Pi 4, AWS Graviton, Oracle Ampere, etc. | 48 | **Linux (ARM64)** - for Raspberry Pi 4, AWS Graviton, Oracle Ampere, etc. | ||
| 49 | + | ||||
| 44 | ```bash | 50 | ```bash | ||
| 45 | wget https://github.com/bnema/gordon/releases/latest/download/{gordon_linux_arm64.tar.gz,checksums.txt} | 51 | wget https://github.com/bnema/gordon/releases/latest/download/{gordon_linux_arm64.tar.gz,checksums.txt} | ||
| 46 | - | grep ' gordon_linux_arm64.tar.gz$' checksums.txt | sha256sum --check --strict | 52 | + | grep -E '^[[:xdigit:]]{64} ([* ]?)gordon_linux_arm64\.tar\.gz$' checksums.txt | sha256sum --check --strict |
| 47 | tar -xzf gordon_linux_arm64.tar.gz | 53 | tar -xzf gordon_linux_arm64.tar.gz | ||
| 48 | chmod +x gordon | 54 | chmod +x gordon | ||
| 49 | sudo mv gordon /usr/local/bin/ | 55 | sudo mv gordon /usr/local/bin/ | ||
| 50 | ``` | 56 | ``` | ||
| 51 | 57 | ||||
| 52 | **macOS (Apple Silicon)** | 58 | **macOS (Apple Silicon)** | ||
| 59 | + | ||||
| 53 | ```bash | 60 | ```bash | ||
| 54 | curl -LO https://github.com/bnema/gordon/releases/latest/download/{gordon_darwin_arm64.tar.gz,checksums.txt} | 61 | curl -LO https://github.com/bnema/gordon/releases/latest/download/{gordon_darwin_arm64.tar.gz,checksums.txt} | ||
| 55 | - | grep ' gordon_darwin_arm64.tar.gz$' checksums.txt | shasum -a 256 --check | 62 | + | grep -E '^[[:xdigit:]]{64} ([* ]?)gordon_darwin_arm64\.tar\.gz$' checksums.txt | shasum -a 256 --check |
| 56 | tar -xzf gordon_darwin_arm64.tar.gz | 63 | tar -xzf gordon_darwin_arm64.tar.gz | ||
| 57 | chmod +x gordon | 64 | chmod +x gordon | ||
| 58 | sudo mv gordon /usr/local/bin/ | 65 | sudo mv gordon /usr/local/bin/ | ||
| 59 | ``` | 66 | ``` | ||
| 60 | 67 | ||||
| 61 | **macOS (Intel)** | 68 | **macOS (Intel)** | ||
| 69 | + | ||||
| 62 | ```bash | 70 | ```bash | ||
| 63 | curl -LO https://github.com/bnema/gordon/releases/latest/download/{gordon_darwin_amd64.tar.gz,checksums.txt} | 71 | curl -LO https://github.com/bnema/gordon/releases/latest/download/{gordon_darwin_amd64.tar.gz,checksums.txt} | ||
| 64 | - | grep ' gordon_darwin_amd64.tar.gz$' checksums.txt | shasum -a 256 --check | 72 | + | grep -E '^[[:xdigit:]]{64} ([* ]?)gordon_darwin_amd64\.tar\.gz$' checksums.txt | shasum -a 256 --check |
| 65 | tar -xzf gordon_darwin_amd64.tar.gz | 73 | tar -xzf gordon_darwin_amd64.tar.gz | ||
| 66 | chmod +x gordon | 74 | chmod +x gordon | ||
| 67 | sudo mv gordon /usr/local/bin/ | 75 | sudo mv gordon /usr/local/bin/ | ||
| 68 | ``` | 76 | ``` | ||
| 69 | 77 | ||||
| 70 | Verify installation: | 78 | Verify installation: | ||
| 79 | + | ||||
| 71 | ```bash | 80 | ```bash | ||
| 72 | gordon version | 81 | gordon version | ||
| 73 | ``` | 82 | ``` | ||
M internal/adapters/in/cli/auth.go +20 -9
| @@ -207,6 +207,7 @@ available while Gordon is running.`, | |||
| 207 | 207 | // newAuthLoginCmd creates the login command for remote authentication. | |
| 208 | 208 | func newAuthLoginCmd() *cobra.Command { | |
| 209 | 209 | var tokenFile string | |
| 210 | + | var jsonOut bool | |
| 210 | 211 | ||
| 211 | 212 | cmd := &cobra.Command{ | |
| 212 | 213 | Use: "login", | |
| @@ -228,13 +229,14 @@ Examples: | |||
| 228 | 229 | if err != nil { | |
| 229 | 230 | return fmt.Errorf("read token: %w", err) | |
| 230 | 231 | } | |
| 231 | - | return runAuthLoginWithToken(cmd.Context(), token, cmd.OutOrStdout()) | |
| 232 | + | return runAuthLoginWithToken(cmd.Context(), token, cmd.OutOrStdout(), jsonOut) | |
| 232 | 233 | } | |
| 233 | - | return runAuthLoginVerify(cmd.Context(), cmd.OutOrStdout()) | |
| 234 | + | return runAuthLoginVerify(cmd.Context(), cmd.OutOrStdout(), jsonOut) | |
| 234 | 235 | }, | |
| 235 | 236 | } | |
| 236 | 237 | ||
| 237 | 238 | cmd.Flags().StringVar(&tokenFile, "token-file", "", "Read authentication token from a mode 0600 file") | |
| 239 | + | cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON") | |
| 238 | 240 | ||
| 239 | 241 | return cmd | |
| 240 | 242 | } | |
| @@ -251,7 +253,7 @@ func resolveRequiredRemote(flagToken string) (*remote.ResolvedRemote, error) { | |||
| 251 | 253 | return resolved, nil | |
| 252 | 254 | } | |
| 253 | 255 | ||
| 254 | - | func runAuthLoginWithToken(ctx context.Context, token string, out io.Writer) error { | |
| 256 | + | func runAuthLoginWithToken(ctx context.Context, token string, out io.Writer, jsonOut bool) error { | |
| 255 | 257 | token = strings.TrimSpace(token) | |
| 256 | 258 | if token == "" { | |
| 257 | 259 | return fmt.Errorf("token cannot be empty") | |
| @@ -268,19 +270,26 @@ func runAuthLoginWithToken(ctx context.Context, token string, out io.Writer) err | |||
| 268 | 270 | defer cancel() | |
| 269 | 271 | verified := true | |
| 270 | 272 | if _, err := client.GetStatus(verifyCtx); err != nil { | |
| 271 | - | _ = cliWriteLine(out, styles.RenderWarning(fmt.Sprintf("Token verification failed: %v", err))) | |
| 273 | + | if !jsonOut { | |
| 274 | + | _ = cliWriteLine(out, styles.RenderWarning(fmt.Sprintf("Token verification failed: %v", err))) | |
| 275 | + | } | |
| 272 | 276 | verified = false | |
| 273 | 277 | } | |
| 274 | 278 | ||
| 275 | 279 | // Store token — requires a named remote. | |
| 276 | 280 | if resolved.Name == "" { | |
| 277 | - | _ = cliWriteLine(out, styles.RenderWarning("Ad-hoc URL: token verified but cannot be stored. Use 'gordon remotes add' to save this remote.")) | |
| 278 | - | return nil | |
| 281 | + | if jsonOut { | |
| 282 | + | return writeJSON(out, map[string]any{"remote": resolved.DisplayName(), "stored": false, "verified": verified}) | |
| 283 | + | } | |
| 284 | + | return cliWriteLine(out, styles.RenderWarning("Ad-hoc URL: token verified but cannot be stored. Use 'gordon remotes add' to save this remote.")) | |
| 279 | 285 | } | |
| 280 | 286 | if err := remote.UpdateRemoteToken(resolved.Name, token); err != nil { | |
| 281 | 287 | return fmt.Errorf("failed to save token: %w", err) | |
| 282 | 288 | } | |
| 283 | 289 | ||
| 290 | + | if jsonOut { | |
| 291 | + | return writeJSON(out, map[string]any{"remote": resolved.Name, "stored": true, "verified": verified}) | |
| 292 | + | } | |
| 284 | 293 | _ = cliWriteLine(out, "") | |
| 285 | 294 | _ = cliWriteLine(out, styles.RenderSuccess(fmt.Sprintf("Token stored for remote '%s'", resolved.Name))) | |
| 286 | 295 | if verified { | |
| @@ -289,7 +298,7 @@ func runAuthLoginWithToken(ctx context.Context, token string, out io.Writer) err | |||
| 289 | 298 | return nil | |
| 290 | 299 | } | |
| 291 | 300 | ||
| 292 | - | func runAuthLoginVerify(ctx context.Context, out io.Writer) error { | |
| 301 | + | func runAuthLoginVerify(ctx context.Context, out io.Writer, jsonOut bool) error { | |
| 293 | 302 | resolved, err := resolveRequiredRemote(tokenFlag) | |
| 294 | 303 | if err != nil { | |
| 295 | 304 | return err | |
| @@ -307,8 +316,10 @@ func runAuthLoginVerify(ctx context.Context, out io.Writer) error { | |||
| 307 | 316 | return fmt.Errorf("authentication failed") | |
| 308 | 317 | } | |
| 309 | 318 | ||
| 310 | - | _ = cliWriteLine(out, styles.RenderSuccess(fmt.Sprintf("Authenticated to '%s'", resolved.DisplayName()))) | |
| 311 | - | return nil | |
| 319 | + | if jsonOut { | |
| 320 | + | return writeJSON(out, map[string]any{"remote": resolved.DisplayName(), "authenticated": true}) | |
| 321 | + | } | |
| 322 | + | return cliWriteLine(out, styles.RenderSuccess(fmt.Sprintf("Authenticated to '%s'", resolved.DisplayName()))) | |
| 312 | 323 | } | |
| 313 | 324 | ||
| 314 | 325 | // newAuthShowTokenCmd creates the show-token command. | |
| @@ -207,6 +207,7 @@ available while Gordon is running.`, | |||||
| 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 tokenFile string | 209 | var tokenFile string | ||
| 210 | + | var jsonOut bool | |||
| 210 | 211 | ||||
| 211 | cmd := &cobra.Command{ | 212 | cmd := &cobra.Command{ | ||
| 212 | Use: "login", | 213 | Use: "login", | ||
| @@ -228,13 +229,14 @@ Examples: | |||||
| 228 | if err != nil { | 229 | if err != nil { | ||
| 229 | return fmt.Errorf("read token: %w", err) | 230 | return fmt.Errorf("read token: %w", err) | ||
| 230 | } | 231 | } | ||
| 231 | - | return runAuthLoginWithToken(cmd.Context(), token, cmd.OutOrStdout()) | 232 | + | return runAuthLoginWithToken(cmd.Context(), token, cmd.OutOrStdout(), jsonOut) |
| 232 | } | 233 | } | ||
| 233 | - | return runAuthLoginVerify(cmd.Context(), cmd.OutOrStdout()) | 234 | + | return runAuthLoginVerify(cmd.Context(), cmd.OutOrStdout(), jsonOut) |
| 234 | }, | 235 | }, | ||
| 235 | } | 236 | } | ||
| 236 | 237 | ||||
| 237 | cmd.Flags().StringVar(&tokenFile, "token-file", "", "Read authentication token from a mode 0600 file") | 238 | cmd.Flags().StringVar(&tokenFile, "token-file", "", "Read authentication token from a mode 0600 file") | ||
| 239 | + | cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON") | |||
| 238 | 240 | ||||
| 239 | return cmd | 241 | return cmd | ||
| 240 | } | 242 | } | ||
| @@ -251,7 +253,7 @@ func resolveRequiredRemote(flagToken string) (*remote.ResolvedRemote, error) { | |||||
| 251 | return resolved, nil | 253 | return resolved, nil | ||
| 252 | } | 254 | } | ||
| 253 | 255 | ||||
| 254 | - | func runAuthLoginWithToken(ctx context.Context, token string, out io.Writer) error { | 256 | + | func runAuthLoginWithToken(ctx context.Context, token string, out io.Writer, jsonOut bool) error { |
| 255 | token = strings.TrimSpace(token) | 257 | token = strings.TrimSpace(token) | ||
| 256 | if token == "" { | 258 | if token == "" { | ||
| 257 | return fmt.Errorf("token cannot be empty") | 259 | return fmt.Errorf("token cannot be empty") | ||
| @@ -268,19 +270,26 @@ func runAuthLoginWithToken(ctx context.Context, token string, out io.Writer) err | |||||
| 268 | defer cancel() | 270 | defer cancel() | ||
| 269 | verified := true | 271 | verified := true | ||
| 270 | if _, err := client.GetStatus(verifyCtx); err != nil { | 272 | if _, err := client.GetStatus(verifyCtx); err != nil { | ||
| 271 | - | _ = cliWriteLine(out, styles.RenderWarning(fmt.Sprintf("Token verification failed: %v", err))) | 273 | + | if !jsonOut { |
| 274 | + | _ = cliWriteLine(out, styles.RenderWarning(fmt.Sprintf("Token verification failed: %v", err))) | |||
| 275 | + | } | |||
| 272 | verified = false | 276 | verified = false | ||
| 273 | } | 277 | } | ||
| 274 | 278 | ||||
| 275 | // Store token — requires a named remote. | 279 | // Store token — requires a named remote. | ||
| 276 | if resolved.Name == "" { | 280 | if resolved.Name == "" { | ||
| 277 | - | _ = cliWriteLine(out, styles.RenderWarning("Ad-hoc URL: token verified but cannot be stored. Use 'gordon remotes add' to save this remote.")) | 281 | + | if jsonOut { |
| 278 | - | return nil | 282 | + | return writeJSON(out, map[string]any{"remote": resolved.DisplayName(), "stored": false, "verified": verified}) |
| 283 | + | } | |||
| 284 | + | return cliWriteLine(out, styles.RenderWarning("Ad-hoc URL: token verified but cannot be stored. Use 'gordon remotes add' to save this remote.")) | |||
| 279 | } | 285 | } | ||
| 280 | if err := remote.UpdateRemoteToken(resolved.Name, token); err != nil { | 286 | if err := remote.UpdateRemoteToken(resolved.Name, token); err != nil { | ||
| 281 | return fmt.Errorf("failed to save token: %w", err) | 287 | return fmt.Errorf("failed to save token: %w", err) | ||
| 282 | } | 288 | } | ||
| 283 | 289 | ||||
| 290 | + | if jsonOut { | |||
| 291 | + | return writeJSON(out, map[string]any{"remote": resolved.Name, "stored": true, "verified": verified}) | |||
| 292 | + | } | |||
| 284 | _ = cliWriteLine(out, "") | 293 | _ = cliWriteLine(out, "") | ||
| 285 | _ = cliWriteLine(out, styles.RenderSuccess(fmt.Sprintf("Token stored for remote '%s'", resolved.Name))) | 294 | _ = cliWriteLine(out, styles.RenderSuccess(fmt.Sprintf("Token stored for remote '%s'", resolved.Name))) | ||
| 286 | if verified { | 295 | if verified { | ||
| @@ -289,7 +298,7 @@ func runAuthLoginWithToken(ctx context.Context, token string, out io.Writer) err | |||||
| 289 | return nil | 298 | return nil | ||
| 290 | } | 299 | } | ||
| 291 | 300 | ||||
| 292 | - | func runAuthLoginVerify(ctx context.Context, out io.Writer) error { | 301 | + | func runAuthLoginVerify(ctx context.Context, out io.Writer, jsonOut bool) error { |
| 293 | resolved, err := resolveRequiredRemote(tokenFlag) | 302 | resolved, err := resolveRequiredRemote(tokenFlag) | ||
| 294 | if err != nil { | 303 | if err != nil { | ||
| 295 | return err | 304 | return err | ||
| @@ -307,8 +316,10 @@ func runAuthLoginVerify(ctx context.Context, out io.Writer) error { | |||||
| 307 | return fmt.Errorf("authentication failed") | 316 | return fmt.Errorf("authentication failed") | ||
| 308 | } | 317 | } | ||
| 309 | 318 | ||||
| 310 | - | _ = cliWriteLine(out, styles.RenderSuccess(fmt.Sprintf("Authenticated to '%s'", resolved.DisplayName()))) | 319 | + | if jsonOut { |
| 311 | - | return nil | 320 | + | return writeJSON(out, map[string]any{"remote": resolved.DisplayName(), "authenticated": true}) |
| 321 | + | } | |||
| 322 | + | return cliWriteLine(out, styles.RenderSuccess(fmt.Sprintf("Authenticated to '%s'", resolved.DisplayName()))) | |||
| 312 | } | 323 | } | ||
| 313 | 324 | ||||
| 314 | // newAuthShowTokenCmd creates the show-token command. | 325 | // newAuthShowTokenCmd creates the show-token command. | ||
M internal/adapters/in/cli/auth_test.go +2 -1
| @@ -39,8 +39,9 @@ func TestRunAuthLoginWithToken_StoresToken(t *testing.T) { | |||
| 39 | 39 | t.Cleanup(func() { remoteFlag = origRemote }) | |
| 40 | 40 | ||
| 41 | 41 | var out bytes.Buffer | |
| 42 | - | err := runAuthLoginWithToken(context.Background(), "token123", &out) | |
| 42 | + | err := runAuthLoginWithToken(context.Background(), "token123", &out, true) | |
| 43 | 43 | assert.NoError(t, err) | |
| 44 | + | assert.JSONEq(t, `{"remote":"prod","stored":true,"verified":false}`, out.String()) | |
| 44 | 45 | ||
| 45 | 46 | loaded, err := remote.LoadRemotes("") | |
| 46 | 47 | assert.NoError(t, err) | |
| @@ -39,8 +39,9 @@ func TestRunAuthLoginWithToken_StoresToken(t *testing.T) { | |||||
| 39 | t.Cleanup(func() { remoteFlag = origRemote }) | 39 | t.Cleanup(func() { remoteFlag = origRemote }) | ||
| 40 | 40 | ||||
| 41 | var out bytes.Buffer | 41 | var out bytes.Buffer | ||
| 42 | - | err := runAuthLoginWithToken(context.Background(), "token123", &out) | 42 | + | err := runAuthLoginWithToken(context.Background(), "token123", &out, true) |
| 43 | assert.NoError(t, err) | 43 | assert.NoError(t, err) | ||
| 44 | + | assert.JSONEq(t, `{"remote":"prod","stored":true,"verified":false}`, out.String()) | |||
| 44 | 45 | ||||
| 45 | loaded, err := remote.LoadRemotes("") | 46 | loaded, err := remote.LoadRemotes("") | ||
| 46 | assert.NoError(t, err) | 47 | assert.NoError(t, err) | ||
M internal/adapters/in/cli/remotes.go +19 -8
| @@ -157,6 +157,7 @@ func newRemotesAddCmd() *cobra.Command { | |||
| 157 | 157 | var tokenFile string | |
| 158 | 158 | var tokenEnv string | |
| 159 | 159 | var insecureTLS bool | |
| 160 | + | var jsonOut bool | |
| 160 | 161 | ||
| 161 | 162 | cmd := &cobra.Command{ | |
| 162 | 163 | Use: "add <name> <url>", | |
| @@ -192,12 +193,15 @@ Examples: | |||
| 192 | 193 | } | |
| 193 | 194 | } | |
| 194 | 195 | ||
| 195 | - | fmt.Println(styles.RenderSuccess(fmt.Sprintf("Remote added: %s -> %s", name, url))) | |
| 196 | - | ||
| 196 | + | if jsonOut { | |
| 197 | + | return writeJSON(cmd.OutOrStdout(), map[string]any{"name": name, "url": url, "added": true}) | |
| 198 | + | } | |
| 199 | + | if err := cliWriteLine(cmd.OutOrStdout(), styles.RenderSuccess(fmt.Sprintf("Remote added: %s -> %s", name, url))); err != nil { | |
| 200 | + | return err | |
| 201 | + | } | |
| 197 | 202 | if tokenFile == "" && tokenEnv == "" { | |
| 198 | - | fmt.Println(styles.Theme.Muted.Render("Tip: Add a token with --token-file or --token-env for authenticated access")) | |
| 203 | + | return cliWriteLine(cmd.OutOrStdout(), styles.Theme.Muted.Render("Tip: Add a token with --token-file or --token-env for authenticated access")) | |
| 199 | 204 | } | |
| 200 | - | ||
| 201 | 205 | return nil | |
| 202 | 206 | }, | |
| 203 | 207 | } | |
| @@ -205,6 +209,7 @@ Examples: | |||
| 205 | 209 | cmd.Flags().StringVar(&tokenFile, "token-file", "", "Read authentication token from a mode 0600 file") | |
| 206 | 210 | cmd.Flags().StringVar(&tokenEnv, "token-env", "", "Environment variable containing token") | |
| 207 | 211 | cmd.Flags().BoolVar(&insecureTLS, "insecure", false, "Skip TLS certificate verification") | |
| 212 | + | cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON") | |
| 208 | 213 | ||
| 209 | 214 | return cmd | |
| 210 | 215 | } | |
| @@ -320,6 +325,7 @@ Examples: | |||
| 320 | 325 | // newRemotesSetTokenCmd creates the remotes set-token command. | |
| 321 | 326 | func newRemotesSetTokenCmd() *cobra.Command { | |
| 322 | 327 | var tokenFile string | |
| 328 | + | var jsonOut bool | |
| 323 | 329 | cmd := &cobra.Command{ | |
| 324 | 330 | Use: "set-token <name> --token-file <path>", | |
| 325 | 331 | Short: "Set the token for a remote", | |
| @@ -349,19 +355,24 @@ Examples: | |||
| 349 | 355 | } | |
| 350 | 356 | ||
| 351 | 357 | if _, exists := remotes[name]; !exists { | |
| 352 | - | fmt.Println(styles.RenderError(fmt.Sprintf("Remote '%s' not found", name))) | |
| 353 | - | return nil | |
| 358 | + | if jsonOut { | |
| 359 | + | return writeJSON(cmd.OutOrStdout(), map[string]any{"name": name, "updated": false, "error": "remote not found"}) | |
| 360 | + | } | |
| 361 | + | return cliWriteLine(cmd.OutOrStdout(), styles.RenderError(fmt.Sprintf("Remote '%s' not found", name))) | |
| 354 | 362 | } | |
| 355 | 363 | ||
| 356 | 364 | if err := remote.UpdateRemoteToken(name, token); err != nil { | |
| 357 | 365 | return fmt.Errorf("failed to update token: %w", err) | |
| 358 | 366 | } | |
| 359 | 367 | ||
| 360 | - | fmt.Println(styles.RenderSuccess(fmt.Sprintf("Token updated for remote '%s'", name))) | |
| 361 | - | return nil | |
| 368 | + | if jsonOut { | |
| 369 | + | return writeJSON(cmd.OutOrStdout(), map[string]any{"name": name, "updated": true}) | |
| 370 | + | } | |
| 371 | + | return cliWriteLine(cmd.OutOrStdout(), styles.RenderSuccess(fmt.Sprintf("Token updated for remote '%s'", name))) | |
| 362 | 372 | }, | |
| 363 | 373 | } | |
| 364 | 374 | cmd.Flags().StringVar(&tokenFile, "token-file", "", "Read authentication token from a mode 0600 file") | |
| 375 | + | cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON") | |
| 365 | 376 | _ = cmd.MarkFlagRequired("token-file") | |
| 366 | 377 | return cmd | |
| 367 | 378 | } | |
| @@ -157,6 +157,7 @@ func newRemotesAddCmd() *cobra.Command { | |||||
| 157 | var tokenFile string | 157 | var tokenFile string | ||
| 158 | var tokenEnv string | 158 | var tokenEnv string | ||
| 159 | var insecureTLS bool | 159 | var insecureTLS bool | ||
| 160 | + | var jsonOut bool | |||
| 160 | 161 | ||||
| 161 | cmd := &cobra.Command{ | 162 | cmd := &cobra.Command{ | ||
| 162 | Use: "add <name> <url>", | 163 | Use: "add <name> <url>", | ||
| @@ -192,12 +193,15 @@ Examples: | |||||
| 192 | } | 193 | } | ||
| 193 | } | 194 | } | ||
| 194 | 195 | ||||
| 195 | - | fmt.Println(styles.RenderSuccess(fmt.Sprintf("Remote added: %s -> %s", name, url))) | 196 | + | if jsonOut { |
| 196 | - | 197 | + | return writeJSON(cmd.OutOrStdout(), map[string]any{"name": name, "url": url, "added": true}) | |
| 198 | + | } | |||
| 199 | + | if err := cliWriteLine(cmd.OutOrStdout(), styles.RenderSuccess(fmt.Sprintf("Remote added: %s -> %s", name, url))); err != nil { | |||
| 200 | + | return err | |||
| 201 | + | } | |||
| 197 | if tokenFile == "" && tokenEnv == "" { | 202 | if tokenFile == "" && tokenEnv == "" { | ||
| 198 | - | fmt.Println(styles.Theme.Muted.Render("Tip: Add a token with --token-file or --token-env for authenticated access")) | 203 | + | return cliWriteLine(cmd.OutOrStdout(), styles.Theme.Muted.Render("Tip: Add a token with --token-file or --token-env for authenticated access")) |
| 199 | } | 204 | } | ||
| 200 | - | ||||
| 201 | return nil | 205 | return nil | ||
| 202 | }, | 206 | }, | ||
| 203 | } | 207 | } | ||
| @@ -205,6 +209,7 @@ Examples: | |||||
| 205 | cmd.Flags().StringVar(&tokenFile, "token-file", "", "Read authentication token from a mode 0600 file") | 209 | cmd.Flags().StringVar(&tokenFile, "token-file", "", "Read authentication token from a mode 0600 file") | ||
| 206 | cmd.Flags().StringVar(&tokenEnv, "token-env", "", "Environment variable containing token") | 210 | cmd.Flags().StringVar(&tokenEnv, "token-env", "", "Environment variable containing token") | ||
| 207 | cmd.Flags().BoolVar(&insecureTLS, "insecure", false, "Skip TLS certificate verification") | 211 | cmd.Flags().BoolVar(&insecureTLS, "insecure", false, "Skip TLS certificate verification") | ||
| 212 | + | cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON") | |||
| 208 | 213 | ||||
| 209 | return cmd | 214 | return cmd | ||
| 210 | } | 215 | } | ||
| @@ -320,6 +325,7 @@ Examples: | |||||
| 320 | // newRemotesSetTokenCmd creates the remotes set-token command. | 325 | // newRemotesSetTokenCmd creates the remotes set-token command. | ||
| 321 | func newRemotesSetTokenCmd() *cobra.Command { | 326 | func newRemotesSetTokenCmd() *cobra.Command { | ||
| 322 | var tokenFile string | 327 | var tokenFile string | ||
| 328 | + | var jsonOut bool | |||
| 323 | cmd := &cobra.Command{ | 329 | cmd := &cobra.Command{ | ||
| 324 | Use: "set-token <name> --token-file <path>", | 330 | Use: "set-token <name> --token-file <path>", | ||
| 325 | Short: "Set the token for a remote", | 331 | Short: "Set the token for a remote", | ||
| @@ -349,19 +355,24 @@ Examples: | |||||
| 349 | } | 355 | } | ||
| 350 | 356 | ||||
| 351 | if _, exists := remotes[name]; !exists { | 357 | if _, exists := remotes[name]; !exists { | ||
| 352 | - | fmt.Println(styles.RenderError(fmt.Sprintf("Remote '%s' not found", name))) | 358 | + | if jsonOut { |
| 353 | - | return nil | 359 | + | return writeJSON(cmd.OutOrStdout(), map[string]any{"name": name, "updated": false, "error": "remote not found"}) |
| 360 | + | } | |||
| 361 | + | return cliWriteLine(cmd.OutOrStdout(), styles.RenderError(fmt.Sprintf("Remote '%s' not found", name))) | |||
| 354 | } | 362 | } | ||
| 355 | 363 | ||||
| 356 | if err := remote.UpdateRemoteToken(name, token); err != nil { | 364 | if err := remote.UpdateRemoteToken(name, token); err != nil { | ||
| 357 | return fmt.Errorf("failed to update token: %w", err) | 365 | return fmt.Errorf("failed to update token: %w", err) | ||
| 358 | } | 366 | } | ||
| 359 | 367 | ||||
| 360 | - | fmt.Println(styles.RenderSuccess(fmt.Sprintf("Token updated for remote '%s'", name))) | 368 | + | if jsonOut { |
| 361 | - | return nil | 369 | + | return writeJSON(cmd.OutOrStdout(), map[string]any{"name": name, "updated": true}) |
| 370 | + | } | |||
| 371 | + | return cliWriteLine(cmd.OutOrStdout(), styles.RenderSuccess(fmt.Sprintf("Token updated for remote '%s'", name))) | |||
| 362 | }, | 372 | }, | ||
| 363 | } | 373 | } | ||
| 364 | cmd.Flags().StringVar(&tokenFile, "token-file", "", "Read authentication token from a mode 0600 file") | 374 | cmd.Flags().StringVar(&tokenFile, "token-file", "", "Read authentication token from a mode 0600 file") | ||
| 375 | + | cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON") | |||
| 365 | _ = cmd.MarkFlagRequired("token-file") | 376 | _ = cmd.MarkFlagRequired("token-file") | ||
| 366 | return cmd | 377 | return cmd | ||
| 367 | } | 378 | } | ||
M internal/adapters/in/cli/secret_input.go +4 -0
| @@ -14,6 +14,10 @@ func readProtectedSecretFile(path string) (string, error) { | |||
| 14 | 14 | if err != nil { | |
| 15 | 15 | return "", fmt.Errorf("open secret file %s: %w", path, err) | |
| 16 | 16 | } | |
| 17 | + | if fd < 0 { | |
| 18 | + | return "", fmt.Errorf("open secret file %s: invalid file descriptor %d", path, fd) | |
| 19 | + | } | |
| 20 | + | //nolint:gosec // G115: fd is validated non-negative and originates from unix.Open. | |
| 17 | 21 | file := os.NewFile(uintptr(fd), path) | |
| 18 | 22 | if file == nil { | |
| 19 | 23 | _ = unix.Close(fd) | |
| @@ -14,6 +14,10 @@ func readProtectedSecretFile(path string) (string, error) { | |||||
| 14 | if err != nil { | 14 | if err != nil { | ||
| 15 | return "", fmt.Errorf("open secret file %s: %w", path, err) | 15 | return "", fmt.Errorf("open secret file %s: %w", path, err) | ||
| 16 | } | 16 | } | ||
| 17 | + | if fd < 0 { | |||
| 18 | + | return "", fmt.Errorf("open secret file %s: invalid file descriptor %d", path, fd) | |||
| 19 | + | } | |||
| 20 | + | //nolint:gosec // G115: fd is validated non-negative and originates from unix.Open. | |||
| 17 | file := os.NewFile(uintptr(fd), path) | 21 | file := os.NewFile(uintptr(fd), path) | ||
| 18 | if file == nil { | 22 | if file == nil { | ||
| 19 | _ = unix.Close(fd) | 23 | _ = unix.Close(fd) | ||
M internal/adapters/in/cli/secrets.go +9 -5
| @@ -223,6 +223,7 @@ func getKeyPrefix(isLastAttachment, isLastKey bool) string { | |||
| 223 | 223 | // newSecretsSetCmd creates the secrets set command. | |
| 224 | 224 | func newSecretsSetCmd() *cobra.Command { | |
| 225 | 225 | var attachment, fromFile string | |
| 226 | + | var jsonOut bool | |
| 226 | 227 | ||
| 227 | 228 | cmd := &cobra.Command{ | |
| 228 | 229 | Use: "set <domain> --from-file <path>", | |
| @@ -275,20 +276,23 @@ Examples: | |||
| 275 | 276 | target = fmt.Sprintf("%s [%s]", secretDomain, attachment) | |
| 276 | 277 | } | |
| 277 | 278 | ||
| 279 | + | if jsonOut { | |
| 280 | + | return writeJSON(cmd.OutOrStdout(), map[string]any{ | |
| 281 | + | "domain": secretDomain, "attachment": attachment, "count": len(secrets), "updated": true, | |
| 282 | + | }) | |
| 283 | + | } | |
| 278 | 284 | if len(secrets) == 1 { | |
| 279 | 285 | for key := range secrets { | |
| 280 | - | fmt.Println(styles.RenderSuccess(fmt.Sprintf("Secret set: %s on %s", key, target))) | |
| 286 | + | return cliWriteLine(cmd.OutOrStdout(), styles.RenderSuccess(fmt.Sprintf("Secret set: %s on %s", key, target))) | |
| 281 | 287 | } | |
| 282 | - | } else { | |
| 283 | - | fmt.Println(styles.RenderSuccess(fmt.Sprintf("Set %d secrets for %s", len(secrets), target))) | |
| 284 | 288 | } | |
| 285 | - | ||
| 286 | - | return nil | |
| 289 | + | return cliWriteLine(cmd.OutOrStdout(), styles.RenderSuccess(fmt.Sprintf("Set %d secrets for %s", len(secrets), target))) | |
| 287 | 290 | }, | |
| 288 | 291 | } | |
| 289 | 292 | ||
| 290 | 293 | cmd.Flags().StringVarP(&attachment, "attachment", "a", "", "Target an attachment service (e.g., postgres, redis)") | |
| 291 | 294 | cmd.Flags().StringVar(&fromFile, "from-file", "", "Read KEY=value lines from a mode 0600 file") | |
| 295 | + | cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON") | |
| 292 | 296 | _ = cmd.MarkFlagRequired("from-file") | |
| 293 | 297 | ||
| 294 | 298 | return cmd | |
| @@ -223,6 +223,7 @@ func getKeyPrefix(isLastAttachment, isLastKey bool) string { | |||||
| 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, fromFile string | 225 | var attachment, fromFile string | ||
| 226 | + | var jsonOut bool | |||
| 226 | 227 | ||||
| 227 | cmd := &cobra.Command{ | 228 | cmd := &cobra.Command{ | ||
| 228 | Use: "set <domain> --from-file <path>", | 229 | Use: "set <domain> --from-file <path>", | ||
| @@ -275,20 +276,23 @@ Examples: | |||||
| 275 | target = fmt.Sprintf("%s [%s]", secretDomain, attachment) | 276 | target = fmt.Sprintf("%s [%s]", secretDomain, attachment) | ||
| 276 | } | 277 | } | ||
| 277 | 278 | ||||
| 279 | + | if jsonOut { | |||
| 280 | + | return writeJSON(cmd.OutOrStdout(), map[string]any{ | |||
| 281 | + | "domain": secretDomain, "attachment": attachment, "count": len(secrets), "updated": true, | |||
| 282 | + | }) | |||
| 283 | + | } | |||
| 278 | if len(secrets) == 1 { | 284 | if len(secrets) == 1 { | ||
| 279 | for key := range secrets { | 285 | for key := range secrets { | ||
| 280 | - | fmt.Println(styles.RenderSuccess(fmt.Sprintf("Secret set: %s on %s", key, target))) | 286 | + | return cliWriteLine(cmd.OutOrStdout(), styles.RenderSuccess(fmt.Sprintf("Secret set: %s on %s", key, target))) |
| 281 | } | 287 | } | ||
| 282 | - | } else { | |||
| 283 | - | fmt.Println(styles.RenderSuccess(fmt.Sprintf("Set %d secrets for %s", len(secrets), target))) | |||
| 284 | } | 288 | } | ||
| 285 | - | 289 | + | return cliWriteLine(cmd.OutOrStdout(), styles.RenderSuccess(fmt.Sprintf("Set %d secrets for %s", len(secrets), target))) | |
| 286 | - | return nil | |||
| 287 | }, | 290 | }, | ||
| 288 | } | 291 | } | ||
| 289 | 292 | ||||
| 290 | cmd.Flags().StringVarP(&attachment, "attachment", "a", "", "Target an attachment service (e.g., postgres, redis)") | 293 | cmd.Flags().StringVarP(&attachment, "attachment", "a", "", "Target an attachment service (e.g., postgres, redis)") | ||
| 291 | cmd.Flags().StringVar(&fromFile, "from-file", "", "Read KEY=value lines from a mode 0600 file") | 294 | cmd.Flags().StringVar(&fromFile, "from-file", "", "Read KEY=value lines from a mode 0600 file") | ||
| 295 | + | cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON") | |||
| 292 | _ = cmd.MarkFlagRequired("from-file") | 296 | _ = cmd.MarkFlagRequired("from-file") | ||
| 293 | 297 | ||||
| 294 | return cmd | 298 | return cmd | ||
M internal/adapters/in/http/registry/handler_test.go +16 -11
| @@ -13,6 +13,7 @@ import ( | |||
| 13 | 13 | "github.com/bnema/zerowrap" | |
| 14 | 14 | "github.com/stretchr/testify/assert" | |
| 15 | 15 | "github.com/stretchr/testify/mock" | |
| 16 | + | "github.com/stretchr/testify/require" | |
| 16 | 17 | ||
| 17 | 18 | inmocks "github.com/bnema/gordon/internal/boundaries/in/mocks" | |
| 18 | 19 | "github.com/bnema/gordon/internal/domain" | |
| @@ -204,6 +205,8 @@ func TestHandler_GetBlob_Success(t *testing.T) { | |||
| 204 | 205 | registrySvc := inmocks.NewMockRegistryService(t) | |
| 205 | 206 | ||
| 206 | 207 | handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize) | |
| 208 | + | server := httptest.NewServer(handler) | |
| 209 | + | defer server.Close() | |
| 207 | 210 | ||
| 208 | 211 | // Create a temp file for http.ServeFile | |
| 209 | 212 | tmpFile, err := os.CreateTemp("", "blob-*") | |
| @@ -216,28 +219,30 @@ func TestHandler_GetBlob_Success(t *testing.T) { | |||
| 216 | 219 | ||
| 217 | 220 | registrySvc.EXPECT().GetBlobPath(mock.Anything, "myapp", "sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4").Return(tmpFile.Name(), nil) | |
| 218 | 221 | ||
| 219 | - | req := httptest.NewRequest("GET", "/v2/myapp/blobs/sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4", nil) | |
| 220 | - | rec := httptest.NewRecorder() | |
| 221 | - | ||
| 222 | - | handler.ServeHTTP(rec, req) | |
| 222 | + | resp, err := server.Client().Get(server.URL + "/v2/myapp/blobs/sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4") | |
| 223 | + | require.NoError(t, err) | |
| 224 | + | defer resp.Body.Close() | |
| 225 | + | body, err := io.ReadAll(resp.Body) | |
| 226 | + | require.NoError(t, err) | |
| 223 | 227 | ||
| 224 | - | assert.Equal(t, http.StatusOK, rec.Code) | |
| 225 | - | assert.Equal(t, blobContent, rec.Body.Bytes()) | |
| 228 | + | assert.Equal(t, http.StatusOK, resp.StatusCode) | |
| 229 | + | assert.Equal(t, blobContent, body) | |
| 226 | 230 | } | |
| 227 | 231 | ||
| 228 | 232 | func TestHandler_GetBlob_NotFound(t *testing.T) { | |
| 229 | 233 | registrySvc := inmocks.NewMockRegistryService(t) | |
| 230 | 234 | ||
| 231 | 235 | handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize) | |
| 236 | + | server := httptest.NewServer(handler) | |
| 237 | + | defer server.Close() | |
| 232 | 238 | ||
| 233 | 239 | registrySvc.EXPECT().GetBlobPath(mock.Anything, "myapp", "sha256:0000000000000000000000000000000000000000000000000000000000000000").Return("", assert.AnError) | |
| 234 | 240 | ||
| 235 | - | req := httptest.NewRequest("GET", "/v2/myapp/blobs/sha256:0000000000000000000000000000000000000000000000000000000000000000", nil) | |
| 236 | - | rec := httptest.NewRecorder() | |
| 237 | - | ||
| 238 | - | handler.ServeHTTP(rec, req) | |
| 241 | + | resp, err := server.Client().Get(server.URL + "/v2/myapp/blobs/sha256:0000000000000000000000000000000000000000000000000000000000000000") | |
| 242 | + | require.NoError(t, err) | |
| 243 | + | defer resp.Body.Close() | |
| 239 | 244 | ||
| 240 | - | assert.Equal(t, http.StatusNotFound, rec.Code) | |
| 245 | + | assert.Equal(t, http.StatusNotFound, resp.StatusCode) | |
| 241 | 246 | } | |
| 242 | 247 | ||
| 243 | 248 | func TestHandler_BlobRoutes_MethodNotAllowed(t *testing.T) { | |
| @@ -13,6 +13,7 @@ import ( | |||||
| 13 | "github.com/bnema/zerowrap" | 13 | "github.com/bnema/zerowrap" | ||
| 14 | "github.com/stretchr/testify/assert" | 14 | "github.com/stretchr/testify/assert" | ||
| 15 | "github.com/stretchr/testify/mock" | 15 | "github.com/stretchr/testify/mock" | ||
| 16 | + | "github.com/stretchr/testify/require" | |||
| 16 | 17 | ||||
| 17 | inmocks "github.com/bnema/gordon/internal/boundaries/in/mocks" | 18 | inmocks "github.com/bnema/gordon/internal/boundaries/in/mocks" | ||
| 18 | "github.com/bnema/gordon/internal/domain" | 19 | "github.com/bnema/gordon/internal/domain" | ||
| @@ -204,6 +205,8 @@ func TestHandler_GetBlob_Success(t *testing.T) { | |||||
| 204 | registrySvc := inmocks.NewMockRegistryService(t) | 205 | registrySvc := inmocks.NewMockRegistryService(t) | ||
| 205 | 206 | ||||
| 206 | handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize) | 207 | handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize) | ||
| 208 | + | server := httptest.NewServer(handler) | |||
| 209 | + | defer server.Close() | |||
| 207 | 210 | ||||
| 208 | // Create a temp file for http.ServeFile | 211 | // Create a temp file for http.ServeFile | ||
| 209 | tmpFile, err := os.CreateTemp("", "blob-*") | 212 | tmpFile, err := os.CreateTemp("", "blob-*") | ||
| @@ -216,28 +219,30 @@ func TestHandler_GetBlob_Success(t *testing.T) { | |||||
| 216 | 219 | ||||
| 217 | registrySvc.EXPECT().GetBlobPath(mock.Anything, "myapp", "sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4").Return(tmpFile.Name(), nil) | 220 | registrySvc.EXPECT().GetBlobPath(mock.Anything, "myapp", "sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4").Return(tmpFile.Name(), nil) | ||
| 218 | 221 | ||||
| 219 | - | req := httptest.NewRequest("GET", "/v2/myapp/blobs/sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4", nil) | 222 | + | resp, err := server.Client().Get(server.URL + "/v2/myapp/blobs/sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4") |
| 220 | - | rec := httptest.NewRecorder() | 223 | + | require.NoError(t, err) |
| 221 | - | 224 | + | defer resp.Body.Close() | |
| 222 | - | handler.ServeHTTP(rec, req) | 225 | + | body, err := io.ReadAll(resp.Body) |
| 226 | + | require.NoError(t, err) | |||
| 223 | 227 | ||||
| 224 | - | assert.Equal(t, http.StatusOK, rec.Code) | 228 | + | assert.Equal(t, http.StatusOK, resp.StatusCode) |
| 225 | - | assert.Equal(t, blobContent, rec.Body.Bytes()) | 229 | + | assert.Equal(t, blobContent, body) |
| 226 | } | 230 | } | ||
| 227 | 231 | ||||
| 228 | func TestHandler_GetBlob_NotFound(t *testing.T) { | 232 | func TestHandler_GetBlob_NotFound(t *testing.T) { | ||
| 229 | registrySvc := inmocks.NewMockRegistryService(t) | 233 | registrySvc := inmocks.NewMockRegistryService(t) | ||
| 230 | 234 | ||||
| 231 | handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize) | 235 | handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize) | ||
| 236 | + | server := httptest.NewServer(handler) | |||
| 237 | + | defer server.Close() | |||
| 232 | 238 | ||||
| 233 | registrySvc.EXPECT().GetBlobPath(mock.Anything, "myapp", "sha256:0000000000000000000000000000000000000000000000000000000000000000").Return("", assert.AnError) | 239 | registrySvc.EXPECT().GetBlobPath(mock.Anything, "myapp", "sha256:0000000000000000000000000000000000000000000000000000000000000000").Return("", assert.AnError) | ||
| 234 | 240 | ||||
| 235 | - | req := httptest.NewRequest("GET", "/v2/myapp/blobs/sha256:0000000000000000000000000000000000000000000000000000000000000000", nil) | 241 | + | resp, err := server.Client().Get(server.URL + "/v2/myapp/blobs/sha256:0000000000000000000000000000000000000000000000000000000000000000") |
| 236 | - | rec := httptest.NewRecorder() | 242 | + | require.NoError(t, err) |
| 237 | - | 243 | + | defer resp.Body.Close() | |
| 238 | - | handler.ServeHTTP(rec, req) | |||
| 239 | 244 | ||||
| 240 | - | assert.Equal(t, http.StatusNotFound, rec.Code) | 245 | + | assert.Equal(t, http.StatusNotFound, resp.StatusCode) |
| 241 | } | 246 | } | ||
| 242 | 247 | ||||
| 243 | func TestHandler_BlobRoutes_MethodNotAllowed(t *testing.T) { | 248 | func TestHandler_BlobRoutes_MethodNotAllowed(t *testing.T) { | ||
M internal/adapters/out/docker/runtime.go +24 -4
| @@ -6,6 +6,7 @@ import ( | |||
| 6 | 6 | "bytes" | |
| 7 | 7 | "context" | |
| 8 | 8 | "encoding/base64" | |
| 9 | + | "encoding/binary" | |
| 9 | 10 | "encoding/json" | |
| 10 | 11 | "errors" | |
| 11 | 12 | "fmt" | |
| @@ -1736,11 +1737,11 @@ type boundedBuffer struct { | |||
| 1736 | 1737 | func (w *boundedBuffer) Write(p []byte) (int, error) { | |
| 1737 | 1738 | remaining := w.limit - w.Len() | |
| 1738 | 1739 | if remaining <= 0 { | |
| 1739 | - | return 0, fmt.Errorf("container exec output exceeds %d bytes", w.limit) | |
| 1740 | + | return 0, fmt.Errorf("%w: limit is %d bytes", domain.ErrExecOutputExceeded, w.limit) | |
| 1740 | 1741 | } | |
| 1741 | 1742 | if len(p) > remaining { | |
| 1742 | 1743 | _, _ = w.Buffer.Write(p[:remaining]) | |
| 1743 | - | return remaining, fmt.Errorf("container exec output exceeds %d bytes", w.limit) | |
| 1744 | + | return remaining, fmt.Errorf("%w: limit is %d bytes", domain.ErrExecOutputExceeded, w.limit) | |
| 1744 | 1745 | } | |
| 1745 | 1746 | return w.Buffer.Write(p) | |
| 1746 | 1747 | } | |
| @@ -1748,9 +1749,28 @@ func (w *boundedBuffer) Write(p []byte) (int, error) { | |||
| 1748 | 1749 | func parseExecOutput(reader io.Reader) ([]byte, []byte, error) { | |
| 1749 | 1750 | stdout := boundedBuffer{limit: maxExecOutputSize} | |
| 1750 | 1751 | stderr := boundedBuffer{limit: maxExecOutputSize} | |
| 1752 | + | buffers := map[byte]*boundedBuffer{1: &stdout, 2: &stderr} | |
| 1753 | + | var header [8]byte | |
| 1751 | 1754 | ||
| 1752 | - | if _, err := stdcopy.StdCopy(&stdout, &stderr, reader); err != nil { | |
| 1753 | - | return nil, nil, err | |
| 1755 | + | for { | |
| 1756 | + | if _, err := io.ReadFull(reader, header[:]); err != nil { | |
| 1757 | + | if errors.Is(err, io.EOF) { | |
| 1758 | + | break | |
| 1759 | + | } | |
| 1760 | + | return nil, nil, fmt.Errorf("read Docker exec frame header: %w", err) | |
| 1761 | + | } | |
| 1762 | + | output, ok := buffers[header[0]] | |
| 1763 | + | if !ok { | |
| 1764 | + | return nil, nil, fmt.Errorf("invalid Docker exec stream %d", header[0]) | |
| 1765 | + | } | |
| 1766 | + | payloadSize := int64(binary.BigEndian.Uint32(header[4:])) | |
| 1767 | + | remaining := int64(output.limit - output.Len()) | |
| 1768 | + | if payloadSize > remaining { | |
| 1769 | + | return nil, nil, fmt.Errorf("%w: frame is %d bytes with %d bytes remaining", domain.ErrExecOutputExceeded, payloadSize, remaining) | |
| 1770 | + | } | |
| 1771 | + | if _, err := io.CopyN(output, reader, payloadSize); err != nil { | |
| 1772 | + | return nil, nil, fmt.Errorf("read Docker exec frame payload: %w", err) | |
| 1773 | + | } | |
| 1754 | 1774 | } | |
| 1755 | 1775 | ||
| 1756 | 1776 | return stdout.Bytes(), stderr.Bytes(), nil | |
| @@ -6,6 +6,7 @@ import ( | |||||
| 6 | "bytes" | 6 | "bytes" | ||
| 7 | "context" | 7 | "context" | ||
| 8 | "encoding/base64" | 8 | "encoding/base64" | ||
| 9 | + | "encoding/binary" | |||
| 9 | "encoding/json" | 10 | "encoding/json" | ||
| 10 | "errors" | 11 | "errors" | ||
| 11 | "fmt" | 12 | "fmt" | ||
| @@ -1736,11 +1737,11 @@ type boundedBuffer struct { | |||||
| 1736 | func (w *boundedBuffer) Write(p []byte) (int, error) { | 1737 | func (w *boundedBuffer) Write(p []byte) (int, error) { | ||
| 1737 | remaining := w.limit - w.Len() | 1738 | remaining := w.limit - w.Len() | ||
| 1738 | if remaining <= 0 { | 1739 | if remaining <= 0 { | ||
| 1739 | - | return 0, fmt.Errorf("container exec output exceeds %d bytes", w.limit) | 1740 | + | return 0, fmt.Errorf("%w: limit is %d bytes", domain.ErrExecOutputExceeded, w.limit) |
| 1740 | } | 1741 | } | ||
| 1741 | if len(p) > remaining { | 1742 | if len(p) > remaining { | ||
| 1742 | _, _ = w.Buffer.Write(p[:remaining]) | 1743 | _, _ = w.Buffer.Write(p[:remaining]) | ||
| 1743 | - | return remaining, fmt.Errorf("container exec output exceeds %d bytes", w.limit) | 1744 | + | return remaining, fmt.Errorf("%w: limit is %d bytes", domain.ErrExecOutputExceeded, w.limit) |
| 1744 | } | 1745 | } | ||
| 1745 | return w.Buffer.Write(p) | 1746 | return w.Buffer.Write(p) | ||
| 1746 | } | 1747 | } | ||
| @@ -1748,9 +1749,28 @@ func (w *boundedBuffer) Write(p []byte) (int, error) { | |||||
| 1748 | func parseExecOutput(reader io.Reader) ([]byte, []byte, error) { | 1749 | func parseExecOutput(reader io.Reader) ([]byte, []byte, error) { | ||
| 1749 | stdout := boundedBuffer{limit: maxExecOutputSize} | 1750 | stdout := boundedBuffer{limit: maxExecOutputSize} | ||
| 1750 | stderr := boundedBuffer{limit: maxExecOutputSize} | 1751 | stderr := boundedBuffer{limit: maxExecOutputSize} | ||
| 1752 | + | buffers := map[byte]*boundedBuffer{1: &stdout, 2: &stderr} | |||
| 1753 | + | var header [8]byte | |||
| 1751 | 1754 | ||||
| 1752 | - | if _, err := stdcopy.StdCopy(&stdout, &stderr, reader); err != nil { | 1755 | + | for { |
| 1753 | - | return nil, nil, err | 1756 | + | if _, err := io.ReadFull(reader, header[:]); err != nil { |
| 1757 | + | if errors.Is(err, io.EOF) { | |||
| 1758 | + | break | |||
| 1759 | + | } | |||
| 1760 | + | return nil, nil, fmt.Errorf("read Docker exec frame header: %w", err) | |||
| 1761 | + | } | |||
| 1762 | + | output, ok := buffers[header[0]] | |||
| 1763 | + | if !ok { | |||
| 1764 | + | return nil, nil, fmt.Errorf("invalid Docker exec stream %d", header[0]) | |||
| 1765 | + | } | |||
| 1766 | + | payloadSize := int64(binary.BigEndian.Uint32(header[4:])) | |||
| 1767 | + | remaining := int64(output.limit - output.Len()) | |||
| 1768 | + | if payloadSize > remaining { | |||
| 1769 | + | return nil, nil, fmt.Errorf("%w: frame is %d bytes with %d bytes remaining", domain.ErrExecOutputExceeded, payloadSize, remaining) | |||
| 1770 | + | } | |||
| 1771 | + | if _, err := io.CopyN(output, reader, payloadSize); err != nil { | |||
| 1772 | + | return nil, nil, fmt.Errorf("read Docker exec frame payload: %w", err) | |||
| 1773 | + | } | |||
| 1754 | } | 1774 | } | ||
| 1755 | 1775 | ||||
| 1756 | return stdout.Bytes(), stderr.Bytes(), nil | 1776 | return stdout.Bytes(), stderr.Bytes(), nil | ||
M internal/adapters/out/docker/runtime_exec_test.go +14 -0
| @@ -11,6 +11,8 @@ import ( | |||
| 11 | 11 | ||
| 12 | 12 | "github.com/stretchr/testify/assert" | |
| 13 | 13 | "github.com/stretchr/testify/require" | |
| 14 | + | ||
| 15 | + | "github.com/bnema/gordon/internal/domain" | |
| 14 | 16 | ) | |
| 15 | 17 | ||
| 16 | 18 | func TestParseExecOutput_SplitsStdoutAndStderr(t *testing.T) { | |
| @@ -23,6 +25,18 @@ func TestParseExecOutput_SplitsStdoutAndStderr(t *testing.T) { | |||
| 23 | 25 | assert.Equal(t, []byte("warn\n"), stderr) | |
| 24 | 26 | } | |
| 25 | 27 | ||
| 28 | + | func TestParseExecOutput_RejectsOversizedFrameBeforeReadingPayload(t *testing.T) { | |
| 29 | + | var header [8]byte | |
| 30 | + | header[0] = 1 | |
| 31 | + | binary.BigEndian.PutUint32(header[4:], maxExecOutputSize+1) | |
| 32 | + | ||
| 33 | + | stdout, stderr, err := parseExecOutput(bytes.NewReader(header[:])) | |
| 34 | + | ||
| 35 | + | assert.ErrorIs(t, err, domain.ErrExecOutputExceeded) | |
| 36 | + | assert.Nil(t, stdout) | |
| 37 | + | assert.Nil(t, stderr) | |
| 38 | + | } | |
| 39 | + | ||
| 26 | 40 | func TestRuntime_ExecInContainer_RejectsEmptyCommand(t *testing.T) { | |
| 27 | 41 | r := &Runtime{} | |
| 28 | 42 | ||
| @@ -11,6 +11,8 @@ import ( | |||||
| 11 | 11 | ||||
| 12 | "github.com/stretchr/testify/assert" | 12 | "github.com/stretchr/testify/assert" | ||
| 13 | "github.com/stretchr/testify/require" | 13 | "github.com/stretchr/testify/require" | ||
| 14 | + | ||||
| 15 | + | "github.com/bnema/gordon/internal/domain" | |||
| 14 | ) | 16 | ) | ||
| 15 | 17 | ||||
| 16 | func TestParseExecOutput_SplitsStdoutAndStderr(t *testing.T) { | 18 | func TestParseExecOutput_SplitsStdoutAndStderr(t *testing.T) { | ||
| @@ -23,6 +25,18 @@ func TestParseExecOutput_SplitsStdoutAndStderr(t *testing.T) { | |||||
| 23 | assert.Equal(t, []byte("warn\n"), stderr) | 25 | assert.Equal(t, []byte("warn\n"), stderr) | ||
| 24 | } | 26 | } | ||
| 25 | 27 | ||||
| 28 | + | func TestParseExecOutput_RejectsOversizedFrameBeforeReadingPayload(t *testing.T) { | |||
| 29 | + | var header [8]byte | |||
| 30 | + | header[0] = 1 | |||
| 31 | + | binary.BigEndian.PutUint32(header[4:], maxExecOutputSize+1) | |||
| 32 | + | ||||
| 33 | + | stdout, stderr, err := parseExecOutput(bytes.NewReader(header[:])) | |||
| 34 | + | ||||
| 35 | + | assert.ErrorIs(t, err, domain.ErrExecOutputExceeded) | |||
| 36 | + | assert.Nil(t, stdout) | |||
| 37 | + | assert.Nil(t, stderr) | |||
| 38 | + | } | |||
| 39 | + | ||||
| 26 | func TestRuntime_ExecInContainer_RejectsEmptyCommand(t *testing.T) { | 40 | func TestRuntime_ExecInContainer_RejectsEmptyCommand(t *testing.T) { | ||
| 27 | r := &Runtime{} | 41 | r := &Runtime{} | ||
| 28 | 42 | ||||
M internal/app/run.go +4 -3
| @@ -84,6 +84,7 @@ import ( | |||
| 84 | 84 | "github.com/bnema/gordon/internal/usecase/proxy" | |
| 85 | 85 | "github.com/bnema/gordon/internal/usecase/publictls" | |
| 86 | 86 | registrySvc "github.com/bnema/gordon/internal/usecase/registry" | |
| 87 | + | "github.com/bnema/gordon/internal/usecase/registrystate" | |
| 87 | 88 | secretsSvc "github.com/bnema/gordon/internal/usecase/secrets" | |
| 88 | 89 | servicecfg "github.com/bnema/gordon/internal/usecase/services" | |
| 89 | 90 | "github.com/bnema/gordon/internal/usecase/traffic" | |
| @@ -747,9 +748,9 @@ func (si *serviceInit) initRuntimeAndProxy() error { | |||
| 747 | 748 | return err | |
| 748 | 749 | } | |
| 749 | 750 | ||
| 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) | |
| 751 | + | registryState := registrystate.New() | |
| 752 | + | si.svc.registrySvc = registrySvc.NewService(si.svc.blobStorage, si.svc.manifestStorage, si.svc.eventBus, registryState) | |
| 753 | + | si.svc.imageSvc = images.NewService(si.svc.runtime, si.svc.manifestStorage, si.svc.blobStorage, si.log, registryState) | |
| 753 | 754 | si.svc.volumeSvc = volumesSvc.NewService(si.svc.runtime) | |
| 754 | 755 | ||
| 755 | 756 | injectTelemetryMetrics(si.cfg, si.svc, si.log) | |
| @@ -84,6 +84,7 @@ import ( | |||||
| 84 | "github.com/bnema/gordon/internal/usecase/proxy" | 84 | "github.com/bnema/gordon/internal/usecase/proxy" | ||
| 85 | "github.com/bnema/gordon/internal/usecase/publictls" | 85 | "github.com/bnema/gordon/internal/usecase/publictls" | ||
| 86 | registrySvc "github.com/bnema/gordon/internal/usecase/registry" | 86 | registrySvc "github.com/bnema/gordon/internal/usecase/registry" | ||
| 87 | + | "github.com/bnema/gordon/internal/usecase/registrystate" | |||
| 87 | secretsSvc "github.com/bnema/gordon/internal/usecase/secrets" | 88 | secretsSvc "github.com/bnema/gordon/internal/usecase/secrets" | ||
| 88 | servicecfg "github.com/bnema/gordon/internal/usecase/services" | 89 | servicecfg "github.com/bnema/gordon/internal/usecase/services" | ||
| 89 | "github.com/bnema/gordon/internal/usecase/traffic" | 90 | "github.com/bnema/gordon/internal/usecase/traffic" | ||
| @@ -747,9 +748,9 @@ func (si *serviceInit) initRuntimeAndProxy() error { | |||||
| 747 | return err | 748 | return err | ||
| 748 | } | 749 | } | ||
| 749 | 750 | ||||
| 750 | - | registryMutationMu := &sync.RWMutex{} | 751 | + | registryState := registrystate.New() |
| 751 | - | si.svc.registrySvc = registrySvc.NewService(si.svc.blobStorage, si.svc.manifestStorage, si.svc.eventBus, registryMutationMu) | 752 | + | si.svc.registrySvc = registrySvc.NewService(si.svc.blobStorage, si.svc.manifestStorage, si.svc.eventBus, registryState) |
| 752 | - | si.svc.imageSvc = images.NewService(si.svc.runtime, si.svc.manifestStorage, si.svc.blobStorage, si.log, registryMutationMu) | 753 | + | si.svc.imageSvc = images.NewService(si.svc.runtime, si.svc.manifestStorage, si.svc.blobStorage, si.log, registryState) |
| 753 | si.svc.volumeSvc = volumesSvc.NewService(si.svc.runtime) | 754 | si.svc.volumeSvc = volumesSvc.NewService(si.svc.runtime) | ||
| 754 | 755 | ||||
| 755 | injectTelemetryMetrics(si.cfg, si.svc, si.log) | 756 | injectTelemetryMetrics(si.cfg, si.svc, si.log) | ||
M internal/domain/errors.go +15 -12
| @@ -28,13 +28,14 @@ var ( | |||
| 28 | 28 | ErrNoTargetAvailable = errors.New("no target available for route") | |
| 29 | 29 | ||
| 30 | 30 | // Registry errors | |
| 31 | - | ErrManifestNotFound = errors.New("manifest not found") | |
| 32 | - | ErrBlobNotFound = errors.New("blob not found") | |
| 33 | - | ErrUploadNotFound = errors.New("upload not found") | |
| 34 | - | ErrInvalidDigest = errors.New("invalid digest") | |
| 35 | - | ErrDigestMismatch = errors.New("digest mismatch") | |
| 36 | - | ErrUnauthorized = errors.New("unauthorized") | |
| 37 | - | ErrBlobSizeExceeded = errors.New("blob size exceeds maximum") | |
| 31 | + | ErrManifestNotFound = errors.New("manifest not found") | |
| 32 | + | ErrBlobNotFound = errors.New("blob not found") | |
| 33 | + | ErrUploadNotFound = errors.New("upload not found") | |
| 34 | + | ErrInvalidDigest = errors.New("invalid digest") | |
| 35 | + | ErrDigestMismatch = errors.New("digest mismatch") | |
| 36 | + | ErrUnauthorized = errors.New("unauthorized") | |
| 37 | + | ErrBlobSizeExceeded = errors.New("blob size exceeds maximum") | |
| 38 | + | ErrExecOutputExceeded = errors.New("container exec output exceeds maximum") | |
| 38 | 39 | ||
| 39 | 40 | // Network errors | |
| 40 | 41 | ErrNetworkNotFound = errors.New("network not found") | |
| @@ -58,11 +59,13 @@ var ( | |||
| 58 | 59 | ErrRouteConflict = errors.New("route conflicts with existing configuration") | |
| 59 | 60 | ||
| 60 | 61 | // Environment errors | |
| 61 | - | ErrEnvFileNotFound = errors.New("environment file not found") | |
| 62 | - | ErrSecretNotFound = errors.New("secret not found") | |
| 63 | - | ErrSecretsAlreadyExist = errors.New("secrets already exist") | |
| 64 | - | ErrProviderNotFound = errors.New("secret provider not found") | |
| 65 | - | ErrInvalidContainerName = errors.New("invalid container name") | |
| 62 | + | ErrEnvFileNotFound = errors.New("environment file not found") | |
| 63 | + | ErrSecretNotFound = errors.New("secret not found") | |
| 64 | + | ErrSecretsAlreadyExist = errors.New("secrets already exist") | |
| 65 | + | ErrProviderNotFound = errors.New("secret provider not found") | |
| 66 | + | ErrInvalidContainerName = errors.New("invalid container name") | |
| 67 | + | ErrAttachmentOwnershipMismatch = errors.New("attachment ownership mismatch") | |
| 68 | + | ErrReadinessLogSizeExceeded = errors.New("readiness log exceeds maximum") | |
| 66 | 69 | ||
| 67 | 70 | // Authentication errors | |
| 68 | 71 | ErrInvalidToken = errors.New("invalid token") | |
| @@ -28,13 +28,14 @@ var ( | |||||
| 28 | ErrNoTargetAvailable = errors.New("no target available for route") | 28 | ErrNoTargetAvailable = errors.New("no target available for route") | ||
| 29 | 29 | ||||
| 30 | // Registry errors | 30 | // Registry errors | ||
| 31 | - | ErrManifestNotFound = errors.New("manifest not found") | 31 | + | ErrManifestNotFound = errors.New("manifest not found") |
| 32 | - | ErrBlobNotFound = errors.New("blob not found") | 32 | + | ErrBlobNotFound = errors.New("blob not found") |
| 33 | - | ErrUploadNotFound = errors.New("upload not found") | 33 | + | ErrUploadNotFound = errors.New("upload not found") |
| 34 | - | ErrInvalidDigest = errors.New("invalid digest") | 34 | + | ErrInvalidDigest = errors.New("invalid digest") |
| 35 | - | ErrDigestMismatch = errors.New("digest mismatch") | 35 | + | ErrDigestMismatch = errors.New("digest mismatch") |
| 36 | - | ErrUnauthorized = errors.New("unauthorized") | 36 | + | ErrUnauthorized = errors.New("unauthorized") |
| 37 | - | ErrBlobSizeExceeded = errors.New("blob size exceeds maximum") | 37 | + | ErrBlobSizeExceeded = errors.New("blob size exceeds maximum") |
| 38 | + | ErrExecOutputExceeded = errors.New("container exec output exceeds maximum") | |||
| 38 | 39 | ||||
| 39 | // Network errors | 40 | // Network errors | ||
| 40 | ErrNetworkNotFound = errors.New("network not found") | 41 | ErrNetworkNotFound = errors.New("network not found") | ||
| @@ -58,11 +59,13 @@ var ( | |||||
| 58 | ErrRouteConflict = errors.New("route conflicts with existing configuration") | 59 | ErrRouteConflict = errors.New("route conflicts with existing configuration") | ||
| 59 | 60 | ||||
| 60 | // Environment errors | 61 | // Environment errors | ||
| 61 | - | ErrEnvFileNotFound = errors.New("environment file not found") | 62 | + | ErrEnvFileNotFound = errors.New("environment file not found") |
| 62 | - | ErrSecretNotFound = errors.New("secret not found") | 63 | + | ErrSecretNotFound = errors.New("secret not found") |
| 63 | - | ErrSecretsAlreadyExist = errors.New("secrets already exist") | 64 | + | ErrSecretsAlreadyExist = errors.New("secrets already exist") |
| 64 | - | ErrProviderNotFound = errors.New("secret provider not found") | 65 | + | ErrProviderNotFound = errors.New("secret provider not found") |
| 65 | - | ErrInvalidContainerName = errors.New("invalid container name") | 66 | + | ErrInvalidContainerName = errors.New("invalid container name") |
| 67 | + | ErrAttachmentOwnershipMismatch = errors.New("attachment ownership mismatch") | |||
| 68 | + | ErrReadinessLogSizeExceeded = errors.New("readiness log exceeds maximum") | |||
| 66 | 69 | ||||
| 67 | // Authentication errors | 70 | // Authentication errors | ||
| 68 | ErrInvalidToken = errors.New("invalid token") | 71 | ErrInvalidToken = errors.New("invalid token") | ||
M internal/usecase/auth/service.go +8 -7
| @@ -515,13 +515,14 @@ func (s *Service) ExtendToken(ctx context.Context, tokenString string) (string, | |||
| 515 | 515 | newExpiresAt := now.Add(tokenExtensionTTL) | |
| 516 | 516 | ||
| 517 | 517 | newClaims := jwt.MapClaims{ | |
| 518 | - | "jti": tokenClaims.ID, // Reuse existing JTI to avoid invalidating concurrent requests | |
| 519 | - | "sub": tokenClaims.Subject, | |
| 520 | - | "iss": TokenIssuer, | |
| 521 | - | "iat": now.Unix(), // Update to current time (token is being re-issued) | |
| 522 | - | "nbf": now.Unix(), // SECURITY: not-before matches issuance time | |
| 523 | - | "scopes": tokenClaims.Scopes, | |
| 524 | - | "exp": newExpiresAt.Unix(), | |
| 518 | + | "jti": tokenClaims.ID, // Reuse existing JTI to avoid invalidating concurrent requests | |
| 519 | + | "sub": tokenClaims.Subject, | |
| 520 | + | "iss": TokenIssuer, | |
| 521 | + | "iat": now.Unix(), // Update to current time (token is being re-issued) | |
| 522 | + | "nbf": now.Unix(), // SECURITY: not-before matches issuance time | |
| 523 | + | "scopes": tokenClaims.Scopes, | |
| 524 | + | "exp": newExpiresAt.Unix(), | |
| 525 | + | "token_type": "stored", | |
| 525 | 526 | } | |
| 526 | 527 | ||
| 527 | 528 | jwtToken := jwt.NewWithClaims(jwt.SigningMethodHS256, newClaims) | |
| @@ -515,13 +515,14 @@ func (s *Service) ExtendToken(ctx context.Context, tokenString string) (string, | |||||
| 515 | newExpiresAt := now.Add(tokenExtensionTTL) | 515 | newExpiresAt := now.Add(tokenExtensionTTL) | ||
| 516 | 516 | ||||
| 517 | newClaims := jwt.MapClaims{ | 517 | newClaims := jwt.MapClaims{ | ||
| 518 | - | "jti": tokenClaims.ID, // Reuse existing JTI to avoid invalidating concurrent requests | 518 | + | "jti": tokenClaims.ID, // Reuse existing JTI to avoid invalidating concurrent requests |
| 519 | - | "sub": tokenClaims.Subject, | 519 | + | "sub": tokenClaims.Subject, |
| 520 | - | "iss": TokenIssuer, | 520 | + | "iss": TokenIssuer, |
| 521 | - | "iat": now.Unix(), // Update to current time (token is being re-issued) | 521 | + | "iat": now.Unix(), // Update to current time (token is being re-issued) |
| 522 | - | "nbf": now.Unix(), // SECURITY: not-before matches issuance time | 522 | + | "nbf": now.Unix(), // SECURITY: not-before matches issuance time |
| 523 | - | "scopes": tokenClaims.Scopes, | 523 | + | "scopes": tokenClaims.Scopes, |
| 524 | - | "exp": newExpiresAt.Unix(), | 524 | + | "exp": newExpiresAt.Unix(), |
| 525 | + | "token_type": "stored", | |||
| 525 | } | 526 | } | ||
| 526 | 527 | ||||
| 527 | jwtToken := jwt.NewWithClaims(jwt.SigningMethodHS256, newClaims) | 528 | jwtToken := jwt.NewWithClaims(jwt.SigningMethodHS256, newClaims) | ||
M internal/usecase/auth/service_test.go +1 -0
| @@ -802,6 +802,7 @@ func TestExtendTokenSlidesExpiry(t *testing.T) { | |||
| 802 | 802 | expectedExpiry := time.Now().Add(24 * time.Hour) | |
| 803 | 803 | actualExpiry := time.Unix(claims.ExpiresAt, 0) | |
| 804 | 804 | assert.WithinDuration(t, expectedExpiry, actualExpiry, 5*time.Minute, "expiry should be ~24h from now") | |
| 805 | + | assert.Equal(t, "stored", claims.TokenType) | |
| 805 | 806 | } | |
| 806 | 807 | ||
| 807 | 808 | func TestExtendTokenDebounce(t *testing.T) { | |
| @@ -802,6 +802,7 @@ func TestExtendTokenSlidesExpiry(t *testing.T) { | |||||
| 802 | expectedExpiry := time.Now().Add(24 * time.Hour) | 802 | expectedExpiry := time.Now().Add(24 * time.Hour) | ||
| 803 | actualExpiry := time.Unix(claims.ExpiresAt, 0) | 803 | actualExpiry := time.Unix(claims.ExpiresAt, 0) | ||
| 804 | assert.WithinDuration(t, expectedExpiry, actualExpiry, 5*time.Minute, "expiry should be ~24h from now") | 804 | assert.WithinDuration(t, expectedExpiry, actualExpiry, 5*time.Minute, "expiry should be ~24h from now") | ||
| 805 | + | assert.Equal(t, "stored", claims.TokenType) | |||
| 805 | } | 806 | } | ||
| 806 | 807 | ||||
| 807 | func TestExtendTokenDebounce(t *testing.T) { | 808 | func TestExtendTokenDebounce(t *testing.T) { | ||
M internal/usecase/container/service.go +40 -6
| @@ -2540,8 +2540,13 @@ func (s *Service) setupVolumes(ctx context.Context, domainName, imageRef string) | |||
| 2540 | 2540 | continue | |
| 2541 | 2541 | } | |
| 2542 | 2542 | if legacyExists { | |
| 2543 | - | volumes[path] = legacyName | |
| 2544 | - | continue | |
| 2543 | + | owned, ownershipErr := s.legacyVolumeOwnedByDomain(ctx, legacyName, domainName) | |
| 2544 | + | if ownershipErr != nil { | |
| 2545 | + | log.Warn().Err(ownershipErr).Str("volume", legacyName).Msg("could not verify legacy volume ownership; using stable volume name") | |
| 2546 | + | } else if owned { | |
| 2547 | + | volumes[path] = legacyName | |
| 2548 | + | continue | |
| 2549 | + | } | |
| 2545 | 2550 | } | |
| 2546 | 2551 | ||
| 2547 | 2552 | exists, err := s.runtime.VolumeExists(ctx, name) | |
| @@ -3126,6 +3131,9 @@ func (s *Service) resolveExistingAttachment(ctx context.Context, ownerDomain, se | |||
| 3126 | 3131 | containerName := fmt.Sprintf("gordon-%s-%s", sanitizeName(ownerDomain), serviceName) | |
| 3127 | 3132 | existingContainer := s.findContainerByName(ctx, containerName) | |
| 3128 | 3133 | if existingContainer != nil { | |
| 3134 | + | if err := validateAttachmentOwnership(existingContainer, ownerDomain, containerName); err != nil { | |
| 3135 | + | return nil, err | |
| 3136 | + | } | |
| 3129 | 3137 | return existingContainer, nil | |
| 3130 | 3138 | } | |
| 3131 | 3139 | ||
| @@ -3135,10 +3143,8 @@ func (s *Service) resolveExistingAttachment(ctx context.Context, ownerDomain, se | |||
| 3135 | 3143 | return nil, nil | |
| 3136 | 3144 | } | |
| 3137 | 3145 | ||
| 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) | |
| 3146 | + | if err := validateAttachmentOwnership(existingContainer, ownerDomain, containerNameLegacy); err != nil { | |
| 3147 | + | return nil, err | |
| 3142 | 3148 | } | |
| 3143 | 3149 | log.Info().Str("container_name", containerNameLegacy).Msg("found attachment with legacy naming, will be replaced") | |
| 3144 | 3150 | if err := s.runtime.StopContainer(ctx, existingContainer.ID); err != nil { | |
| @@ -3151,6 +3157,15 @@ func (s *Service) resolveExistingAttachment(ctx context.Context, ownerDomain, se | |||
| 3151 | 3157 | return nil, nil | |
| 3152 | 3158 | } | |
| 3153 | 3159 | ||
| 3160 | + | func validateAttachmentOwnership(container *domain.Container, ownerDomain, containerName string) error { | |
| 3161 | + | if container.Labels[domain.LabelManaged] != "true" || | |
| 3162 | + | container.Labels[domain.LabelAttachment] != "true" || | |
| 3163 | + | container.Labels[domain.LabelAttachedTo] != ownerDomain { | |
| 3164 | + | return fmt.Errorf("%w: attachment name %q is owned by another workload", domain.ErrAttachmentOwnershipMismatch, containerName) | |
| 3165 | + | } | |
| 3166 | + | return nil | |
| 3167 | + | } | |
| 3168 | + | ||
| 3154 | 3169 | // Utility functions | |
| 3155 | 3170 | ||
| 3156 | 3171 | func managedContainerName(domainName string) string { | |
| @@ -3211,6 +3226,25 @@ func legacyVolumeName(prefix, domainName, volumePath string) string { | |||
| 3211 | 3226 | strings.ReplaceAll(strings.Trim(volumePath, "/"), "/", "-")) | |
| 3212 | 3227 | } | |
| 3213 | 3228 | ||
| 3229 | + | func (s *Service) legacyVolumeOwnedByDomain(ctx context.Context, volumeName, domainName string) (bool, error) { | |
| 3230 | + | containers, err := s.runtime.ListContainers(ctx, true) | |
| 3231 | + | if err != nil { | |
| 3232 | + | return false, fmt.Errorf("list containers for legacy volume ownership: %w", err) | |
| 3233 | + | } | |
| 3234 | + | for _, container := range containers { | |
| 3235 | + | if container.Labels[domain.LabelManaged] != "true" || | |
| 3236 | + | (container.Labels[domain.LabelRoute] != domainName && container.Labels[domain.LabelDomain] != domainName) { | |
| 3237 | + | continue | |
| 3238 | + | } | |
| 3239 | + | for _, mount := range container.VolumeMounts { | |
| 3240 | + | if mount.Name == volumeName || mount.Source == volumeName { | |
| 3241 | + | return true, nil | |
| 3242 | + | } | |
| 3243 | + | } | |
| 3244 | + | } | |
| 3245 | + | return false, nil | |
| 3246 | + | } | |
| 3247 | + | ||
| 3214 | 3248 | func mergeEnvironmentVariables(dockerfileEnv, userEnv []string) []string { | |
| 3215 | 3249 | envMap := make(map[string]string) | |
| 3216 | 3250 | ||
| @@ -2540,8 +2540,13 @@ func (s *Service) setupVolumes(ctx context.Context, domainName, imageRef string) | |||||
| 2540 | continue | 2540 | continue | ||
| 2541 | } | 2541 | } | ||
| 2542 | if legacyExists { | 2542 | if legacyExists { | ||
| 2543 | - | volumes[path] = legacyName | 2543 | + | owned, ownershipErr := s.legacyVolumeOwnedByDomain(ctx, legacyName, domainName) |
| 2544 | - | continue | 2544 | + | if ownershipErr != nil { |
| 2545 | + | log.Warn().Err(ownershipErr).Str("volume", legacyName).Msg("could not verify legacy volume ownership; using stable volume name") | |||
| 2546 | + | } else if owned { | |||
| 2547 | + | volumes[path] = legacyName | |||
| 2548 | + | continue | |||
| 2549 | + | } | |||
| 2545 | } | 2550 | } | ||
| 2546 | 2551 | ||||
| 2547 | exists, err := s.runtime.VolumeExists(ctx, name) | 2552 | exists, err := s.runtime.VolumeExists(ctx, name) | ||
| @@ -3126,6 +3131,9 @@ func (s *Service) resolveExistingAttachment(ctx context.Context, ownerDomain, se | |||||
| 3126 | containerName := fmt.Sprintf("gordon-%s-%s", sanitizeName(ownerDomain), serviceName) | 3131 | containerName := fmt.Sprintf("gordon-%s-%s", sanitizeName(ownerDomain), serviceName) | ||
| 3127 | existingContainer := s.findContainerByName(ctx, containerName) | 3132 | existingContainer := s.findContainerByName(ctx, containerName) | ||
| 3128 | if existingContainer != nil { | 3133 | if existingContainer != nil { | ||
| 3134 | + | if err := validateAttachmentOwnership(existingContainer, ownerDomain, containerName); err != nil { | |||
| 3135 | + | return nil, err | |||
| 3136 | + | } | |||
| 3129 | return existingContainer, nil | 3137 | return existingContainer, nil | ||
| 3130 | } | 3138 | } | ||
| 3131 | 3139 | ||||
| @@ -3135,10 +3143,8 @@ func (s *Service) resolveExistingAttachment(ctx context.Context, ownerDomain, se | |||||
| 3135 | return nil, nil | 3143 | return nil, nil | ||
| 3136 | } | 3144 | } | ||
| 3137 | 3145 | ||||
| 3138 | - | if existingContainer.Labels[domain.LabelManaged] != "true" || | 3146 | + | if err := validateAttachmentOwnership(existingContainer, ownerDomain, containerNameLegacy); err != nil { |
| 3139 | - | existingContainer.Labels[domain.LabelAttachment] != "true" || | 3147 | + | return nil, err |
| 3140 | - | existingContainer.Labels[domain.LabelAttachedTo] != ownerDomain { | |||
| 3141 | - | return nil, fmt.Errorf("legacy attachment name %q is owned by another workload", containerNameLegacy) | |||
| 3142 | } | 3148 | } | ||
| 3143 | log.Info().Str("container_name", containerNameLegacy).Msg("found attachment with legacy naming, will be replaced") | 3149 | log.Info().Str("container_name", containerNameLegacy).Msg("found attachment with legacy naming, will be replaced") | ||
| 3144 | if err := s.runtime.StopContainer(ctx, existingContainer.ID); err != nil { | 3150 | if err := s.runtime.StopContainer(ctx, existingContainer.ID); err != nil { | ||
| @@ -3151,6 +3157,15 @@ func (s *Service) resolveExistingAttachment(ctx context.Context, ownerDomain, se | |||||
| 3151 | return nil, nil | 3157 | return nil, nil | ||
| 3152 | } | 3158 | } | ||
| 3153 | 3159 | ||||
| 3160 | + | func validateAttachmentOwnership(container *domain.Container, ownerDomain, containerName string) error { | |||
| 3161 | + | if container.Labels[domain.LabelManaged] != "true" || | |||
| 3162 | + | container.Labels[domain.LabelAttachment] != "true" || | |||
| 3163 | + | container.Labels[domain.LabelAttachedTo] != ownerDomain { | |||
| 3164 | + | return fmt.Errorf("%w: attachment name %q is owned by another workload", domain.ErrAttachmentOwnershipMismatch, containerName) | |||
| 3165 | + | } | |||
| 3166 | + | return nil | |||
| 3167 | + | } | |||
| 3168 | + | ||||
| 3154 | // Utility functions | 3169 | // Utility functions | ||
| 3155 | 3170 | ||||
| 3156 | func managedContainerName(domainName string) string { | 3171 | func managedContainerName(domainName string) string { | ||
| @@ -3211,6 +3226,25 @@ func legacyVolumeName(prefix, domainName, volumePath string) string { | |||||
| 3211 | strings.ReplaceAll(strings.Trim(volumePath, "/"), "/", "-")) | 3226 | strings.ReplaceAll(strings.Trim(volumePath, "/"), "/", "-")) | ||
| 3212 | } | 3227 | } | ||
| 3213 | 3228 | ||||
| 3229 | + | func (s *Service) legacyVolumeOwnedByDomain(ctx context.Context, volumeName, domainName string) (bool, error) { | |||
| 3230 | + | containers, err := s.runtime.ListContainers(ctx, true) | |||
| 3231 | + | if err != nil { | |||
| 3232 | + | return false, fmt.Errorf("list containers for legacy volume ownership: %w", err) | |||
| 3233 | + | } | |||
| 3234 | + | for _, container := range containers { | |||
| 3235 | + | if container.Labels[domain.LabelManaged] != "true" || | |||
| 3236 | + | (container.Labels[domain.LabelRoute] != domainName && container.Labels[domain.LabelDomain] != domainName) { | |||
| 3237 | + | continue | |||
| 3238 | + | } | |||
| 3239 | + | for _, mount := range container.VolumeMounts { | |||
| 3240 | + | if mount.Name == volumeName || mount.Source == volumeName { | |||
| 3241 | + | return true, nil | |||
| 3242 | + | } | |||
| 3243 | + | } | |||
| 3244 | + | } | |||
| 3245 | + | return false, nil | |||
| 3246 | + | } | |||
| 3247 | + | ||||
| 3214 | func mergeEnvironmentVariables(dockerfileEnv, userEnv []string) []string { | 3248 | func mergeEnvironmentVariables(dockerfileEnv, userEnv []string) []string { | ||
| 3215 | envMap := make(map[string]string) | 3249 | envMap := make(map[string]string) | ||
| 3216 | 3250 | ||||
M internal/usecase/container/service_test.go +37 -0
| @@ -1794,6 +1794,10 @@ func TestService_SetupVolumes_ReusesLegacyVolume(t *testing.T) { | |||
| 1794 | 1794 | ||
| 1795 | 1795 | runtime.EXPECT().InspectImageVolumes(mock.Anything, "myapp:latest").Return([]string{"/data"}, nil) | |
| 1796 | 1796 | runtime.EXPECT().VolumeExists(mock.Anything, legacyName).Return(true, nil) | |
| 1797 | + | runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{{ | |
| 1798 | + | Labels: map[string]string{domain.LabelManaged: "true", domain.LabelRoute: "app.example.com"}, | |
| 1799 | + | VolumeMounts: []domain.ContainerVolumeMount{{Name: legacyName}}, | |
| 1800 | + | }}, nil) | |
| 1797 | 1801 | ||
| 1798 | 1802 | volumes, err := svc.setupVolumes(testContext(), "app.example.com", "myapp:latest") | |
| 1799 | 1803 | ||
| @@ -1801,6 +1805,39 @@ func TestService_SetupVolumes_ReusesLegacyVolume(t *testing.T) { | |||
| 1801 | 1805 | assert.Equal(t, map[string]string{"/data": legacyName}, volumes) | |
| 1802 | 1806 | } | |
| 1803 | 1807 | ||
| 1808 | + | func TestService_SetupVolumes_DoesNotReuseCollidingLegacyVolume(t *testing.T) { | |
| 1809 | + | runtime := mocks.NewMockContainerRuntime(t) | |
| 1810 | + | svc := NewService(runtime, nil, nil, nil, Config{VolumeAutoCreate: true, VolumePrefix: "gordon"}, nil) | |
| 1811 | + | legacyName := legacyVolumeName("gordon", "app.example.com", "/data") | |
| 1812 | + | stableName := generateVolumeName("gordon", "app.example.com", "/data") | |
| 1813 | + | require.Equal(t, legacyName, legacyVolumeName("gordon", "app-example.com", "/data")) | |
| 1814 | + | ||
| 1815 | + | runtime.EXPECT().InspectImageVolumes(mock.Anything, "myapp:latest").Return([]string{"/data"}, nil) | |
| 1816 | + | runtime.EXPECT().VolumeExists(mock.Anything, legacyName).Return(true, nil) | |
| 1817 | + | runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{{ | |
| 1818 | + | Labels: map[string]string{domain.LabelManaged: "true", domain.LabelRoute: "app-example.com"}, | |
| 1819 | + | VolumeMounts: []domain.ContainerVolumeMount{{Name: legacyName}}, | |
| 1820 | + | }}, nil) | |
| 1821 | + | runtime.EXPECT().VolumeExists(mock.Anything, stableName).Return(true, nil) | |
| 1822 | + | ||
| 1823 | + | volumes, err := svc.setupVolumes(testContext(), "app.example.com", "myapp:latest") | |
| 1824 | + | ||
| 1825 | + | require.NoError(t, err) | |
| 1826 | + | assert.Equal(t, map[string]string{"/data": stableName}, volumes) | |
| 1827 | + | } | |
| 1828 | + | ||
| 1829 | + | func TestValidateAttachmentOwnership(t *testing.T) { | |
| 1830 | + | owned := &domain.Container{Labels: map[string]string{ | |
| 1831 | + | domain.LabelManaged: "true", domain.LabelAttachment: "true", domain.LabelAttachedTo: "app.example.com", | |
| 1832 | + | }} | |
| 1833 | + | require.NoError(t, validateAttachmentOwnership(owned, "app.example.com", "gordon-app-postgres")) | |
| 1834 | + | ||
| 1835 | + | foreign := &domain.Container{Labels: map[string]string{ | |
| 1836 | + | domain.LabelManaged: "true", domain.LabelAttachment: "true", domain.LabelAttachedTo: "other.example.com", | |
| 1837 | + | }} | |
| 1838 | + | assert.ErrorIs(t, validateAttachmentOwnership(foreign, "app.example.com", "gordon-app-postgres"), domain.ErrAttachmentOwnershipMismatch) | |
| 1839 | + | } | |
| 1840 | + | ||
| 1804 | 1841 | func TestMergeEnvironmentVariables(t *testing.T) { | |
| 1805 | 1842 | dockerEnv := []string{"FOO=docker", "BAR=docker"} | |
| 1806 | 1843 | userEnv := []string{"FOO=user", "BAZ=user"} | |
| @@ -1794,6 +1794,10 @@ func TestService_SetupVolumes_ReusesLegacyVolume(t *testing.T) { | |||||
| 1794 | 1794 | ||||
| 1795 | runtime.EXPECT().InspectImageVolumes(mock.Anything, "myapp:latest").Return([]string{"/data"}, nil) | 1795 | runtime.EXPECT().InspectImageVolumes(mock.Anything, "myapp:latest").Return([]string{"/data"}, nil) | ||
| 1796 | runtime.EXPECT().VolumeExists(mock.Anything, legacyName).Return(true, nil) | 1796 | runtime.EXPECT().VolumeExists(mock.Anything, legacyName).Return(true, nil) | ||
| 1797 | + | runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{{ | |||
| 1798 | + | Labels: map[string]string{domain.LabelManaged: "true", domain.LabelRoute: "app.example.com"}, | |||
| 1799 | + | VolumeMounts: []domain.ContainerVolumeMount{{Name: legacyName}}, | |||
| 1800 | + | }}, nil) | |||
| 1797 | 1801 | ||||
| 1798 | volumes, err := svc.setupVolumes(testContext(), "app.example.com", "myapp:latest") | 1802 | volumes, err := svc.setupVolumes(testContext(), "app.example.com", "myapp:latest") | ||
| 1799 | 1803 | ||||
| @@ -1801,6 +1805,39 @@ func TestService_SetupVolumes_ReusesLegacyVolume(t *testing.T) { | |||||
| 1801 | assert.Equal(t, map[string]string{"/data": legacyName}, volumes) | 1805 | assert.Equal(t, map[string]string{"/data": legacyName}, volumes) | ||
| 1802 | } | 1806 | } | ||
| 1803 | 1807 | ||||
| 1808 | + | func TestService_SetupVolumes_DoesNotReuseCollidingLegacyVolume(t *testing.T) { | |||
| 1809 | + | runtime := mocks.NewMockContainerRuntime(t) | |||
| 1810 | + | svc := NewService(runtime, nil, nil, nil, Config{VolumeAutoCreate: true, VolumePrefix: "gordon"}, nil) | |||
| 1811 | + | legacyName := legacyVolumeName("gordon", "app.example.com", "/data") | |||
| 1812 | + | stableName := generateVolumeName("gordon", "app.example.com", "/data") | |||
| 1813 | + | require.Equal(t, legacyName, legacyVolumeName("gordon", "app-example.com", "/data")) | |||
| 1814 | + | ||||
| 1815 | + | runtime.EXPECT().InspectImageVolumes(mock.Anything, "myapp:latest").Return([]string{"/data"}, nil) | |||
| 1816 | + | runtime.EXPECT().VolumeExists(mock.Anything, legacyName).Return(true, nil) | |||
| 1817 | + | runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{{ | |||
| 1818 | + | Labels: map[string]string{domain.LabelManaged: "true", domain.LabelRoute: "app-example.com"}, | |||
| 1819 | + | VolumeMounts: []domain.ContainerVolumeMount{{Name: legacyName}}, | |||
| 1820 | + | }}, nil) | |||
| 1821 | + | runtime.EXPECT().VolumeExists(mock.Anything, stableName).Return(true, nil) | |||
| 1822 | + | ||||
| 1823 | + | volumes, err := svc.setupVolumes(testContext(), "app.example.com", "myapp:latest") | |||
| 1824 | + | ||||
| 1825 | + | require.NoError(t, err) | |||
| 1826 | + | assert.Equal(t, map[string]string{"/data": stableName}, volumes) | |||
| 1827 | + | } | |||
| 1828 | + | ||||
| 1829 | + | func TestValidateAttachmentOwnership(t *testing.T) { | |||
| 1830 | + | owned := &domain.Container{Labels: map[string]string{ | |||
| 1831 | + | domain.LabelManaged: "true", domain.LabelAttachment: "true", domain.LabelAttachedTo: "app.example.com", | |||
| 1832 | + | }} | |||
| 1833 | + | require.NoError(t, validateAttachmentOwnership(owned, "app.example.com", "gordon-app-postgres")) | |||
| 1834 | + | ||||
| 1835 | + | foreign := &domain.Container{Labels: map[string]string{ | |||
| 1836 | + | domain.LabelManaged: "true", domain.LabelAttachment: "true", domain.LabelAttachedTo: "other.example.com", | |||
| 1837 | + | }} | |||
| 1838 | + | assert.ErrorIs(t, validateAttachmentOwnership(foreign, "app.example.com", "gordon-app-postgres"), domain.ErrAttachmentOwnershipMismatch) | |||
| 1839 | + | } | |||
| 1840 | + | ||||
| 1804 | func TestMergeEnvironmentVariables(t *testing.T) { | 1841 | func TestMergeEnvironmentVariables(t *testing.T) { | ||
| 1805 | dockerEnv := []string{"FOO=docker", "BAR=docker"} | 1842 | dockerEnv := []string{"FOO=docker", "BAR=docker"} | ||
| 1806 | userEnv := []string{"FOO=user", "BAZ=user"} | 1843 | userEnv := []string{"FOO=user", "BAZ=user"} | ||
M internal/usecase/images/service.go +12 -5
| @@ -14,6 +14,7 @@ import ( | |||
| 14 | 14 | ||
| 15 | 15 | "github.com/bnema/gordon/internal/boundaries/out" | |
| 16 | 16 | "github.com/bnema/gordon/internal/domain" | |
| 17 | + | "github.com/bnema/gordon/internal/usecase/registrystate" | |
| 17 | 18 | "github.com/bnema/gordon/pkg/runtime" | |
| 18 | 19 | "github.com/bnema/gordon/pkg/validation" | |
| 19 | 20 | ) | |
| @@ -25,6 +26,7 @@ type Service struct { | |||
| 25 | 26 | blobStorage out.BlobStorage | |
| 26 | 27 | log zerowrap.Logger | |
| 27 | 28 | mutationMu *sync.RWMutex | |
| 29 | + | registryState *registrystate.State | |
| 28 | 30 | } | |
| 29 | 31 | ||
| 30 | 32 | type imageRuntime interface { | |
| @@ -38,18 +40,19 @@ func NewService( | |||
| 38 | 40 | manifestStorage out.ManifestStorage, | |
| 39 | 41 | blobStorage out.BlobStorage, | |
| 40 | 42 | log zerowrap.Logger, | |
| 41 | - | mutationLocks ...*sync.RWMutex, | |
| 43 | + | states ...*registrystate.State, | |
| 42 | 44 | ) *Service { | |
| 43 | - | mutationMu := &sync.RWMutex{} | |
| 44 | - | if len(mutationLocks) > 0 && mutationLocks[0] != nil { | |
| 45 | - | mutationMu = mutationLocks[0] | |
| 45 | + | registryState := registrystate.New() | |
| 46 | + | if len(states) > 0 && states[0] != nil { | |
| 47 | + | registryState = states[0] | |
| 46 | 48 | } | |
| 47 | 49 | return &Service{ | |
| 48 | 50 | runtime: rt, | |
| 49 | 51 | manifestStorage: manifestStorage, | |
| 50 | 52 | blobStorage: blobStorage, | |
| 51 | 53 | log: log, | |
| 52 | - | mutationMu: mutationMu, | |
| 54 | + | mutationMu: ®istryState.MutationMu, | |
| 55 | + | registryState: registryState, | |
| 53 | 56 | } | |
| 54 | 57 | } | |
| 55 | 58 | ||
| @@ -245,6 +248,10 @@ func (s *Service) PruneRegistry(ctx context.Context, keepLast int) (domain.Image | |||
| 245 | 248 | } | |
| 246 | 249 | } | |
| 247 | 250 | ||
| 251 | + | for digest := range s.registryState.PendingDigests(time.Now().UTC()) { | |
| 252 | + | referencedDigests[digest] = struct{}{} | |
| 253 | + | } | |
| 254 | + | ||
| 248 | 255 | blobs, err := s.blobStorage.ListBlobs() | |
| 249 | 256 | if err != nil { | |
| 250 | 257 | return domain.ImagePruneReport{}, log.WrapErr(err, "failed to list blobs") | |
| @@ -14,6 +14,7 @@ import ( | |||||
| 14 | 14 | ||||
| 15 | "github.com/bnema/gordon/internal/boundaries/out" | 15 | "github.com/bnema/gordon/internal/boundaries/out" | ||
| 16 | "github.com/bnema/gordon/internal/domain" | 16 | "github.com/bnema/gordon/internal/domain" | ||
| 17 | + | "github.com/bnema/gordon/internal/usecase/registrystate" | |||
| 17 | "github.com/bnema/gordon/pkg/runtime" | 18 | "github.com/bnema/gordon/pkg/runtime" | ||
| 18 | "github.com/bnema/gordon/pkg/validation" | 19 | "github.com/bnema/gordon/pkg/validation" | ||
| 19 | ) | 20 | ) | ||
| @@ -25,6 +26,7 @@ type Service struct { | |||||
| 25 | blobStorage out.BlobStorage | 26 | blobStorage out.BlobStorage | ||
| 26 | log zerowrap.Logger | 27 | log zerowrap.Logger | ||
| 27 | mutationMu *sync.RWMutex | 28 | mutationMu *sync.RWMutex | ||
| 29 | + | registryState *registrystate.State | |||
| 28 | } | 30 | } | ||
| 29 | 31 | ||||
| 30 | type imageRuntime interface { | 32 | type imageRuntime interface { | ||
| @@ -38,18 +40,19 @@ func NewService( | |||||
| 38 | manifestStorage out.ManifestStorage, | 40 | manifestStorage out.ManifestStorage, | ||
| 39 | blobStorage out.BlobStorage, | 41 | blobStorage out.BlobStorage, | ||
| 40 | log zerowrap.Logger, | 42 | log zerowrap.Logger, | ||
| 41 | - | mutationLocks ...*sync.RWMutex, | 43 | + | states ...*registrystate.State, |
| 42 | ) *Service { | 44 | ) *Service { | ||
| 43 | - | mutationMu := &sync.RWMutex{} | 45 | + | registryState := registrystate.New() |
| 44 | - | if len(mutationLocks) > 0 && mutationLocks[0] != nil { | 46 | + | if len(states) > 0 && states[0] != nil { |
| 45 | - | mutationMu = mutationLocks[0] | 47 | + | registryState = states[0] |
| 46 | } | 48 | } | ||
| 47 | return &Service{ | 49 | return &Service{ | ||
| 48 | runtime: rt, | 50 | runtime: rt, | ||
| 49 | manifestStorage: manifestStorage, | 51 | manifestStorage: manifestStorage, | ||
| 50 | blobStorage: blobStorage, | 52 | blobStorage: blobStorage, | ||
| 51 | log: log, | 53 | log: log, | ||
| 52 | - | mutationMu: mutationMu, | 54 | + | mutationMu: ®istryState.MutationMu, |
| 55 | + | registryState: registryState, | |||
| 53 | } | 56 | } | ||
| 54 | } | 57 | } | ||
| 55 | 58 | ||||
| @@ -245,6 +248,10 @@ func (s *Service) PruneRegistry(ctx context.Context, keepLast int) (domain.Image | |||||
| 245 | } | 248 | } | ||
| 246 | } | 249 | } | ||
| 247 | 250 | ||||
| 251 | + | for digest := range s.registryState.PendingDigests(time.Now().UTC()) { | |||
| 252 | + | referencedDigests[digest] = struct{}{} | |||
| 253 | + | } | |||
| 254 | + | ||||
| 248 | blobs, err := s.blobStorage.ListBlobs() | 255 | blobs, err := s.blobStorage.ListBlobs() | ||
| 249 | if err != nil { | 256 | if err != nil { | ||
| 250 | return domain.ImagePruneReport{}, log.WrapErr(err, "failed to list blobs") | 257 | return domain.ImagePruneReport{}, log.WrapErr(err, "failed to list blobs") | ||
M internal/usecase/images/service_test.go +19 -0
| @@ -13,6 +13,7 @@ import ( | |||
| 13 | 13 | ||
| 14 | 14 | "github.com/bnema/gordon/internal/boundaries/out" | |
| 15 | 15 | "github.com/bnema/gordon/internal/domain" | |
| 16 | + | "github.com/bnema/gordon/internal/usecase/registrystate" | |
| 16 | 17 | pkgruntime "github.com/bnema/gordon/pkg/runtime" | |
| 17 | 18 | ) | |
| 18 | 19 | ||
| @@ -332,6 +333,24 @@ func TestService_PruneRegistry_GarbageCollectsUnreferencedBlobs(t *testing.T) { | |||
| 332 | 333 | assert.Equal(t, []string{"sha256:orphan"}, blobStorage.deletedBlobs) | |
| 333 | 334 | } | |
| 334 | 335 | ||
| 336 | + | func TestService_PruneRegistry_PreservesPendingBlob(t *testing.T) { | |
| 337 | + | manifestStorage := newFakeManifestStorage() | |
| 338 | + | manifestStorage.repositories = []string{"gordon/api"} | |
| 339 | + | manifestStorage.tagsByRepo["gordon/api"] = []string{"latest"} | |
| 340 | + | manifestStorage.modTimes[manifestRefKey("gordon/api", "latest")] = time.Now().UTC() | |
| 341 | + | manifestStorage.manifests[manifestRefKey("gordon/api", "latest")] = mustManifestJSON(t, "sha256:cfg-live", "sha256:layer-live") | |
| 342 | + | blobStorage := &fakeBlobStorage{blobs: []string{"sha256:pending"}} | |
| 343 | + | state := registrystate.New() | |
| 344 | + | state.AddPending("sha256:pending", time.Now().UTC()) | |
| 345 | + | svc := NewService(&fakeRuntime{}, manifestStorage, blobStorage, zerowrap.Default(), state) | |
| 346 | + | ||
| 347 | + | report, err := svc.PruneRegistry(context.Background(), 1) | |
| 348 | + | ||
| 349 | + | require.NoError(t, err) | |
| 350 | + | assert.Zero(t, report.Registry.BlobsRemoved) | |
| 351 | + | assert.Empty(t, blobStorage.deletedBlobs) | |
| 352 | + | } | |
| 353 | + | ||
| 335 | 354 | func TestService_PruneRegistry_PreservesSharedBlobs(t *testing.T) { | |
| 336 | 355 | manifestStorage := newFakeManifestStorage() | |
| 337 | 356 | manifestStorage.repositories = []string{"gordon/api"} | |
| @@ -13,6 +13,7 @@ import ( | |||||
| 13 | 13 | ||||
| 14 | "github.com/bnema/gordon/internal/boundaries/out" | 14 | "github.com/bnema/gordon/internal/boundaries/out" | ||
| 15 | "github.com/bnema/gordon/internal/domain" | 15 | "github.com/bnema/gordon/internal/domain" | ||
| 16 | + | "github.com/bnema/gordon/internal/usecase/registrystate" | |||
| 16 | pkgruntime "github.com/bnema/gordon/pkg/runtime" | 17 | pkgruntime "github.com/bnema/gordon/pkg/runtime" | ||
| 17 | ) | 18 | ) | ||
| 18 | 19 | ||||
| @@ -332,6 +333,24 @@ func TestService_PruneRegistry_GarbageCollectsUnreferencedBlobs(t *testing.T) { | |||||
| 332 | assert.Equal(t, []string{"sha256:orphan"}, blobStorage.deletedBlobs) | 333 | assert.Equal(t, []string{"sha256:orphan"}, blobStorage.deletedBlobs) | ||
| 333 | } | 334 | } | ||
| 334 | 335 | ||||
| 336 | + | func TestService_PruneRegistry_PreservesPendingBlob(t *testing.T) { | |||
| 337 | + | manifestStorage := newFakeManifestStorage() | |||
| 338 | + | manifestStorage.repositories = []string{"gordon/api"} | |||
| 339 | + | manifestStorage.tagsByRepo["gordon/api"] = []string{"latest"} | |||
| 340 | + | manifestStorage.modTimes[manifestRefKey("gordon/api", "latest")] = time.Now().UTC() | |||
| 341 | + | manifestStorage.manifests[manifestRefKey("gordon/api", "latest")] = mustManifestJSON(t, "sha256:cfg-live", "sha256:layer-live") | |||
| 342 | + | blobStorage := &fakeBlobStorage{blobs: []string{"sha256:pending"}} | |||
| 343 | + | state := registrystate.New() | |||
| 344 | + | state.AddPending("sha256:pending", time.Now().UTC()) | |||
| 345 | + | svc := NewService(&fakeRuntime{}, manifestStorage, blobStorage, zerowrap.Default(), state) | |||
| 346 | + | ||||
| 347 | + | report, err := svc.PruneRegistry(context.Background(), 1) | |||
| 348 | + | ||||
| 349 | + | require.NoError(t, err) | |||
| 350 | + | assert.Zero(t, report.Registry.BlobsRemoved) | |||
| 351 | + | assert.Empty(t, blobStorage.deletedBlobs) | |||
| 352 | + | } | |||
| 353 | + | ||||
| 335 | func TestService_PruneRegistry_PreservesSharedBlobs(t *testing.T) { | 354 | func TestService_PruneRegistry_PreservesSharedBlobs(t *testing.T) { | ||
| 336 | manifestStorage := newFakeManifestStorage() | 355 | manifestStorage := newFakeManifestStorage() | ||
| 337 | manifestStorage.repositories = []string{"gordon/api"} | 356 | manifestStorage.repositories = []string{"gordon/api"} | ||
M internal/usecase/registry/service.go +65 -14
| @@ -22,6 +22,7 @@ import ( | |||
| 22 | 22 | "github.com/bnema/gordon/internal/adapters/out/telemetry" | |
| 23 | 23 | "github.com/bnema/gordon/internal/boundaries/out" | |
| 24 | 24 | "github.com/bnema/gordon/internal/domain" | |
| 25 | + | "github.com/bnema/gordon/internal/usecase/registrystate" | |
| 25 | 26 | "github.com/bnema/gordon/pkg/validation" | |
| 26 | 27 | ) | |
| 27 | 28 | ||
| @@ -35,6 +36,7 @@ type Service struct { | |||
| 35 | 36 | metrics *telemetry.Metrics | |
| 36 | 37 | suppressedImages sync.Map // imageName -> *time.Timer | |
| 37 | 38 | mutationMu *sync.RWMutex | |
| 39 | + | registryState *registrystate.State | |
| 38 | 40 | } | |
| 39 | 41 | ||
| 40 | 42 | // SetMetrics sets the telemetry metrics for the registry service. | |
| @@ -47,17 +49,18 @@ func NewService( | |||
| 47 | 49 | blobStorage out.BlobStorage, | |
| 48 | 50 | manifestStorage out.ManifestStorage, | |
| 49 | 51 | eventBus out.EventPublisher, | |
| 50 | - | mutationLocks ...*sync.RWMutex, | |
| 52 | + | states ...*registrystate.State, | |
| 51 | 53 | ) *Service { | |
| 52 | - | mutationMu := &sync.RWMutex{} | |
| 53 | - | if len(mutationLocks) > 0 && mutationLocks[0] != nil { | |
| 54 | - | mutationMu = mutationLocks[0] | |
| 54 | + | registryState := registrystate.New() | |
| 55 | + | if len(states) > 0 && states[0] != nil { | |
| 56 | + | registryState = states[0] | |
| 55 | 57 | } | |
| 56 | 58 | return &Service{ | |
| 57 | 59 | blobStorage: blobStorage, | |
| 58 | 60 | manifestStorage: manifestStorage, | |
| 59 | 61 | eventBus: eventBus, | |
| 60 | - | mutationMu: mutationMu, | |
| 62 | + | mutationMu: ®istryState.MutationMu, | |
| 63 | + | registryState: registryState, | |
| 61 | 64 | } | |
| 62 | 65 | } | |
| 63 | 66 | ||
| @@ -202,6 +205,7 @@ func (s *Service) PutManifest(ctx context.Context, manifest *domain.Manifest) (s | |||
| 202 | 205 | if err := s.manifestStorage.PutManifest(manifest.Name, manifest.Reference, manifest.ContentType, manifest.Data); err != nil { | |
| 203 | 206 | return "", log.WrapErr(err, "failed to store manifest") | |
| 204 | 207 | } | |
| 208 | + | s.registryState.MarkPublished(manifestReferencedDigests(manifest.Data)) | |
| 205 | 209 | ||
| 206 | 210 | // Record push metrics | |
| 207 | 211 | if s.metrics != nil { | |
| @@ -312,6 +316,8 @@ type manifestReferences struct { | |||
| 312 | 316 | Subject *manifestDescriptor `json:"subject"` | |
| 313 | 317 | } | |
| 314 | 318 | ||
| 319 | + | const maxManifestTraversal = 10000 | |
| 320 | + | ||
| 315 | 321 | func (s *Service) repositoryReferencesDigest(name, target string) (bool, error) { | |
| 316 | 322 | tags, err := s.manifestStorage.ListTags(name) | |
| 317 | 323 | if err != nil { | |
| @@ -321,6 +327,9 @@ func (s *Service) repositoryReferencesDigest(name, target string) (bool, error) | |||
| 321 | 327 | return false, fmt.Errorf("list tags for repository %s: %w", name, err) | |
| 322 | 328 | } | |
| 323 | 329 | ||
| 330 | + | if len(tags) > maxManifestTraversal { | |
| 331 | + | return false, fmt.Errorf("%w: repository %s exceeds manifest traversal limit", domain.ErrBlobNotFound, name) | |
| 332 | + | } | |
| 324 | 333 | queue := append([]string(nil), tags...) | |
| 325 | 334 | seen := make(map[string]struct{}, len(queue)) | |
| 326 | 335 | for len(queue) > 0 { | |
| @@ -330,6 +339,9 @@ func (s *Service) repositoryReferencesDigest(name, target string) (bool, error) | |||
| 330 | 339 | continue | |
| 331 | 340 | } | |
| 332 | 341 | seen[reference] = struct{}{} | |
| 342 | + | if len(seen) > maxManifestTraversal { | |
| 343 | + | return false, fmt.Errorf("%w: repository %s exceeds manifest traversal limit", domain.ErrBlobNotFound, name) | |
| 344 | + | } | |
| 333 | 345 | ||
| 334 | 346 | data, _, err := s.manifestStorage.GetManifest(name, reference) | |
| 335 | 347 | if err != nil { | |
| @@ -340,22 +352,60 @@ func (s *Service) repositoryReferencesDigest(name, target string) (bool, error) | |||
| 340 | 352 | return false, fmt.Errorf("decode manifest %s: %w", reference, err) | |
| 341 | 353 | } | |
| 342 | 354 | ||
| 343 | - | if refs.Config.Digest == target || descriptorListContains(refs.Layers, target) || | |
| 344 | - | descriptorListContains(refs.Blobs, target) || (refs.Subject != nil && refs.Subject.Digest == target) { | |
| 355 | + | if manifestReferencesTarget(refs, target) { | |
| 345 | 356 | return true, nil | |
| 346 | 357 | } | |
| 347 | - | for _, child := range refs.Manifests { | |
| 348 | - | if child.Digest == target { | |
| 349 | - | return true, nil | |
| 350 | - | } | |
| 351 | - | if child.Digest != "" { | |
| 352 | - | queue = append(queue, child.Digest) | |
| 353 | - | } | |
| 358 | + | nestedManifests := refs.Manifests | |
| 359 | + | if refs.Subject != nil && refs.Subject.Digest != "" { | |
| 360 | + | nestedManifests = append(append([]manifestDescriptor(nil), refs.Manifests...), *refs.Subject) | |
| 361 | + | } | |
| 362 | + | if len(seen)+len(queue)+len(nestedManifests) > maxManifestTraversal { | |
| 363 | + | return false, fmt.Errorf("%w: repository %s exceeds manifest traversal limit", domain.ErrBlobNotFound, name) | |
| 354 | 364 | } | |
| 365 | + | queue = appendManifestDigests(queue, nestedManifests) | |
| 355 | 366 | } | |
| 356 | 367 | return false, nil | |
| 357 | 368 | } | |
| 358 | 369 | ||
| 370 | + | func manifestReferencesTarget(refs manifestReferences, target string) bool { | |
| 371 | + | return refs.Config.Digest == target || | |
| 372 | + | descriptorListContains(refs.Layers, target) || | |
| 373 | + | descriptorListContains(refs.Manifests, target) || | |
| 374 | + | descriptorListContains(refs.Blobs, target) || | |
| 375 | + | (refs.Subject != nil && refs.Subject.Digest == target) | |
| 376 | + | } | |
| 377 | + | ||
| 378 | + | func appendManifestDigests(queue []string, descriptors []manifestDescriptor) []string { | |
| 379 | + | for _, descriptor := range descriptors { | |
| 380 | + | if descriptor.Digest != "" { | |
| 381 | + | queue = append(queue, descriptor.Digest) | |
| 382 | + | } | |
| 383 | + | } | |
| 384 | + | return queue | |
| 385 | + | } | |
| 386 | + | ||
| 387 | + | func manifestReferencedDigests(data []byte) []string { | |
| 388 | + | var refs manifestReferences | |
| 389 | + | if json.Unmarshal(data, &refs) != nil { | |
| 390 | + | return nil | |
| 391 | + | } | |
| 392 | + | digests := make([]string, 0, 1+len(refs.Layers)+len(refs.Manifests)+len(refs.Blobs)) | |
| 393 | + | if refs.Config.Digest != "" { | |
| 394 | + | digests = append(digests, refs.Config.Digest) | |
| 395 | + | } | |
| 396 | + | for _, descriptors := range [][]manifestDescriptor{refs.Layers, refs.Manifests, refs.Blobs} { | |
| 397 | + | for _, descriptor := range descriptors { | |
| 398 | + | if descriptor.Digest != "" { | |
| 399 | + | digests = append(digests, descriptor.Digest) | |
| 400 | + | } | |
| 401 | + | } | |
| 402 | + | } | |
| 403 | + | if refs.Subject != nil && refs.Subject.Digest != "" { | |
| 404 | + | digests = append(digests, refs.Subject.Digest) | |
| 405 | + | } | |
| 406 | + | return digests | |
| 407 | + | } | |
| 408 | + | ||
| 359 | 409 | func descriptorListContains(descriptors []manifestDescriptor, digest string) bool { | |
| 360 | 410 | for _, descriptor := range descriptors { | |
| 361 | 411 | if descriptor.Digest == digest { | |
| @@ -396,6 +446,7 @@ func (s *Service) PutBlob(ctx context.Context, digest string, data io.Reader, si | |||
| 396 | 446 | if err := s.blobStorage.PutBlob(digest, data, size); err != nil { | |
| 397 | 447 | return log.WrapErr(err, "failed to store blob") | |
| 398 | 448 | } | |
| 449 | + | s.registryState.AddPending(digest, time.Now().UTC()) | |
| 399 | 450 | ||
| 400 | 451 | log.Info().Msg("blob stored") | |
| 401 | 452 | return nil | |
| @@ -22,6 +22,7 @@ import ( | |||||
| 22 | "github.com/bnema/gordon/internal/adapters/out/telemetry" | 22 | "github.com/bnema/gordon/internal/adapters/out/telemetry" | ||
| 23 | "github.com/bnema/gordon/internal/boundaries/out" | 23 | "github.com/bnema/gordon/internal/boundaries/out" | ||
| 24 | "github.com/bnema/gordon/internal/domain" | 24 | "github.com/bnema/gordon/internal/domain" | ||
| 25 | + | "github.com/bnema/gordon/internal/usecase/registrystate" | |||
| 25 | "github.com/bnema/gordon/pkg/validation" | 26 | "github.com/bnema/gordon/pkg/validation" | ||
| 26 | ) | 27 | ) | ||
| 27 | 28 | ||||
| @@ -35,6 +36,7 @@ type Service struct { | |||||
| 35 | metrics *telemetry.Metrics | 36 | metrics *telemetry.Metrics | ||
| 36 | suppressedImages sync.Map // imageName -> *time.Timer | 37 | suppressedImages sync.Map // imageName -> *time.Timer | ||
| 37 | mutationMu *sync.RWMutex | 38 | mutationMu *sync.RWMutex | ||
| 39 | + | registryState *registrystate.State | |||
| 38 | } | 40 | } | ||
| 39 | 41 | ||||
| 40 | // SetMetrics sets the telemetry metrics for the registry service. | 42 | // SetMetrics sets the telemetry metrics for the registry service. | ||
| @@ -47,17 +49,18 @@ func NewService( | |||||
| 47 | blobStorage out.BlobStorage, | 49 | blobStorage out.BlobStorage, | ||
| 48 | manifestStorage out.ManifestStorage, | 50 | manifestStorage out.ManifestStorage, | ||
| 49 | eventBus out.EventPublisher, | 51 | eventBus out.EventPublisher, | ||
| 50 | - | mutationLocks ...*sync.RWMutex, | 52 | + | states ...*registrystate.State, |
| 51 | ) *Service { | 53 | ) *Service { | ||
| 52 | - | mutationMu := &sync.RWMutex{} | 54 | + | registryState := registrystate.New() |
| 53 | - | if len(mutationLocks) > 0 && mutationLocks[0] != nil { | 55 | + | if len(states) > 0 && states[0] != nil { |
| 54 | - | mutationMu = mutationLocks[0] | 56 | + | registryState = states[0] |
| 55 | } | 57 | } | ||
| 56 | return &Service{ | 58 | return &Service{ | ||
| 57 | blobStorage: blobStorage, | 59 | blobStorage: blobStorage, | ||
| 58 | manifestStorage: manifestStorage, | 60 | manifestStorage: manifestStorage, | ||
| 59 | eventBus: eventBus, | 61 | eventBus: eventBus, | ||
| 60 | - | mutationMu: mutationMu, | 62 | + | mutationMu: ®istryState.MutationMu, |
| 63 | + | registryState: registryState, | |||
| 61 | } | 64 | } | ||
| 62 | } | 65 | } | ||
| 63 | 66 | ||||
| @@ -202,6 +205,7 @@ func (s *Service) PutManifest(ctx context.Context, manifest *domain.Manifest) (s | |||||
| 202 | if err := s.manifestStorage.PutManifest(manifest.Name, manifest.Reference, manifest.ContentType, manifest.Data); err != nil { | 205 | if err := s.manifestStorage.PutManifest(manifest.Name, manifest.Reference, manifest.ContentType, manifest.Data); err != nil { | ||
| 203 | return "", log.WrapErr(err, "failed to store manifest") | 206 | return "", log.WrapErr(err, "failed to store manifest") | ||
| 204 | } | 207 | } | ||
| 208 | + | s.registryState.MarkPublished(manifestReferencedDigests(manifest.Data)) | |||
| 205 | 209 | ||||
| 206 | // Record push metrics | 210 | // Record push metrics | ||
| 207 | if s.metrics != nil { | 211 | if s.metrics != nil { | ||
| @@ -312,6 +316,8 @@ type manifestReferences struct { | |||||
| 312 | Subject *manifestDescriptor `json:"subject"` | 316 | Subject *manifestDescriptor `json:"subject"` | ||
| 313 | } | 317 | } | ||
| 314 | 318 | ||||
| 319 | + | const maxManifestTraversal = 10000 | |||
| 320 | + | ||||
| 315 | func (s *Service) repositoryReferencesDigest(name, target string) (bool, error) { | 321 | func (s *Service) repositoryReferencesDigest(name, target string) (bool, error) { | ||
| 316 | tags, err := s.manifestStorage.ListTags(name) | 322 | tags, err := s.manifestStorage.ListTags(name) | ||
| 317 | if err != nil { | 323 | if err != nil { | ||
| @@ -321,6 +327,9 @@ func (s *Service) repositoryReferencesDigest(name, target string) (bool, error) | |||||
| 321 | return false, fmt.Errorf("list tags for repository %s: %w", name, err) | 327 | return false, fmt.Errorf("list tags for repository %s: %w", name, err) | ||
| 322 | } | 328 | } | ||
| 323 | 329 | ||||
| 330 | + | if len(tags) > maxManifestTraversal { | |||
| 331 | + | return false, fmt.Errorf("%w: repository %s exceeds manifest traversal limit", domain.ErrBlobNotFound, name) | |||
| 332 | + | } | |||
| 324 | queue := append([]string(nil), tags...) | 333 | queue := append([]string(nil), tags...) | ||
| 325 | seen := make(map[string]struct{}, len(queue)) | 334 | seen := make(map[string]struct{}, len(queue)) | ||
| 326 | for len(queue) > 0 { | 335 | for len(queue) > 0 { | ||
| @@ -330,6 +339,9 @@ func (s *Service) repositoryReferencesDigest(name, target string) (bool, error) | |||||
| 330 | continue | 339 | continue | ||
| 331 | } | 340 | } | ||
| 332 | seen[reference] = struct{}{} | 341 | seen[reference] = struct{}{} | ||
| 342 | + | if len(seen) > maxManifestTraversal { | |||
| 343 | + | return false, fmt.Errorf("%w: repository %s exceeds manifest traversal limit", domain.ErrBlobNotFound, name) | |||
| 344 | + | } | |||
| 333 | 345 | ||||
| 334 | data, _, err := s.manifestStorage.GetManifest(name, reference) | 346 | data, _, err := s.manifestStorage.GetManifest(name, reference) | ||
| 335 | if err != nil { | 347 | if err != nil { | ||
| @@ -340,22 +352,60 @@ func (s *Service) repositoryReferencesDigest(name, target string) (bool, error) | |||||
| 340 | return false, fmt.Errorf("decode manifest %s: %w", reference, err) | 352 | return false, fmt.Errorf("decode manifest %s: %w", reference, err) | ||
| 341 | } | 353 | } | ||
| 342 | 354 | ||||
| 343 | - | if refs.Config.Digest == target || descriptorListContains(refs.Layers, target) || | 355 | + | if manifestReferencesTarget(refs, target) { |
| 344 | - | descriptorListContains(refs.Blobs, target) || (refs.Subject != nil && refs.Subject.Digest == target) { | |||
| 345 | return true, nil | 356 | return true, nil | ||
| 346 | } | 357 | } | ||
| 347 | - | for _, child := range refs.Manifests { | 358 | + | nestedManifests := refs.Manifests |
| 348 | - | if child.Digest == target { | 359 | + | if refs.Subject != nil && refs.Subject.Digest != "" { |
| 349 | - | return true, nil | 360 | + | nestedManifests = append(append([]manifestDescriptor(nil), refs.Manifests...), *refs.Subject) |
| 350 | - | } | 361 | + | } |
| 351 | - | if child.Digest != "" { | 362 | + | if len(seen)+len(queue)+len(nestedManifests) > maxManifestTraversal { |
| 352 | - | queue = append(queue, child.Digest) | 363 | + | return false, fmt.Errorf("%w: repository %s exceeds manifest traversal limit", domain.ErrBlobNotFound, name) |
| 353 | - | } | |||
| 354 | } | 364 | } | ||
| 365 | + | queue = appendManifestDigests(queue, nestedManifests) | |||
| 355 | } | 366 | } | ||
| 356 | return false, nil | 367 | return false, nil | ||
| 357 | } | 368 | } | ||
| 358 | 369 | ||||
| 370 | + | func manifestReferencesTarget(refs manifestReferences, target string) bool { | |||
| 371 | + | return refs.Config.Digest == target || | |||
| 372 | + | descriptorListContains(refs.Layers, target) || | |||
| 373 | + | descriptorListContains(refs.Manifests, target) || | |||
| 374 | + | descriptorListContains(refs.Blobs, target) || | |||
| 375 | + | (refs.Subject != nil && refs.Subject.Digest == target) | |||
| 376 | + | } | |||
| 377 | + | ||||
| 378 | + | func appendManifestDigests(queue []string, descriptors []manifestDescriptor) []string { | |||
| 379 | + | for _, descriptor := range descriptors { | |||
| 380 | + | if descriptor.Digest != "" { | |||
| 381 | + | queue = append(queue, descriptor.Digest) | |||
| 382 | + | } | |||
| 383 | + | } | |||
| 384 | + | return queue | |||
| 385 | + | } | |||
| 386 | + | ||||
| 387 | + | func manifestReferencedDigests(data []byte) []string { | |||
| 388 | + | var refs manifestReferences | |||
| 389 | + | if json.Unmarshal(data, &refs) != nil { | |||
| 390 | + | return nil | |||
| 391 | + | } | |||
| 392 | + | digests := make([]string, 0, 1+len(refs.Layers)+len(refs.Manifests)+len(refs.Blobs)) | |||
| 393 | + | if refs.Config.Digest != "" { | |||
| 394 | + | digests = append(digests, refs.Config.Digest) | |||
| 395 | + | } | |||
| 396 | + | for _, descriptors := range [][]manifestDescriptor{refs.Layers, refs.Manifests, refs.Blobs} { | |||
| 397 | + | for _, descriptor := range descriptors { | |||
| 398 | + | if descriptor.Digest != "" { | |||
| 399 | + | digests = append(digests, descriptor.Digest) | |||
| 400 | + | } | |||
| 401 | + | } | |||
| 402 | + | } | |||
| 403 | + | if refs.Subject != nil && refs.Subject.Digest != "" { | |||
| 404 | + | digests = append(digests, refs.Subject.Digest) | |||
| 405 | + | } | |||
| 406 | + | return digests | |||
| 407 | + | } | |||
| 408 | + | ||||
| 359 | func descriptorListContains(descriptors []manifestDescriptor, digest string) bool { | 409 | func descriptorListContains(descriptors []manifestDescriptor, digest string) bool { | ||
| 360 | for _, descriptor := range descriptors { | 410 | for _, descriptor := range descriptors { | ||
| 361 | if descriptor.Digest == digest { | 411 | if descriptor.Digest == digest { | ||
| @@ -396,6 +446,7 @@ func (s *Service) PutBlob(ctx context.Context, digest string, data io.Reader, si | |||||
| 396 | if err := s.blobStorage.PutBlob(digest, data, size); err != nil { | 446 | if err := s.blobStorage.PutBlob(digest, data, size); err != nil { | ||
| 397 | return log.WrapErr(err, "failed to store blob") | 447 | return log.WrapErr(err, "failed to store blob") | ||
| 398 | } | 448 | } | ||
| 449 | + | s.registryState.AddPending(digest, time.Now().UTC()) | |||
| 399 | 450 | ||||
| 400 | log.Info().Msg("blob stored") | 451 | log.Info().Msg("blob stored") | ||
| 401 | return nil | 452 | return nil | ||
M internal/usecase/registry/service_test.go +40 -2
| @@ -9,6 +9,7 @@ import ( | |||
| 9 | 9 | "io" | |
| 10 | 10 | "strings" | |
| 11 | 11 | "testing" | |
| 12 | + | "time" | |
| 12 | 13 | ||
| 13 | 14 | "github.com/bnema/zerowrap" | |
| 14 | 15 | "github.com/stretchr/testify/assert" | |
| @@ -17,6 +18,7 @@ import ( | |||
| 17 | 18 | ||
| 18 | 19 | "github.com/bnema/gordon/internal/boundaries/out/mocks" | |
| 19 | 20 | "github.com/bnema/gordon/internal/domain" | |
| 21 | + | "github.com/bnema/gordon/internal/usecase/registrystate" | |
| 20 | 22 | ) | |
| 21 | 23 | ||
| 22 | 24 | func testContext() context.Context { | |
| @@ -90,8 +92,10 @@ func TestService_PutManifest_SHA512DigestDoesNotPublishEvent(t *testing.T) { | |||
| 90 | 92 | blobStorage := mocks.NewMockBlobStorage(t) | |
| 91 | 93 | manifestStorage := mocks.NewMockManifestStorage(t) | |
| 92 | 94 | eventBus := mocks.NewMockEventPublisher(t) | |
| 93 | - | svc := NewService(blobStorage, manifestStorage, eventBus) | |
| 94 | - | data := []byte(`{"schemaVersion":2}`) | |
| 95 | + | state := registrystate.New() | |
| 96 | + | state.AddPending("sha256:config", time.Now().UTC()) | |
| 97 | + | svc := NewService(blobStorage, manifestStorage, eventBus, state) | |
| 98 | + | data := []byte(`{"schemaVersion":2,"config":{"digest":"sha256:config"}}`) | |
| 95 | 99 | reference := fmt.Sprintf("sha512:%x", sha512.Sum512(data)) | |
| 96 | 100 | manifest := &domain.Manifest{Name: "myapp", Reference: reference, ContentType: "application/vnd.oci.image.manifest.v1+json", Data: data} | |
| 97 | 101 | ||
| @@ -101,6 +105,7 @@ func TestService_PutManifest_SHA512DigestDoesNotPublishEvent(t *testing.T) { | |||
| 101 | 105 | ||
| 102 | 106 | require.NoError(t, err) | |
| 103 | 107 | assert.Equal(t, reference, digest) | |
| 108 | + | assert.Empty(t, state.PendingDigests(time.Now().UTC())) | |
| 104 | 109 | } | |
| 105 | 110 | ||
| 106 | 111 | func TestService_PutManifest_RejectsDigestMismatch(t *testing.T) { | |
| @@ -149,6 +154,39 @@ func TestService_GetBlobPath_RequiresRepositoryReference(t *testing.T) { | |||
| 149 | 154 | assert.ErrorIs(t, err, domain.ErrBlobNotFound) | |
| 150 | 155 | } | |
| 151 | 156 | ||
| 157 | + | func TestService_GetBlobPath_FollowsSubjectManifest(t *testing.T) { | |
| 158 | + | const target = "sha256:target" | |
| 159 | + | blobStorage := mocks.NewMockBlobStorage(t) | |
| 160 | + | manifestStorage := mocks.NewMockManifestStorage(t) | |
| 161 | + | svc := NewService(blobStorage, manifestStorage, nil) | |
| 162 | + | ||
| 163 | + | manifestStorage.EXPECT().ListTags("artifacts").Return([]string{"latest"}, nil) | |
| 164 | + | manifestStorage.EXPECT().GetManifest("artifacts", "latest").Return( | |
| 165 | + | []byte(`{"subject":{"digest":"sha256:subject"}}`), "application/vnd.oci.artifact.manifest.v1+json", nil, | |
| 166 | + | ) | |
| 167 | + | manifestStorage.EXPECT().GetManifest("artifacts", "sha256:subject").Return( | |
| 168 | + | []byte(`{"config":{"digest":"`+target+`"}}`), "application/vnd.oci.image.manifest.v1+json", nil, | |
| 169 | + | ) | |
| 170 | + | blobStorage.EXPECT().GetBlobPath(target).Return("/registry/target", nil) | |
| 171 | + | ||
| 172 | + | path, err := svc.GetBlobPath(testContext(), "artifacts", target) | |
| 173 | + | ||
| 174 | + | require.NoError(t, err) | |
| 175 | + | assert.Equal(t, "/registry/target", path) | |
| 176 | + | } | |
| 177 | + | ||
| 178 | + | func TestService_GetBlobPath_BoundsManifestTraversal(t *testing.T) { | |
| 179 | + | blobStorage := mocks.NewMockBlobStorage(t) | |
| 180 | + | manifestStorage := mocks.NewMockManifestStorage(t) | |
| 181 | + | svc := NewService(blobStorage, manifestStorage, nil) | |
| 182 | + | tags := make([]string, maxManifestTraversal+1) | |
| 183 | + | manifestStorage.EXPECT().ListTags("busy").Return(tags, nil) | |
| 184 | + | ||
| 185 | + | _, err := svc.GetBlobPath(testContext(), "busy", "sha256:target") | |
| 186 | + | ||
| 187 | + | assert.ErrorIs(t, err, domain.ErrBlobNotFound) | |
| 188 | + | } | |
| 189 | + | ||
| 152 | 190 | func TestService_PutManifest_StorageError(t *testing.T) { | |
| 153 | 191 | blobStorage := mocks.NewMockBlobStorage(t) | |
| 154 | 192 | manifestStorage := mocks.NewMockManifestStorage(t) | |
| @@ -9,6 +9,7 @@ import ( | |||||
| 9 | "io" | 9 | "io" | ||
| 10 | "strings" | 10 | "strings" | ||
| 11 | "testing" | 11 | "testing" | ||
| 12 | + | "time" | |||
| 12 | 13 | ||||
| 13 | "github.com/bnema/zerowrap" | 14 | "github.com/bnema/zerowrap" | ||
| 14 | "github.com/stretchr/testify/assert" | 15 | "github.com/stretchr/testify/assert" | ||
| @@ -17,6 +18,7 @@ import ( | |||||
| 17 | 18 | ||||
| 18 | "github.com/bnema/gordon/internal/boundaries/out/mocks" | 19 | "github.com/bnema/gordon/internal/boundaries/out/mocks" | ||
| 19 | "github.com/bnema/gordon/internal/domain" | 20 | "github.com/bnema/gordon/internal/domain" | ||
| 21 | + | "github.com/bnema/gordon/internal/usecase/registrystate" | |||
| 20 | ) | 22 | ) | ||
| 21 | 23 | ||||
| 22 | func testContext() context.Context { | 24 | func testContext() context.Context { | ||
| @@ -90,8 +92,10 @@ func TestService_PutManifest_SHA512DigestDoesNotPublishEvent(t *testing.T) { | |||||
| 90 | blobStorage := mocks.NewMockBlobStorage(t) | 92 | blobStorage := mocks.NewMockBlobStorage(t) | ||
| 91 | manifestStorage := mocks.NewMockManifestStorage(t) | 93 | manifestStorage := mocks.NewMockManifestStorage(t) | ||
| 92 | eventBus := mocks.NewMockEventPublisher(t) | 94 | eventBus := mocks.NewMockEventPublisher(t) | ||
| 93 | - | svc := NewService(blobStorage, manifestStorage, eventBus) | 95 | + | state := registrystate.New() |
| 94 | - | data := []byte(`{"schemaVersion":2}`) | 96 | + | state.AddPending("sha256:config", time.Now().UTC()) |
| 97 | + | svc := NewService(blobStorage, manifestStorage, eventBus, state) | |||
| 98 | + | data := []byte(`{"schemaVersion":2,"config":{"digest":"sha256:config"}}`) | |||
| 95 | reference := fmt.Sprintf("sha512:%x", sha512.Sum512(data)) | 99 | 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} | 100 | manifest := &domain.Manifest{Name: "myapp", Reference: reference, ContentType: "application/vnd.oci.image.manifest.v1+json", Data: data} | ||
| 97 | 101 | ||||
| @@ -101,6 +105,7 @@ func TestService_PutManifest_SHA512DigestDoesNotPublishEvent(t *testing.T) { | |||||
| 101 | 105 | ||||
| 102 | require.NoError(t, err) | 106 | require.NoError(t, err) | ||
| 103 | assert.Equal(t, reference, digest) | 107 | assert.Equal(t, reference, digest) | ||
| 108 | + | assert.Empty(t, state.PendingDigests(time.Now().UTC())) | |||
| 104 | } | 109 | } | ||
| 105 | 110 | ||||
| 106 | func TestService_PutManifest_RejectsDigestMismatch(t *testing.T) { | 111 | func TestService_PutManifest_RejectsDigestMismatch(t *testing.T) { | ||
| @@ -149,6 +154,39 @@ func TestService_GetBlobPath_RequiresRepositoryReference(t *testing.T) { | |||||
| 149 | assert.ErrorIs(t, err, domain.ErrBlobNotFound) | 154 | assert.ErrorIs(t, err, domain.ErrBlobNotFound) | ||
| 150 | } | 155 | } | ||
| 151 | 156 | ||||
| 157 | + | func TestService_GetBlobPath_FollowsSubjectManifest(t *testing.T) { | |||
| 158 | + | const target = "sha256:target" | |||
| 159 | + | blobStorage := mocks.NewMockBlobStorage(t) | |||
| 160 | + | manifestStorage := mocks.NewMockManifestStorage(t) | |||
| 161 | + | svc := NewService(blobStorage, manifestStorage, nil) | |||
| 162 | + | ||||
| 163 | + | manifestStorage.EXPECT().ListTags("artifacts").Return([]string{"latest"}, nil) | |||
| 164 | + | manifestStorage.EXPECT().GetManifest("artifacts", "latest").Return( | |||
| 165 | + | []byte(`{"subject":{"digest":"sha256:subject"}}`), "application/vnd.oci.artifact.manifest.v1+json", nil, | |||
| 166 | + | ) | |||
| 167 | + | manifestStorage.EXPECT().GetManifest("artifacts", "sha256:subject").Return( | |||
| 168 | + | []byte(`{"config":{"digest":"`+target+`"}}`), "application/vnd.oci.image.manifest.v1+json", nil, | |||
| 169 | + | ) | |||
| 170 | + | blobStorage.EXPECT().GetBlobPath(target).Return("/registry/target", nil) | |||
| 171 | + | ||||
| 172 | + | path, err := svc.GetBlobPath(testContext(), "artifacts", target) | |||
| 173 | + | ||||
| 174 | + | require.NoError(t, err) | |||
| 175 | + | assert.Equal(t, "/registry/target", path) | |||
| 176 | + | } | |||
| 177 | + | ||||
| 178 | + | func TestService_GetBlobPath_BoundsManifestTraversal(t *testing.T) { | |||
| 179 | + | blobStorage := mocks.NewMockBlobStorage(t) | |||
| 180 | + | manifestStorage := mocks.NewMockManifestStorage(t) | |||
| 181 | + | svc := NewService(blobStorage, manifestStorage, nil) | |||
| 182 | + | tags := make([]string, maxManifestTraversal+1) | |||
| 183 | + | manifestStorage.EXPECT().ListTags("busy").Return(tags, nil) | |||
| 184 | + | ||||
| 185 | + | _, err := svc.GetBlobPath(testContext(), "busy", "sha256:target") | |||
| 186 | + | ||||
| 187 | + | assert.ErrorIs(t, err, domain.ErrBlobNotFound) | |||
| 188 | + | } | |||
| 189 | + | ||||
| 152 | func TestService_PutManifest_StorageError(t *testing.T) { | 190 | func TestService_PutManifest_StorageError(t *testing.T) { | ||
| 153 | blobStorage := mocks.NewMockBlobStorage(t) | 191 | blobStorage := mocks.NewMockBlobStorage(t) | ||
| 154 | manifestStorage := mocks.NewMockManifestStorage(t) | 192 | manifestStorage := mocks.NewMockManifestStorage(t) | ||
A internal/usecase/registrystate/state.go +57 -0
| @@ -0,0 +1,57 @@ | |||
| 1 | + | // Package registrystate coordinates registry mutations and garbage collection. | |
| 2 | + | package registrystate | |
| 3 | + | ||
| 4 | + | import ( | |
| 5 | + | "sync" | |
| 6 | + | "time" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | // PendingBlobTTL protects uploaded blobs while clients publish their manifest. | |
| 10 | + | const PendingBlobTTL = 24 * time.Hour | |
| 11 | + | ||
| 12 | + | // State is shared by registry writes and registry garbage collection. | |
| 13 | + | type State struct { | |
| 14 | + | MutationMu sync.RWMutex | |
| 15 | + | ||
| 16 | + | pendingMu sync.Mutex | |
| 17 | + | pending map[string]time.Time | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | // New creates empty registry coordination state. | |
| 21 | + | func New() *State { | |
| 22 | + | return &State{pending: make(map[string]time.Time)} | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | // AddPending records a blob that has been uploaded but not yet referenced by a manifest. | |
| 26 | + | func (s *State) AddPending(digest string, now time.Time) { | |
| 27 | + | s.pendingMu.Lock() | |
| 28 | + | defer s.pendingMu.Unlock() | |
| 29 | + | if s.pending == nil { | |
| 30 | + | s.pending = make(map[string]time.Time) | |
| 31 | + | } | |
| 32 | + | s.pending[digest] = now | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | // MarkPublished removes blobs now owned by a published manifest from the pending set. | |
| 36 | + | func (s *State) MarkPublished(digests []string) { | |
| 37 | + | s.pendingMu.Lock() | |
| 38 | + | defer s.pendingMu.Unlock() | |
| 39 | + | for _, digest := range digests { | |
| 40 | + | delete(s.pending, digest) | |
| 41 | + | } | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | // PendingDigests returns non-expired pending blobs and drops abandoned entries. | |
| 45 | + | func (s *State) PendingDigests(now time.Time) map[string]struct{} { | |
| 46 | + | s.pendingMu.Lock() | |
| 47 | + | defer s.pendingMu.Unlock() | |
| 48 | + | result := make(map[string]struct{}, len(s.pending)) | |
| 49 | + | for digest, createdAt := range s.pending { | |
| 50 | + | if now.Sub(createdAt) > PendingBlobTTL { | |
| 51 | + | delete(s.pending, digest) | |
| 52 | + | continue | |
| 53 | + | } | |
| 54 | + | result[digest] = struct{}{} | |
| 55 | + | } | |
| 56 | + | return result | |
| 57 | + | } | |
| @@ -0,0 +1,57 @@ | |||||
| 1 | + | // Package registrystate coordinates registry mutations and garbage collection. | |||
| 2 | + | package registrystate | |||
| 3 | + | ||||
| 4 | + | import ( | |||
| 5 | + | "sync" | |||
| 6 | + | "time" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | // PendingBlobTTL protects uploaded blobs while clients publish their manifest. | |||
| 10 | + | const PendingBlobTTL = 24 * time.Hour | |||
| 11 | + | ||||
| 12 | + | // State is shared by registry writes and registry garbage collection. | |||
| 13 | + | type State struct { | |||
| 14 | + | MutationMu sync.RWMutex | |||
| 15 | + | ||||
| 16 | + | pendingMu sync.Mutex | |||
| 17 | + | pending map[string]time.Time | |||
| 18 | + | } | |||
| 19 | + | ||||
| 20 | + | // New creates empty registry coordination state. | |||
| 21 | + | func New() *State { | |||
| 22 | + | return &State{pending: make(map[string]time.Time)} | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | // AddPending records a blob that has been uploaded but not yet referenced by a manifest. | |||
| 26 | + | func (s *State) AddPending(digest string, now time.Time) { | |||
| 27 | + | s.pendingMu.Lock() | |||
| 28 | + | defer s.pendingMu.Unlock() | |||
| 29 | + | if s.pending == nil { | |||
| 30 | + | s.pending = make(map[string]time.Time) | |||
| 31 | + | } | |||
| 32 | + | s.pending[digest] = now | |||
| 33 | + | } | |||
| 34 | + | ||||
| 35 | + | // MarkPublished removes blobs now owned by a published manifest from the pending set. | |||
| 36 | + | func (s *State) MarkPublished(digests []string) { | |||
| 37 | + | s.pendingMu.Lock() | |||
| 38 | + | defer s.pendingMu.Unlock() | |||
| 39 | + | for _, digest := range digests { | |||
| 40 | + | delete(s.pending, digest) | |||
| 41 | + | } | |||
| 42 | + | } | |||
| 43 | + | ||||
| 44 | + | // PendingDigests returns non-expired pending blobs and drops abandoned entries. | |||
| 45 | + | func (s *State) PendingDigests(now time.Time) map[string]struct{} { | |||
| 46 | + | s.pendingMu.Lock() | |||
| 47 | + | defer s.pendingMu.Unlock() | |||
| 48 | + | result := make(map[string]struct{}, len(s.pending)) | |||
| 49 | + | for digest, createdAt := range s.pending { | |||
| 50 | + | if now.Sub(createdAt) > PendingBlobTTL { | |||
| 51 | + | delete(s.pending, digest) | |||
| 52 | + | continue | |||
| 53 | + | } | |||
| 54 | + | result[digest] = struct{}{} | |||
| 55 | + | } | |||
| 56 | + | return result | |||
| 57 | + | } | |||
A internal/usecase/registrystate/state_test.go +21 -0
| @@ -0,0 +1,21 @@ | |||
| 1 | + | package registrystate | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | "time" | |
| 6 | + | ||
| 7 | + | "github.com/stretchr/testify/assert" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | func TestStateTracksPublishesAndExpiresPendingBlobs(t *testing.T) { | |
| 11 | + | now := time.Now().UTC() | |
| 12 | + | state := New() | |
| 13 | + | state.AddPending("sha256:published", now) | |
| 14 | + | state.AddPending("sha256:pending", now) | |
| 15 | + | state.AddPending("sha256:expired", now.Add(-PendingBlobTTL-time.Second)) | |
| 16 | + | ||
| 17 | + | state.MarkPublished([]string{"sha256:published"}) | |
| 18 | + | pending := state.PendingDigests(now) | |
| 19 | + | ||
| 20 | + | assert.Equal(t, map[string]struct{}{"sha256:pending": {}}, pending) | |
| 21 | + | } | |
| @@ -0,0 +1,21 @@ | |||||
| 1 | + | package registrystate | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | "time" | |||
| 6 | + | ||||
| 7 | + | "github.com/stretchr/testify/assert" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | func TestStateTracksPublishesAndExpiresPendingBlobs(t *testing.T) { | |||
| 11 | + | now := time.Now().UTC() | |||
| 12 | + | state := New() | |||
| 13 | + | state.AddPending("sha256:published", now) | |||
| 14 | + | state.AddPending("sha256:pending", now) | |||
| 15 | + | state.AddPending("sha256:expired", now.Add(-PendingBlobTTL-time.Second)) | |||
| 16 | + | ||||
| 17 | + | state.MarkPublished([]string{"sha256:published"}) | |||
| 18 | + | pending := state.PendingDigests(now) | |||
| 19 | + | ||||
| 20 | + | assert.Equal(t, map[string]struct{}{"sha256:pending": {}}, pending) | |||
| 21 | + | } | |||
M internal/usecase/services/service.go +38 -6
| @@ -2,10 +2,12 @@ package services | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "bufio" | |
| 5 | + | "bytes" | |
| 5 | 6 | "context" | |
| 6 | 7 | "crypto/sha256" | |
| 7 | 8 | "encoding/hex" | |
| 8 | 9 | "encoding/json" | |
| 10 | + | "errors" | |
| 9 | 11 | "fmt" | |
| 10 | 12 | "io" | |
| 11 | 13 | "maps" | |
| @@ -532,6 +534,9 @@ func (s *Service) waitLogReadiness(ctx context.Context, containerID string, svc | |||
| 532 | 534 | if err == nil && found { | |
| 533 | 535 | return nil | |
| 534 | 536 | } | |
| 537 | + | if errors.Is(err, domain.ErrReadinessLogSizeExceeded) { | |
| 538 | + | return err | |
| 539 | + | } | |
| 535 | 540 | if err != nil { | |
| 536 | 541 | lastErr = err | |
| 537 | 542 | } | |
| @@ -559,14 +564,41 @@ func (s *Service) logContains(ctx context.Context, containerID, path, contains s | |||
| 559 | 564 | return false, err | |
| 560 | 565 | } | |
| 561 | 566 | defer reader.Close() | |
| 562 | - | content, err := io.ReadAll(io.LimitReader(reader, maxReadinessLogSize+1)) | |
| 563 | - | if err != nil { | |
| 564 | - | return false, err | |
| 567 | + | if contains == "" { | |
| 568 | + | return true, nil | |
| 569 | + | } | |
| 570 | + | ||
| 571 | + | needle := []byte(contains) | |
| 572 | + | buffer := make([]byte, 32*1024) | |
| 573 | + | carry := make([]byte, 0, len(needle)-1) | |
| 574 | + | remaining := maxReadinessLogSize | |
| 575 | + | for remaining > 0 { | |
| 576 | + | readSize := min(len(buffer), remaining) | |
| 577 | + | n, readErr := reader.Read(buffer[:readSize]) | |
| 578 | + | if n > 0 { | |
| 579 | + | window := append(carry, buffer[:n]...) | |
| 580 | + | if bytes.Contains(window, needle) { | |
| 581 | + | return true, nil | |
| 582 | + | } | |
| 583 | + | overlap := min(len(needle)-1, len(window)) | |
| 584 | + | carry = append(carry[:0], window[len(window)-overlap:]...) | |
| 585 | + | remaining -= n | |
| 586 | + | } | |
| 587 | + | if errors.Is(readErr, io.EOF) { | |
| 588 | + | return false, nil | |
| 589 | + | } | |
| 590 | + | if readErr != nil { | |
| 591 | + | return false, readErr | |
| 592 | + | } | |
| 565 | 593 | } | |
| 566 | - | if len(content) > maxReadinessLogSize { | |
| 567 | - | return false, fmt.Errorf("readiness log exceeds %d bytes", maxReadinessLogSize) | |
| 594 | + | ||
| 595 | + | var extra [1]byte | |
| 596 | + | if _, err := reader.Read(extra[:]); errors.Is(err, io.EOF) { | |
| 597 | + | return false, nil | |
| 598 | + | } else if err != nil { | |
| 599 | + | return false, err | |
| 568 | 600 | } | |
| 569 | - | return strings.Contains(string(content), contains), nil | |
| 601 | + | return false, fmt.Errorf("%w: limit is %d bytes", domain.ErrReadinessLogSizeExceeded, maxReadinessLogSize) | |
| 570 | 602 | } | |
| 571 | 603 | ||
| 572 | 604 | func normalizeCleanup(cleanup domain.StandaloneServiceCleanup) domain.StandaloneServiceCleanup { | |
| @@ -2,10 +2,12 @@ package services | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "bufio" | 4 | "bufio" | ||
| 5 | + | "bytes" | |||
| 5 | "context" | 6 | "context" | ||
| 6 | "crypto/sha256" | 7 | "crypto/sha256" | ||
| 7 | "encoding/hex" | 8 | "encoding/hex" | ||
| 8 | "encoding/json" | 9 | "encoding/json" | ||
| 10 | + | "errors" | |||
| 9 | "fmt" | 11 | "fmt" | ||
| 10 | "io" | 12 | "io" | ||
| 11 | "maps" | 13 | "maps" | ||
| @@ -532,6 +534,9 @@ func (s *Service) waitLogReadiness(ctx context.Context, containerID string, svc | |||||
| 532 | if err == nil && found { | 534 | if err == nil && found { | ||
| 533 | return nil | 535 | return nil | ||
| 534 | } | 536 | } | ||
| 537 | + | if errors.Is(err, domain.ErrReadinessLogSizeExceeded) { | |||
| 538 | + | return err | |||
| 539 | + | } | |||
| 535 | if err != nil { | 540 | if err != nil { | ||
| 536 | lastErr = err | 541 | lastErr = err | ||
| 537 | } | 542 | } | ||
| @@ -559,14 +564,41 @@ func (s *Service) logContains(ctx context.Context, containerID, path, contains s | |||||
| 559 | return false, err | 564 | return false, err | ||
| 560 | } | 565 | } | ||
| 561 | defer reader.Close() | 566 | defer reader.Close() | ||
| 562 | - | content, err := io.ReadAll(io.LimitReader(reader, maxReadinessLogSize+1)) | 567 | + | if contains == "" { |
| 563 | - | if err != nil { | 568 | + | return true, nil |
| 564 | - | return false, err | 569 | + | } |
| 570 | + | ||||
| 571 | + | needle := []byte(contains) | |||
| 572 | + | buffer := make([]byte, 32*1024) | |||
| 573 | + | carry := make([]byte, 0, len(needle)-1) | |||
| 574 | + | remaining := maxReadinessLogSize | |||
| 575 | + | for remaining > 0 { | |||
| 576 | + | readSize := min(len(buffer), remaining) | |||
| 577 | + | n, readErr := reader.Read(buffer[:readSize]) | |||
| 578 | + | if n > 0 { | |||
| 579 | + | window := append(carry, buffer[:n]...) | |||
| 580 | + | if bytes.Contains(window, needle) { | |||
| 581 | + | return true, nil | |||
| 582 | + | } | |||
| 583 | + | overlap := min(len(needle)-1, len(window)) | |||
| 584 | + | carry = append(carry[:0], window[len(window)-overlap:]...) | |||
| 585 | + | remaining -= n | |||
| 586 | + | } | |||
| 587 | + | if errors.Is(readErr, io.EOF) { | |||
| 588 | + | return false, nil | |||
| 589 | + | } | |||
| 590 | + | if readErr != nil { | |||
| 591 | + | return false, readErr | |||
| 592 | + | } | |||
| 565 | } | 593 | } | ||
| 566 | - | if len(content) > maxReadinessLogSize { | 594 | + | |
| 567 | - | return false, fmt.Errorf("readiness log exceeds %d bytes", maxReadinessLogSize) | 595 | + | var extra [1]byte |
| 596 | + | if _, err := reader.Read(extra[:]); errors.Is(err, io.EOF) { | |||
| 597 | + | return false, nil | |||
| 598 | + | } else if err != nil { | |||
| 599 | + | return false, err | |||
| 568 | } | 600 | } | ||
| 569 | - | return strings.Contains(string(content), contains), nil | 601 | + | return false, fmt.Errorf("%w: limit is %d bytes", domain.ErrReadinessLogSizeExceeded, maxReadinessLogSize) |
| 570 | } | 602 | } | ||
| 571 | 603 | ||||
| 572 | func normalizeCleanup(cleanup domain.StandaloneServiceCleanup) domain.StandaloneServiceCleanup { | 604 | func normalizeCleanup(cleanup domain.StandaloneServiceCleanup) domain.StandaloneServiceCleanup { | ||
M internal/usecase/services/service_test.go +26 -0
| @@ -387,6 +387,32 @@ func TestWaitLogReadinessReadsContainerFileUntilTextAppears(t *testing.T) { | |||
| 387 | 387 | require.NoError(t, err) | |
| 388 | 388 | } | |
| 389 | 389 | ||
| 390 | + | func TestLogContainsStopsAtMatchBeforeSizeLimit(t *testing.T) { | |
| 391 | + | runtime := outmocks.NewMockContainerRuntime(t) | |
| 392 | + | svc := NewService(runtime) | |
| 393 | + | runtime.EXPECT().CopyFromContainer(mock.Anything, "container-1", "/logs/server.log").Return( | |
| 394 | + | io.NopCloser(strings.NewReader("ready\n"+strings.Repeat("x", maxReadinessLogSize))), nil, | |
| 395 | + | ) | |
| 396 | + | ||
| 397 | + | found, err := svc.logContains(context.Background(), "container-1", "/logs/server.log", "ready") | |
| 398 | + | ||
| 399 | + | require.NoError(t, err) | |
| 400 | + | assert.True(t, found) | |
| 401 | + | } | |
| 402 | + | ||
| 403 | + | func TestLogContainsReturnsSentinelAtSizeLimit(t *testing.T) { | |
| 404 | + | runtime := outmocks.NewMockContainerRuntime(t) | |
| 405 | + | svc := NewService(runtime) | |
| 406 | + | runtime.EXPECT().CopyFromContainer(mock.Anything, "container-1", "/logs/server.log").Return( | |
| 407 | + | io.NopCloser(strings.NewReader(strings.Repeat("x", maxReadinessLogSize+1))), nil, | |
| 408 | + | ) | |
| 409 | + | ||
| 410 | + | found, err := svc.logContains(context.Background(), "container-1", "/logs/server.log", "ready") | |
| 411 | + | ||
| 412 | + | assert.False(t, found) | |
| 413 | + | assert.ErrorIs(t, err, domain.ErrReadinessLogSizeExceeded) | |
| 414 | + | } | |
| 415 | + | ||
| 390 | 416 | func TestWaitReadinessHonorsContextTimeout(t *testing.T) { | |
| 391 | 417 | svc := sampleService() | |
| 392 | 418 | svc.Readiness = domain.StandaloneServiceReadiness{Type: domain.StandaloneServiceReadinessTCP} | |
| @@ -387,6 +387,32 @@ func TestWaitLogReadinessReadsContainerFileUntilTextAppears(t *testing.T) { | |||||
| 387 | require.NoError(t, err) | 387 | require.NoError(t, err) | ||
| 388 | } | 388 | } | ||
| 389 | 389 | ||||
| 390 | + | func TestLogContainsStopsAtMatchBeforeSizeLimit(t *testing.T) { | |||
| 391 | + | runtime := outmocks.NewMockContainerRuntime(t) | |||
| 392 | + | svc := NewService(runtime) | |||
| 393 | + | runtime.EXPECT().CopyFromContainer(mock.Anything, "container-1", "/logs/server.log").Return( | |||
| 394 | + | io.NopCloser(strings.NewReader("ready\n"+strings.Repeat("x", maxReadinessLogSize))), nil, | |||
| 395 | + | ) | |||
| 396 | + | ||||
| 397 | + | found, err := svc.logContains(context.Background(), "container-1", "/logs/server.log", "ready") | |||
| 398 | + | ||||
| 399 | + | require.NoError(t, err) | |||
| 400 | + | assert.True(t, found) | |||
| 401 | + | } | |||
| 402 | + | ||||
| 403 | + | func TestLogContainsReturnsSentinelAtSizeLimit(t *testing.T) { | |||
| 404 | + | runtime := outmocks.NewMockContainerRuntime(t) | |||
| 405 | + | svc := NewService(runtime) | |||
| 406 | + | runtime.EXPECT().CopyFromContainer(mock.Anything, "container-1", "/logs/server.log").Return( | |||
| 407 | + | io.NopCloser(strings.NewReader(strings.Repeat("x", maxReadinessLogSize+1))), nil, | |||
| 408 | + | ) | |||
| 409 | + | ||||
| 410 | + | found, err := svc.logContains(context.Background(), "container-1", "/logs/server.log", "ready") | |||
| 411 | + | ||||
| 412 | + | assert.False(t, found) | |||
| 413 | + | assert.ErrorIs(t, err, domain.ErrReadinessLogSizeExceeded) | |||
| 414 | + | } | |||
| 415 | + | ||||
| 390 | func TestWaitReadinessHonorsContextTimeout(t *testing.T) { | 416 | func TestWaitReadinessHonorsContextTimeout(t *testing.T) { | ||
| 391 | svc := sampleService() | 417 | svc := sampleService() | ||
| 392 | svc.Readiness = domain.StandaloneServiceReadiness{Type: domain.StandaloneServiceReadinessTCP} | 418 | svc.Readiness = domain.StandaloneServiceReadiness{Type: domain.StandaloneServiceReadinessTCP} | ||