detached 65654e6

Merge pull request #226 from bnema/security/scan-9bfec05a

fix(security): remediate codex security scan findings
commit
65654e613de32a8aabda8bf7b01edeac024e94a4
author
bnema · · 2026-08-11 02:09 UTC
committer
GitHub · · 2026-08-11 02:09 UTC
merge of
58a92f3 + 378d7cf · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
38 files · +1199 -297
M .github/actions/deploy/README.md +20 -23
@@ -27,10 +27,12 @@ jobs:
2727 deploy:
2828 runs-on: ubuntu-latest
2929 steps:
30- - uses: actions/checkout@v6
30+ - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
31+ with:
32+ persist-credentials: false
3133
3234 - name: Deploy to Gordon
33- uses: bnema/gordon/.github/actions/deploy@main
35+ uses: bnema/gordon/.github/actions/deploy@271c36a4cc32fabd91b20f93cd006bdd2a01b181
3436 with:
3537 registry: registry.mydomain.com
3638 username: ${{ secrets.GORDON_USERNAME }}
@@ -52,11 +54,13 @@ jobs:
5254 deploy:
5355 runs-on: ubuntu-latest
5456 steps:
55- - uses: actions/checkout@v6
57+ - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
58+ with:
59+ persist-credentials: false
5660
5761 - name: Deploy to Gordon
5862 id: deploy
59- uses: bnema/gordon/.github/actions/deploy@main
63+ uses: bnema/gordon/.github/actions/deploy@271c36a4cc32fabd91b20f93cd006bdd2a01b181
6064 with:
6165 registry: registry.mydomain.com
6266 username: ${{ secrets.GORDON_USERNAME }}
@@ -64,9 +68,6 @@ jobs:
6468 image: myapp # Custom image name
6569 dockerfile: ./docker/Dockerfile # Custom Dockerfile path
6670 context: . # Build context
67- build-args: | # Build arguments
68- NODE_ENV=production
69- API_URL=https://api.example.com
7071 platforms: linux/amd64,linux/arm64 # Multi-platform
7172 push-latest: 'true' # Also push :latest
7273
@@ -88,7 +89,6 @@ jobs:
8889 | `tag` | Override image tag | No | Git tag or short SHA |
8990 | `dockerfile` | Path to Dockerfile | No | `./Dockerfile` |
9091 | `context` | Build context path | No | `.` |
91-| `build-args` | Build arguments (one per line) | No | - |
9292 | `platforms` | Target platforms | No | - |
9393 | `push-latest` | Also push with `:latest` tag | No | `true` |
9494 | `cache-from` | Cache source | No | `type=gha` |
@@ -152,11 +152,12 @@ jobs:
152152 deploy:
153153 runs-on: ubuntu-latest
154154 steps:
155- - uses: actions/checkout@v6
155+ - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
156156 with:
157157 ref: ${{ github.event.inputs.tag || github.ref }}
158+ persist-credentials: false
158159
159- - uses: bnema/gordon/.github/actions/deploy@main
160+ - uses: bnema/gordon/.github/actions/deploy@271c36a4cc32fabd91b20f93cd006bdd2a01b181
160161 with:
161162 registry: registry.mydomain.com
162163 username: ${{ secrets.GORDON_USERNAME }}
@@ -178,8 +179,10 @@ jobs:
178179 deploy-api:
179180 runs-on: ubuntu-latest
180181 steps:
181- - uses: actions/checkout@v6
182- - uses: bnema/gordon/.github/actions/deploy@main
182+ - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
183+ with:
184+ persist-credentials: false
185+ - uses: bnema/gordon/.github/actions/deploy@271c36a4cc32fabd91b20f93cd006bdd2a01b181
183186 with:
184187 registry: registry.mydomain.com
185188 username: ${{ secrets.GORDON_USERNAME }}
@@ -191,8 +194,10 @@ jobs:
191194 deploy-web:
192195 runs-on: ubuntu-latest
193196 steps:
194- - uses: actions/checkout@v6
195- - uses: bnema/gordon/.github/actions/deploy@main
197+ - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
198+ with:
199+ persist-credentials: false
200+ - uses: bnema/gordon/.github/actions/deploy@271c36a4cc32fabd91b20f93cd006bdd2a01b181
196201 with:
197202 registry: registry.mydomain.com
198203 username: ${{ secrets.GORDON_USERNAME }}
@@ -204,15 +209,7 @@ jobs:
204209
205210 ### Build with Secrets (BuildKit)
206211
207-```yaml
208-- uses: bnema/gordon/.github/actions/deploy@main
209- with:
210- registry: registry.mydomain.com
211- username: ${{ secrets.GORDON_USERNAME }}
212- password: ${{ secrets.GORDON_TOKEN }}
213- build-args: |
214- NPM_TOKEN=${{ secrets.NPM_TOKEN }}
215-```
212+The deploy action intentionally does not accept build arguments because CI secrets passed as build arguments can remain in image metadata or layers. For secret-bearing builds, use `docker/build-push-action` with its BuildKit `secrets` input, reference the secret with `RUN --mount=type=secret` in the Dockerfile, and pin every action to a reviewed commit SHA.
216213
217214 ## Troubleshooting
218215
@@ -27,10 +27,12 @@ jobs:
27 deploy:27 deploy:
28 runs-on: ubuntu-latest28 runs-on: ubuntu-latest
29 steps:29 steps:
30- - uses: actions/checkout@v630+ - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
31+ with:
32+ persist-credentials: false
31 33
32 - name: Deploy to Gordon34 - name: Deploy to Gordon
33- uses: bnema/gordon/.github/actions/deploy@main35+ uses: bnema/gordon/.github/actions/deploy@271c36a4cc32fabd91b20f93cd006bdd2a01b181
34 with:36 with:
35 registry: registry.mydomain.com37 registry: registry.mydomain.com
36 username: ${{ secrets.GORDON_USERNAME }}38 username: ${{ secrets.GORDON_USERNAME }}
@@ -52,11 +54,13 @@ jobs:
52 deploy:54 deploy:
53 runs-on: ubuntu-latest55 runs-on: ubuntu-latest
54 steps:56 steps:
55- - uses: actions/checkout@v657+ - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
58+ with:
59+ persist-credentials: false
56 60
57 - name: Deploy to Gordon61 - name: Deploy to Gordon
58 id: deploy62 id: deploy
59- uses: bnema/gordon/.github/actions/deploy@main63+ uses: bnema/gordon/.github/actions/deploy@271c36a4cc32fabd91b20f93cd006bdd2a01b181
60 with:64 with:
61 registry: registry.mydomain.com65 registry: registry.mydomain.com
62 username: ${{ secrets.GORDON_USERNAME }}66 username: ${{ secrets.GORDON_USERNAME }}
@@ -64,9 +68,6 @@ jobs:
64 image: myapp # Custom image name68 image: myapp # Custom image name
65 dockerfile: ./docker/Dockerfile # Custom Dockerfile path69 dockerfile: ./docker/Dockerfile # Custom Dockerfile path
66 context: . # Build context70 context: . # Build context
67- build-args: | # Build arguments
68- NODE_ENV=production
69- API_URL=https://api.example.com
70 platforms: linux/amd64,linux/arm64 # Multi-platform71 platforms: linux/amd64,linux/arm64 # Multi-platform
71 push-latest: 'true' # Also push :latest72 push-latest: 'true' # Also push :latest
72 73
@@ -88,7 +89,6 @@ jobs:
88 | `tag` | Override image tag | No | Git tag or short SHA |89 | `tag` | Override image tag | No | Git tag or short SHA |
89 | `dockerfile` | Path to Dockerfile | No | `./Dockerfile` |90 | `dockerfile` | Path to Dockerfile | No | `./Dockerfile` |
90 | `context` | Build context path | No | `.` |91 | `context` | Build context path | No | `.` |
91-| `build-args` | Build arguments (one per line) | No | - |
92 | `platforms` | Target platforms | No | - |92 | `platforms` | Target platforms | No | - |
93 | `push-latest` | Also push with `:latest` tag | No | `true` |93 | `push-latest` | Also push with `:latest` tag | No | `true` |
94 | `cache-from` | Cache source | No | `type=gha` |94 | `cache-from` | Cache source | No | `type=gha` |
@@ -152,11 +152,12 @@ jobs:
152 deploy:152 deploy:
153 runs-on: ubuntu-latest153 runs-on: ubuntu-latest
154 steps:154 steps:
155- - uses: actions/checkout@v6155+ - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
156 with:156 with:
157 ref: ${{ github.event.inputs.tag || github.ref }}157 ref: ${{ github.event.inputs.tag || github.ref }}
158+ persist-credentials: false
158 159
159- - uses: bnema/gordon/.github/actions/deploy@main160+ - uses: bnema/gordon/.github/actions/deploy@271c36a4cc32fabd91b20f93cd006bdd2a01b181
160 with:161 with:
161 registry: registry.mydomain.com162 registry: registry.mydomain.com
162 username: ${{ secrets.GORDON_USERNAME }}163 username: ${{ secrets.GORDON_USERNAME }}
@@ -178,8 +179,10 @@ jobs:
178 deploy-api:179 deploy-api:
179 runs-on: ubuntu-latest180 runs-on: ubuntu-latest
180 steps:181 steps:
181- - uses: actions/checkout@v6182+ - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
182- - uses: bnema/gordon/.github/actions/deploy@main183+ with:
184+ persist-credentials: false
185+ - uses: bnema/gordon/.github/actions/deploy@271c36a4cc32fabd91b20f93cd006bdd2a01b181
183 with:186 with:
184 registry: registry.mydomain.com187 registry: registry.mydomain.com
185 username: ${{ secrets.GORDON_USERNAME }}188 username: ${{ secrets.GORDON_USERNAME }}
@@ -191,8 +194,10 @@ jobs:
191 deploy-web:194 deploy-web:
192 runs-on: ubuntu-latest195 runs-on: ubuntu-latest
193 steps:196 steps:
194- - uses: actions/checkout@v6197+ - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
195- - uses: bnema/gordon/.github/actions/deploy@main198+ with:
199+ persist-credentials: false
200+ - uses: bnema/gordon/.github/actions/deploy@271c36a4cc32fabd91b20f93cd006bdd2a01b181
196 with:201 with:
197 registry: registry.mydomain.com202 registry: registry.mydomain.com
198 username: ${{ secrets.GORDON_USERNAME }}203 username: ${{ secrets.GORDON_USERNAME }}
@@ -204,15 +209,7 @@ jobs:
204 209
205 ### Build with Secrets (BuildKit)210 ### Build with Secrets (BuildKit)
206 211
207-```yaml212+The deploy action intentionally does not accept build arguments because CI secrets passed as build arguments can remain in image metadata or layers. For secret-bearing builds, use `docker/build-push-action` with its BuildKit `secrets` input, reference the secret with `RUN --mount=type=secret` in the Dockerfile, and pin every action to a reviewed commit SHA.
208-- uses: bnema/gordon/.github/actions/deploy@main
209- with:
210- registry: registry.mydomain.com
211- username: ${{ secrets.GORDON_USERNAME }}
212- password: ${{ secrets.GORDON_TOKEN }}
213- build-args: |
214- NPM_TOKEN=${{ secrets.NPM_TOKEN }}
215-```
216 213
217 ## Troubleshooting214 ## Troubleshooting
218 215
M .github/actions/deploy/action.yml +69 -34
@@ -32,10 +32,6 @@ inputs:
3232 description: 'Build context path (defaults to .)'
3333 required: false
3434 default: '.'
35- build-args:
36- description: 'Build arguments (one per line, KEY=value format)'
37- required: false
38- default: ''
3935 platforms:
4036 description: 'Target platforms (e.g., linux/amd64,linux/arm64)'
4137 required: false
@@ -69,49 +65,76 @@ runs:
6965 steps:
7066 - name: Validate inputs
7167 shell: bash
68+ env:
69+ INPUT_DOCKERFILE: ${{ inputs.dockerfile }}
7270 run: |
73- if [[ ! -f "${{ inputs.dockerfile }}" ]]; then
74- echo "::error::Dockerfile not found at ${{ inputs.dockerfile }}"
71+ if [[ ! -f "${INPUT_DOCKERFILE}" ]]; then
72+ echo "::error::Dockerfile not found at ${INPUT_DOCKERFILE}"
7573 exit 1
7674 fi
7775
7876 - name: Extract metadata
7977 id: meta
8078 shell: bash
79+ env:
80+ INPUT_IMAGE: ${{ inputs.image }}
81+ INPUT_TAG: ${{ inputs.tag }}
82+ INPUT_REGISTRY: ${{ inputs.registry }}
8183 run: |
82- # Determine image name
83- IMAGE_NAME="${{ inputs.image }}"
84- if [[ -z "$IMAGE_NAME" ]]; then
85- IMAGE_NAME="${GITHUB_REPOSITORY#*/}"
86- fi
84+ reject_line_breaks() {
85+ [[ "$2" != *$'\n'* && "$2" != *$'\r'* ]] || {
86+ echo "::error::$1 must not contain line breaks"
87+ exit 1
88+ }
89+ }
90+ reject_line_breaks "registry" "$INPUT_REGISTRY"
91+ reject_line_breaks "image" "$INPUT_IMAGE"
92+ reject_line_breaks "tag" "$INPUT_TAG"
8793
88- # Extract tag from ref (or override)
89- if [[ -n "${{ inputs.tag }}" ]]; then
90- TAG="${{ inputs.tag }}"
94+ IMAGE_NAME="${INPUT_IMAGE:-${GITHUB_REPOSITORY#*/}}"
95+ if [[ -n "${INPUT_TAG}" ]]; then
96+ TAG="${INPUT_TAG}"
9197 elif [[ "$GITHUB_REF" == refs/tags/* ]]; then
9298 TAG="${GITHUB_REF#refs/tags/}"
9399 else
94- TAG="$(echo "$GITHUB_SHA" | head -c 7)"
100+ TAG="${GITHUB_SHA:0:7}"
101+ fi
102+
103+ [[ "$INPUT_REGISTRY" =~ ^[a-zA-Z0-9.-]+(:[0-9]{1,5})?$ ]] || {
104+ echo "::error::registry must be a hostname with an optional port"
105+ exit 1
106+ }
107+ if [[ "$INPUT_REGISTRY" =~ :([0-9]+)$ ]]; then
108+ PORT="${BASH_REMATCH[1]}"
109+ [[ "$PORT" != 0* && $((10#$PORT)) -ge 1 && $((10#$PORT)) -le 65535 ]] || {
110+ echo "::error::registry port must be between 1 and 65535 without leading zeros"
111+ exit 1
112+ }
95113 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+ }
96122
97- # Full image reference
98- FULL_IMAGE="${{ inputs.registry }}/${IMAGE_NAME}:${TAG}"
123+ FULL_IMAGE="${INPUT_REGISTRY}/${IMAGE_NAME}:${TAG}"
99124
100- echo "image=${FULL_IMAGE}" >> $GITHUB_OUTPUT
101- echo "image_name=${IMAGE_NAME}" >> $GITHUB_OUTPUT
102- echo "tag=${TAG}" >> $GITHUB_OUTPUT
103- echo "registry=${{ inputs.registry }}" >> $GITHUB_OUTPUT
125+ printf 'image=%s\nimage_name=%s\ntag=%s\nregistry=%s\n' \
126+ "${FULL_IMAGE}" "${IMAGE_NAME}" "${TAG}" "${INPUT_REGISTRY}" >> "${GITHUB_OUTPUT}"
104127
105128 echo "Building: ${FULL_IMAGE}"
106129
107130 - name: Set up QEMU
108- uses: docker/setup-qemu-action@v3
131+ uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3
109132
110133 - name: Set up Docker Buildx
111- uses: docker/setup-buildx-action@v3
134+ uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
112135
113136 - name: Log in to Gordon registry
114- uses: docker/login-action@v3
137+ uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
115138 with:
116139 registry: ${{ inputs.registry }}
117140 username: ${{ inputs.username }}
@@ -120,16 +143,25 @@ runs:
120143 - name: Build tags
121144 id: tags
122145 shell: bash
146+ env:
147+ META_IMAGE: ${{ steps.meta.outputs.image }}
148+ META_IMAGE_NAME: ${{ steps.meta.outputs.image_name }}
149+ INPUT_REGISTRY: ${{ inputs.registry }}
150+ INPUT_PUSH_LATEST: ${{ inputs.push-latest }}
123151 run: |
124- TAGS="${{ steps.meta.outputs.image }}"
125- if [[ "${{ inputs.push-latest }}" == "true" ]]; then
126- TAGS="${TAGS}"$'\n'"${{ inputs.registry }}/${{ steps.meta.outputs.image_name }}:latest"
152+ TAGS="${META_IMAGE}"
153+ if [[ "${INPUT_PUSH_LATEST}" == "true" ]]; then
154+ TAGS="${TAGS}"$'\n'"${INPUT_REGISTRY}/${META_IMAGE_NAME}:latest"
127155 fi
128- printf "tags<<EOF\n%s\nEOF\n" "$TAGS" >> "$GITHUB_OUTPUT"
156+ DELIMITER="gordon_$(openssl rand -hex 16)"
157+ while grep -Fqx "$DELIMITER" <<< "$TAGS"; do
158+ DELIMITER="gordon_$(openssl rand -hex 16)"
159+ done
160+ printf 'tags<<%s\n%s\n%s\n' "$DELIMITER" "$TAGS" "$DELIMITER" >> "$GITHUB_OUTPUT"
129161
130162 - name: Build and push
131163 id: push
132- uses: docker/build-push-action@v6
164+ uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6
133165 env:
134166 DOCKER_BUILD_SUMMARY: false
135167 with:
@@ -137,7 +169,6 @@ runs:
137169 file: ${{ inputs.dockerfile }}
138170 push: true
139171 tags: ${{ steps.tags.outputs.tags }}
140- build-args: ${{ inputs.build-args }}
141172 platforms: ${{ inputs.platforms }}
142173 cache-from: ${{ inputs.cache-from }}
143174 cache-to: ${{ inputs.cache-to }}
@@ -145,13 +176,17 @@ runs:
145176
146177 - name: Summary
147178 shell: bash
179+ env:
180+ META_IMAGE: ${{ steps.meta.outputs.image }}
181+ META_TAG: ${{ steps.meta.outputs.tag }}
182+ PUSH_DIGEST: ${{ steps.push.outputs.digest }}
148183 run: |
149- echo "## Gordon Deploy Summary" >> $GITHUB_STEP_SUMMARY
184+ echo "## Gordon Deploy Summary" >> "$GITHUB_STEP_SUMMARY"
150185 echo "" >> $GITHUB_STEP_SUMMARY
151186 echo "| Field | Value |" >> $GITHUB_STEP_SUMMARY
152187 echo "|-------|-------|" >> $GITHUB_STEP_SUMMARY
153- echo "| Image | \`${{ steps.meta.outputs.image }}\` |" >> $GITHUB_STEP_SUMMARY
154- echo "| Tag | \`${{ steps.meta.outputs.tag }}\` |" >> $GITHUB_STEP_SUMMARY
155- echo "| Digest | \`${{ steps.push.outputs.digest }}\` |" >> $GITHUB_STEP_SUMMARY
188+ printf '| Image | `%s` |\n' "$META_IMAGE" >> "$GITHUB_STEP_SUMMARY"
189+ printf '| Tag | `%s` |\n' "$META_TAG" >> "$GITHUB_STEP_SUMMARY"
190+ printf '| Digest | `%s` |\n' "$PUSH_DIGEST" >> "$GITHUB_STEP_SUMMARY"
156191 echo "" >> $GITHUB_STEP_SUMMARY
157192 echo "Container deployed to Gordon registry and will be automatically deployed." >> $GITHUB_STEP_SUMMARY
@@ -32,10 +32,6 @@ inputs:
32 description: 'Build context path (defaults to .)'32 description: 'Build context path (defaults to .)'
33 required: false33 required: false
34 default: '.'34 default: '.'
35- build-args:
36- description: 'Build arguments (one per line, KEY=value format)'
37- required: false
38- default: ''
39 platforms:35 platforms:
40 description: 'Target platforms (e.g., linux/amd64,linux/arm64)'36 description: 'Target platforms (e.g., linux/amd64,linux/arm64)'
41 required: false37 required: false
@@ -69,49 +65,76 @@ runs:
69 steps:65 steps:
70 - name: Validate inputs66 - name: Validate inputs
71 shell: bash67 shell: bash
68+ env:
69+ INPUT_DOCKERFILE: ${{ inputs.dockerfile }}
72 run: |70 run: |
73- if [[ ! -f "${{ inputs.dockerfile }}" ]]; then71+ if [[ ! -f "${INPUT_DOCKERFILE}" ]]; then
74- echo "::error::Dockerfile not found at ${{ inputs.dockerfile }}"72+ echo "::error::Dockerfile not found at ${INPUT_DOCKERFILE}"
75 exit 173 exit 1
76 fi74 fi
77 75
78 - name: Extract metadata76 - name: Extract metadata
79 id: meta77 id: meta
80 shell: bash78 shell: bash
79+ env:
80+ INPUT_IMAGE: ${{ inputs.image }}
81+ INPUT_TAG: ${{ inputs.tag }}
82+ INPUT_REGISTRY: ${{ inputs.registry }}
81 run: |83 run: |
82- # Determine image name84+ reject_line_breaks() {
83- IMAGE_NAME="${{ inputs.image }}"85+ [[ "$2" != *$'\n'* && "$2" != *$'\r'* ]] || {
84- if [[ -z "$IMAGE_NAME" ]]; then86+ echo "::error::$1 must not contain line breaks"
85- IMAGE_NAME="${GITHUB_REPOSITORY#*/}"87+ exit 1
86- fi88+ }
89+ }
90+ reject_line_breaks "registry" "$INPUT_REGISTRY"
91+ reject_line_breaks "image" "$INPUT_IMAGE"
92+ reject_line_breaks "tag" "$INPUT_TAG"
87 93
88- # Extract tag from ref (or override)94+ IMAGE_NAME="${INPUT_IMAGE:-${GITHUB_REPOSITORY#*/}}"
89- if [[ -n "${{ inputs.tag }}" ]]; then95+ if [[ -n "${INPUT_TAG}" ]]; then
90- TAG="${{ inputs.tag }}"96+ TAG="${INPUT_TAG}"
91 elif [[ "$GITHUB_REF" == refs/tags/* ]]; then97 elif [[ "$GITHUB_REF" == refs/tags/* ]]; then
92 TAG="${GITHUB_REF#refs/tags/}"98 TAG="${GITHUB_REF#refs/tags/}"
93 else99 else
94- TAG="$(echo "$GITHUB_SHA" | head -c 7)"100+ TAG="${GITHUB_SHA:0:7}"
101+ fi
102+
103+ [[ "$INPUT_REGISTRY" =~ ^[a-zA-Z0-9.-]+(:[0-9]{1,5})?$ ]] || {
104+ echo "::error::registry must be a hostname with an optional port"
105+ exit 1
106+ }
107+ if [[ "$INPUT_REGISTRY" =~ :([0-9]+)$ ]]; then
108+ PORT="${BASH_REMATCH[1]}"
109+ [[ "$PORT" != 0* && $((10#$PORT)) -ge 1 && $((10#$PORT)) -le 65535 ]] || {
110+ echo "::error::registry port must be between 1 and 65535 without leading zeros"
111+ exit 1
112+ }
95 fi113 fi
114+ [[ "$IMAGE_NAME" =~ ^[a-z0-9]+([._-][a-z0-9]+)*(/[a-z0-9]+([._-][a-z0-9]+)*)*$ ]] || {
115+ echo "::error::image contains invalid Docker repository components"
116+ exit 1
117+ }
118+ [[ "$TAG" =~ ^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$ ]] || {
119+ echo "::error::tag is not a valid Docker tag"
120+ exit 1
121+ }
96 122
97- # Full image reference123+ FULL_IMAGE="${INPUT_REGISTRY}/${IMAGE_NAME}:${TAG}"
98- FULL_IMAGE="${{ inputs.registry }}/${IMAGE_NAME}:${TAG}"
99 124
100- echo "image=${FULL_IMAGE}" >> $GITHUB_OUTPUT125+ printf 'image=%s\nimage_name=%s\ntag=%s\nregistry=%s\n' \
101- echo "image_name=${IMAGE_NAME}" >> $GITHUB_OUTPUT126+ "${FULL_IMAGE}" "${IMAGE_NAME}" "${TAG}" "${INPUT_REGISTRY}" >> "${GITHUB_OUTPUT}"
102- echo "tag=${TAG}" >> $GITHUB_OUTPUT
103- echo "registry=${{ inputs.registry }}" >> $GITHUB_OUTPUT
104 127
105 echo "Building: ${FULL_IMAGE}"128 echo "Building: ${FULL_IMAGE}"
106 129
107 - name: Set up QEMU130 - name: Set up QEMU
108- uses: docker/setup-qemu-action@v3131+ uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3
109 132
110 - name: Set up Docker Buildx133 - name: Set up Docker Buildx
111- uses: docker/setup-buildx-action@v3134+ uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
112 135
113 - name: Log in to Gordon registry136 - name: Log in to Gordon registry
114- uses: docker/login-action@v3137+ uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
115 with:138 with:
116 registry: ${{ inputs.registry }}139 registry: ${{ inputs.registry }}
117 username: ${{ inputs.username }}140 username: ${{ inputs.username }}
@@ -120,16 +143,25 @@ runs:
120 - name: Build tags143 - name: Build tags
121 id: tags144 id: tags
122 shell: bash145 shell: bash
146+ env:
147+ META_IMAGE: ${{ steps.meta.outputs.image }}
148+ META_IMAGE_NAME: ${{ steps.meta.outputs.image_name }}
149+ INPUT_REGISTRY: ${{ inputs.registry }}
150+ INPUT_PUSH_LATEST: ${{ inputs.push-latest }}
123 run: |151 run: |
124- TAGS="${{ steps.meta.outputs.image }}"152+ TAGS="${META_IMAGE}"
125- if [[ "${{ inputs.push-latest }}" == "true" ]]; then153+ if [[ "${INPUT_PUSH_LATEST}" == "true" ]]; then
126- TAGS="${TAGS}"$'\n'"${{ inputs.registry }}/${{ steps.meta.outputs.image_name }}:latest"154+ TAGS="${TAGS}"$'\n'"${INPUT_REGISTRY}/${META_IMAGE_NAME}:latest"
127 fi155 fi
128- printf "tags<<EOF\n%s\nEOF\n" "$TAGS" >> "$GITHUB_OUTPUT"156+ DELIMITER="gordon_$(openssl rand -hex 16)"
157+ while grep -Fqx "$DELIMITER" <<< "$TAGS"; do
158+ DELIMITER="gordon_$(openssl rand -hex 16)"
159+ done
160+ printf 'tags<<%s\n%s\n%s\n' "$DELIMITER" "$TAGS" "$DELIMITER" >> "$GITHUB_OUTPUT"
129 161
130 - name: Build and push162 - name: Build and push
131 id: push163 id: push
132- uses: docker/build-push-action@v6164+ uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6
133 env:165 env:
134 DOCKER_BUILD_SUMMARY: false166 DOCKER_BUILD_SUMMARY: false
135 with:167 with:
@@ -137,7 +169,6 @@ runs:
137 file: ${{ inputs.dockerfile }}169 file: ${{ inputs.dockerfile }}
138 push: true170 push: true
139 tags: ${{ steps.tags.outputs.tags }}171 tags: ${{ steps.tags.outputs.tags }}
140- build-args: ${{ inputs.build-args }}
141 platforms: ${{ inputs.platforms }}172 platforms: ${{ inputs.platforms }}
142 cache-from: ${{ inputs.cache-from }}173 cache-from: ${{ inputs.cache-from }}
143 cache-to: ${{ inputs.cache-to }}174 cache-to: ${{ inputs.cache-to }}
@@ -145,13 +176,17 @@ runs:
145 176
146 - name: Summary177 - name: Summary
147 shell: bash178 shell: bash
179+ env:
180+ META_IMAGE: ${{ steps.meta.outputs.image }}
181+ META_TAG: ${{ steps.meta.outputs.tag }}
182+ PUSH_DIGEST: ${{ steps.push.outputs.digest }}
148 run: |183 run: |
149- echo "## Gordon Deploy Summary" >> $GITHUB_STEP_SUMMARY184+ echo "## Gordon Deploy Summary" >> "$GITHUB_STEP_SUMMARY"
150 echo "" >> $GITHUB_STEP_SUMMARY185 echo "" >> $GITHUB_STEP_SUMMARY
151 echo "| Field | Value |" >> $GITHUB_STEP_SUMMARY186 echo "| Field | Value |" >> $GITHUB_STEP_SUMMARY
152 echo "|-------|-------|" >> $GITHUB_STEP_SUMMARY187 echo "|-------|-------|" >> $GITHUB_STEP_SUMMARY
153- echo "| Image | \`${{ steps.meta.outputs.image }}\` |" >> $GITHUB_STEP_SUMMARY188+ printf '| Image | `%s` |\n' "$META_IMAGE" >> "$GITHUB_STEP_SUMMARY"
154- echo "| Tag | \`${{ steps.meta.outputs.tag }}\` |" >> $GITHUB_STEP_SUMMARY189+ printf '| Tag | `%s` |\n' "$META_TAG" >> "$GITHUB_STEP_SUMMARY"
155- echo "| Digest | \`${{ steps.push.outputs.digest }}\` |" >> $GITHUB_STEP_SUMMARY190+ printf '| Digest | `%s` |\n' "$PUSH_DIGEST" >> "$GITHUB_STEP_SUMMARY"
156 echo "" >> $GITHUB_STEP_SUMMARY191 echo "" >> $GITHUB_STEP_SUMMARY
157 echo "Container deployed to Gordon registry and will be automatically deployed." >> $GITHUB_STEP_SUMMARY192 echo "Container deployed to Gordon registry and will be automatically deployed." >> $GITHUB_STEP_SUMMARY
M .github/workflows/release.yml +4 -3
@@ -13,16 +13,17 @@ jobs:
1313 name: Release
1414 runs-on: ubuntu-latest
1515 steps:
16- - uses: actions/checkout@v6
16+ - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
1717 with:
1818 fetch-depth: 0
19+ persist-credentials: false
1920
20- - uses: actions/setup-go@v5
21+ - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
2122 with:
2223 go-version: stable
2324
2425 - name: Run GoReleaser
25- uses: goreleaser/goreleaser-action@v6
26+ uses: goreleaser/goreleaser-action@e435ccd777264be153ace6237001ef4d979d3a7a # v6
2627 with:
2728 distribution: goreleaser
2829 version: "~> v2"
@@ -13,16 +13,17 @@ jobs:
13 name: Release13 name: Release
14 runs-on: ubuntu-latest14 runs-on: ubuntu-latest
15 steps:15 steps:
16- - uses: actions/checkout@v616+ - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
17 with:17 with:
18 fetch-depth: 018 fetch-depth: 0
19+ persist-credentials: false
19 20
20- - uses: actions/setup-go@v521+ - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
21 with:22 with:
22 go-version: stable23 go-version: stable
23 24
24 - name: Run GoReleaser25 - name: Run GoReleaser
25- uses: goreleaser/goreleaser-action@v626+ uses: goreleaser/goreleaser-action@e435ccd777264be153ace6237001ef4d979d3a7a # v6
26 with:27 with:
27 distribution: goreleaser28 distribution: goreleaser
28 version: "~> v2"29 version: "~> v2"
M docs/deployment/github-actions.md +8 -4
@@ -138,11 +138,15 @@ jobs:
138138 - name: Build and Deploy
139139 env:
140140 GORDON_TOKEN: ${{ secrets.GORDON_TOKEN }}
141+ GORDON_REMOTE: ${{ secrets.GORDON_REMOTE }}
142+ DEPLOY_TAG: ${{ inputs.tag }}
141143 run: |
142- gordon push --build \
143- --remote ${{ secrets.GORDON_REMOTE }} \
144- ${{ github.event.inputs.tag && format('--tag {0}', github.event.inputs.tag) || '' }} \
145- --no-confirm
144+ args=(push --build --remote "$GORDON_REMOTE" --no-confirm)
145+ if [[ -n "$DEPLOY_TAG" ]]; then
146+ [[ "$DEPLOY_TAG" =~ ^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$ ]] || exit 1
147+ args+=(--tag "$DEPLOY_TAG")
148+ fi
149+ gordon "${args[@]}"
146150 ```
147151
148152 #### Monorepo
@@ -138,11 +138,15 @@ jobs:
138 - name: Build and Deploy138 - name: Build and Deploy
139 env:139 env:
140 GORDON_TOKEN: ${{ secrets.GORDON_TOKEN }}140 GORDON_TOKEN: ${{ secrets.GORDON_TOKEN }}
141+ GORDON_REMOTE: ${{ secrets.GORDON_REMOTE }}
142+ DEPLOY_TAG: ${{ inputs.tag }}
141 run: |143 run: |
142- gordon push --build \144+ args=(push --build --remote "$GORDON_REMOTE" --no-confirm)
143- --remote ${{ secrets.GORDON_REMOTE }} \145+ if [[ -n "$DEPLOY_TAG" ]]; then
144- ${{ github.event.inputs.tag && format('--tag {0}', github.event.inputs.tag) || '' }} \146+ [[ "$DEPLOY_TAG" =~ ^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$ ]] || exit 1
145- --no-confirm147+ args+=(--tag "$DEPLOY_TAG")
148+ fi
149+ gordon "${args[@]}"
146 ```150 ```
147 151
148 #### Monorepo152 #### Monorepo
M docs/deployment/rollback.md +24 -9
@@ -147,21 +147,36 @@ jobs:
147147 runs-on: ubuntu-latest
148148 steps:
149149 - name: Login to Registry
150- run: |
151- echo "${{ secrets.GORDON_TOKEN }}" | \
152- docker login -u ${{ secrets.GORDON_USERNAME }} --password-stdin ${{ secrets.GORDON_REGISTRY }}
150+ env:
151+ GORDON_TOKEN: ${{ secrets.GORDON_TOKEN }}
152+ GORDON_USERNAME: ${{ secrets.GORDON_USERNAME }}
153+ GORDON_REGISTRY: ${{ secrets.GORDON_REGISTRY }}
154+ run: printf '%s' "$GORDON_TOKEN" | docker login -u "$GORDON_USERNAME" --password-stdin "$GORDON_REGISTRY"
153155
154156 - name: Rollback
157+ env:
158+ GORDON_REGISTRY: ${{ secrets.GORDON_REGISTRY }}
159+ VERSION: ${{ inputs.version }}
155160 run: |
156- docker pull ${{ secrets.GORDON_REGISTRY }}/myapp:${{ github.event.inputs.version }}
157- docker tag ${{ secrets.GORDON_REGISTRY }}/myapp:${{ github.event.inputs.version }} \
158- ${{ secrets.GORDON_REGISTRY }}/myapp:latest
159- docker push ${{ secrets.GORDON_REGISTRY }}/myapp:latest
161+ [[ "$VERSION" =~ ^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$ ]] || exit 1
162+ REPOSITORY="$GORDON_REGISTRY/myapp"
163+ SOURCE_IMAGE="$REPOSITORY:$VERSION"
164+ LATEST_IMAGE="$REPOSITORY:latest"
165+ SOURCE_DIGEST="$(docker buildx imagetools inspect "$SOURCE_IMAGE" --format '{{json .Manifest}}' | jq -er '.digest')"
166+ IMMUTABLE_IMAGE="$REPOSITORY@$SOURCE_DIGEST"
167+ docker buildx imagetools create --tag "$LATEST_IMAGE" "$IMMUTABLE_IMAGE"
168+ LATEST_DIGEST="$(docker buildx imagetools inspect "$LATEST_IMAGE" --format '{{json .Manifest}}' | jq -er '.digest')"
169+ [[ "$LATEST_DIGEST" == "$SOURCE_DIGEST" ]] || {
170+ echo "rollback verification failed: latest resolved to $LATEST_DIGEST, expected $SOURCE_DIGEST" >&2
171+ exit 1
172+ }
160173
161174 - name: Summary
175+ env:
176+ VERSION: ${{ inputs.version }}
162177 run: |
163- echo "## Rollback Complete" >> $GITHUB_STEP_SUMMARY
164- echo "Rolled back to version: ${{ github.event.inputs.version }}" >> $GITHUB_STEP_SUMMARY
178+ echo "## Rollback Complete" >> "$GITHUB_STEP_SUMMARY"
179+ printf 'Rolled back to version: %s\n' "$VERSION" >> "$GITHUB_STEP_SUMMARY"
165180 ```
166181
167182 ### Rollback Script
@@ -147,21 +147,36 @@ jobs:
147 runs-on: ubuntu-latest147 runs-on: ubuntu-latest
148 steps:148 steps:
149 - name: Login to Registry149 - name: Login to Registry
150- run: |150+ env:
151- echo "${{ secrets.GORDON_TOKEN }}" | \151+ GORDON_TOKEN: ${{ secrets.GORDON_TOKEN }}
152- docker login -u ${{ secrets.GORDON_USERNAME }} --password-stdin ${{ secrets.GORDON_REGISTRY }}152+ GORDON_USERNAME: ${{ secrets.GORDON_USERNAME }}
153+ GORDON_REGISTRY: ${{ secrets.GORDON_REGISTRY }}
154+ run: printf '%s' "$GORDON_TOKEN" | docker login -u "$GORDON_USERNAME" --password-stdin "$GORDON_REGISTRY"
153 155
154 - name: Rollback156 - name: Rollback
157+ env:
158+ GORDON_REGISTRY: ${{ secrets.GORDON_REGISTRY }}
159+ VERSION: ${{ inputs.version }}
155 run: |160 run: |
156- docker pull ${{ secrets.GORDON_REGISTRY }}/myapp:${{ github.event.inputs.version }}161+ [[ "$VERSION" =~ ^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$ ]] || exit 1
157- docker tag ${{ secrets.GORDON_REGISTRY }}/myapp:${{ github.event.inputs.version }} \162+ REPOSITORY="$GORDON_REGISTRY/myapp"
158- ${{ secrets.GORDON_REGISTRY }}/myapp:latest163+ SOURCE_IMAGE="$REPOSITORY:$VERSION"
159- docker push ${{ secrets.GORDON_REGISTRY }}/myapp:latest164+ LATEST_IMAGE="$REPOSITORY:latest"
165+ SOURCE_DIGEST="$(docker buildx imagetools inspect "$SOURCE_IMAGE" --format '{{json .Manifest}}' | jq -er '.digest')"
166+ IMMUTABLE_IMAGE="$REPOSITORY@$SOURCE_DIGEST"
167+ docker buildx imagetools create --tag "$LATEST_IMAGE" "$IMMUTABLE_IMAGE"
168+ LATEST_DIGEST="$(docker buildx imagetools inspect "$LATEST_IMAGE" --format '{{json .Manifest}}' | jq -er '.digest')"
169+ [[ "$LATEST_DIGEST" == "$SOURCE_DIGEST" ]] || {
170+ echo "rollback verification failed: latest resolved to $LATEST_DIGEST, expected $SOURCE_DIGEST" >&2
171+ exit 1
172+ }
160 173
161 - name: Summary174 - name: Summary
175+ env:
176+ VERSION: ${{ inputs.version }}
162 run: |177 run: |
163- echo "## Rollback Complete" >> $GITHUB_STEP_SUMMARY178+ echo "## Rollback Complete" >> "$GITHUB_STEP_SUMMARY"
164- echo "Rolled back to version: ${{ github.event.inputs.version }}" >> $GITHUB_STEP_SUMMARY179+ printf 'Rolled back to version: %s\n' "$VERSION" >> "$GITHUB_STEP_SUMMARY"
165 ```180 ```
166 181
167 ### Rollback Script182 ### Rollback Script
M docs/installation.md +31 -8
@@ -15,46 +15,68 @@ Detailed installation guide for production environments.
1515 ### Quick Install (Recommended)
1616
1717 ```bash
18-curl -fsSL https://gordon.bnema.dev/install | bash
18+(
19+ set -euo pipefail
20+ installer="$(mktemp)"
21+ trap 'rm -f "$installer"' EXIT
22+ curl -fsSL --output "$installer" https://gordon.bnema.dev/install
23+ if command -v less >/dev/null 2>&1; then
24+ less "$installer"
25+ else
26+ cat "$installer"
27+ fi
28+ bash "$installer"
29+)
1930 ```
2031
21-This script automatically detects your OS (Linux/macOS) and architecture (amd64/arm64), downloads the appropriate binary from GitHub releases, and installs it to `/usr/local/bin`.
32+Download and inspect the installer before executing it. The installer verifies the release archive checksum before installation and automatically detects your OS (Linux/macOS) and architecture (amd64/arm64), downloads the appropriate binary from GitHub releases, and installs it to `/usr/local/bin`.
2233
2334 ### Manual Installation
2435
36+Manual archive installation requires downloading the matching `checksums.txt` release asset and verifying the archive's SHA-256 checksum before extraction. The checksum-verifying installer above is recommended.
37+
2538 **Linux (x86_64)**
39+
2640 ```bash
27-wget https://github.com/bnema/gordon/releases/latest/download/gordon_linux_amd64.tar.gz
41+wget https://github.com/bnema/gordon/releases/latest/download/{gordon_linux_amd64.tar.gz,checksums.txt}
42+grep -E '^[[:xdigit:]]{64} ([* ]?)gordon_linux_amd64\.tar\.gz$' checksums.txt | sha256sum --check --strict
2843 tar -xzf gordon_linux_amd64.tar.gz
2944 chmod +x gordon
3045 sudo mv gordon /usr/local/bin/
3146 ```
3247
3348 **Linux (ARM64)** - for Raspberry Pi 4, AWS Graviton, Oracle Ampere, etc.
49+
3450 ```bash
35-wget https://github.com/bnema/gordon/releases/latest/download/gordon_linux_arm64.tar.gz
51+wget https://github.com/bnema/gordon/releases/latest/download/{gordon_linux_arm64.tar.gz,checksums.txt}
52+grep -E '^[[:xdigit:]]{64} ([* ]?)gordon_linux_arm64\.tar\.gz$' checksums.txt | sha256sum --check --strict
3653 tar -xzf gordon_linux_arm64.tar.gz
3754 chmod +x gordon
3855 sudo mv gordon /usr/local/bin/
3956 ```
4057
4158 **macOS (Apple Silicon)**
59+
4260 ```bash
43-curl -LO https://github.com/bnema/gordon/releases/latest/download/gordon_darwin_arm64.tar.gz
61+curl -LO https://github.com/bnema/gordon/releases/latest/download/{gordon_darwin_arm64.tar.gz,checksums.txt}
62+grep -E '^[[:xdigit:]]{64} ([* ]?)gordon_darwin_arm64\.tar\.gz$' checksums.txt | shasum -a 256 --check
4463 tar -xzf gordon_darwin_arm64.tar.gz
4564 chmod +x gordon
4665 sudo mv gordon /usr/local/bin/
4766 ```
4867
4968 **macOS (Intel)**
69+
5070 ```bash
51-curl -LO https://github.com/bnema/gordon/releases/latest/download/gordon_darwin_amd64.tar.gz
71+curl -LO https://github.com/bnema/gordon/releases/latest/download/{gordon_darwin_amd64.tar.gz,checksums.txt}
72+grep -E '^[[:xdigit:]]{64} ([* ]?)gordon_darwin_amd64\.tar\.gz$' checksums.txt | shasum -a 256 --check
5273 tar -xzf gordon_darwin_amd64.tar.gz
5374 chmod +x gordon
5475 sudo mv gordon /usr/local/bin/
5576 ```
5677
5778 Verify installation:
79+
5880 ```bash
5981 gordon version
6082 ```
@@ -74,8 +96,9 @@ sudo mv gordon /usr/local/bin/
7496 ### Docker
7597
7698 ```bash
77-# Install Docker
78-curl -fsSL https://get.docker.com | sh
99+# Install Docker from your distribution's signed package repository.
100+sudo apt update
101+sudo apt install docker.io
79102
80103 # Add user to docker group
81104 sudo usermod -aG docker $USER
@@ -15,46 +15,68 @@ Detailed installation guide for production environments.
15 ### Quick Install (Recommended)15 ### Quick Install (Recommended)
16 16
17 ```bash17 ```bash
18-curl -fsSL https://gordon.bnema.dev/install | bash18+(
19+ set -euo pipefail
20+ installer="$(mktemp)"
21+ trap 'rm -f "$installer"' EXIT
22+ curl -fsSL --output "$installer" https://gordon.bnema.dev/install
23+ if command -v less >/dev/null 2>&1; then
24+ less "$installer"
25+ else
26+ cat "$installer"
27+ fi
28+ bash "$installer"
29+)
19 ```30 ```
20 31
21-This script automatically detects your OS (Linux/macOS) and architecture (amd64/arm64), downloads the appropriate binary from GitHub releases, and installs it to `/usr/local/bin`.32+Download and inspect the installer before executing it. The installer verifies the release archive checksum before installation and automatically detects your OS (Linux/macOS) and architecture (amd64/arm64), downloads the appropriate binary from GitHub releases, and installs it to `/usr/local/bin`.
22 33
23 ### Manual Installation34 ### Manual Installation
24 35
36+Manual archive installation requires downloading the matching `checksums.txt` release asset and verifying the archive's SHA-256 checksum before extraction. The checksum-verifying installer above is recommended.
37+
25 **Linux (x86_64)**38 **Linux (x86_64)**
39+
26 ```bash40 ```bash
27-wget https://github.com/bnema/gordon/releases/latest/download/gordon_linux_amd64.tar.gz41+wget https://github.com/bnema/gordon/releases/latest/download/{gordon_linux_amd64.tar.gz,checksums.txt}
42+grep -E '^[[:xdigit:]]{64} ([* ]?)gordon_linux_amd64\.tar\.gz$' checksums.txt | sha256sum --check --strict
28 tar -xzf gordon_linux_amd64.tar.gz43 tar -xzf gordon_linux_amd64.tar.gz
29 chmod +x gordon44 chmod +x gordon
30 sudo mv gordon /usr/local/bin/45 sudo mv gordon /usr/local/bin/
31 ```46 ```
32 47
33 **Linux (ARM64)** - for Raspberry Pi 4, AWS Graviton, Oracle Ampere, etc.48 **Linux (ARM64)** - for Raspberry Pi 4, AWS Graviton, Oracle Ampere, etc.
49+
34 ```bash50 ```bash
35-wget https://github.com/bnema/gordon/releases/latest/download/gordon_linux_arm64.tar.gz51+wget https://github.com/bnema/gordon/releases/latest/download/{gordon_linux_arm64.tar.gz,checksums.txt}
52+grep -E '^[[:xdigit:]]{64} ([* ]?)gordon_linux_arm64\.tar\.gz$' checksums.txt | sha256sum --check --strict
36 tar -xzf gordon_linux_arm64.tar.gz53 tar -xzf gordon_linux_arm64.tar.gz
37 chmod +x gordon54 chmod +x gordon
38 sudo mv gordon /usr/local/bin/55 sudo mv gordon /usr/local/bin/
39 ```56 ```
40 57
41 **macOS (Apple Silicon)**58 **macOS (Apple Silicon)**
59+
42 ```bash60 ```bash
43-curl -LO https://github.com/bnema/gordon/releases/latest/download/gordon_darwin_arm64.tar.gz61+curl -LO https://github.com/bnema/gordon/releases/latest/download/{gordon_darwin_arm64.tar.gz,checksums.txt}
62+grep -E '^[[:xdigit:]]{64} ([* ]?)gordon_darwin_arm64\.tar\.gz$' checksums.txt | shasum -a 256 --check
44 tar -xzf gordon_darwin_arm64.tar.gz63 tar -xzf gordon_darwin_arm64.tar.gz
45 chmod +x gordon64 chmod +x gordon
46 sudo mv gordon /usr/local/bin/65 sudo mv gordon /usr/local/bin/
47 ```66 ```
48 67
49 **macOS (Intel)**68 **macOS (Intel)**
69+
50 ```bash70 ```bash
51-curl -LO https://github.com/bnema/gordon/releases/latest/download/gordon_darwin_amd64.tar.gz71+curl -LO https://github.com/bnema/gordon/releases/latest/download/{gordon_darwin_amd64.tar.gz,checksums.txt}
72+grep -E '^[[:xdigit:]]{64} ([* ]?)gordon_darwin_amd64\.tar\.gz$' checksums.txt | shasum -a 256 --check
52 tar -xzf gordon_darwin_amd64.tar.gz73 tar -xzf gordon_darwin_amd64.tar.gz
53 chmod +x gordon74 chmod +x gordon
54 sudo mv gordon /usr/local/bin/75 sudo mv gordon /usr/local/bin/
55 ```76 ```
56 77
57 Verify installation:78 Verify installation:
79+
58 ```bash80 ```bash
59 gordon version81 gordon version
60 ```82 ```
@@ -74,8 +96,9 @@ sudo mv gordon /usr/local/bin/
74 ### Docker96 ### Docker
75 97
76 ```bash98 ```bash
77-# Install Docker99+# Install Docker from your distribution's signed package repository.
78-curl -fsSL https://get.docker.com | sh100+sudo apt update
101+sudo apt install docker.io
79 102
80 # Add user to docker group103 # Add user to docker group
81 sudo usermod -aG docker $USER104 sudo usermod -aG docker $USER
M internal/adapters/in/cli/auth.go +32 -17
@@ -206,31 +206,37 @@ available while Gordon is running.`,
206206
207207 // newAuthLoginCmd creates the login command for remote authentication.
208208 func newAuthLoginCmd() *cobra.Command {
209- var token string
209+ var tokenFile string
210+ var jsonOut bool
210211
211212 cmd := &cobra.Command{
212213 Use: "login",
213214 Short: "Authenticate with a Gordon server",
214215 Long: `Store or verify a token for a Gordon server remote.
215216
216-With --token: stores the token and verifies it.
217-Without --token: verifies the existing stored token still works.
217+With --token-file: reads, stores, and verifies a token without exposing it in argv.
218+Without --token-file: verifies the existing stored token still works.
218219
219220 Generate a token on the server with: gordon auth token generate
220221
221222 Examples:
222- gordon auth login --token <token> Store token for active remote
223- gordon auth login --remote prod --token <tok> Store token for specific remote
223+ gordon auth login --token-file ./token Store token for active remote
224+ gordon auth login --remote prod --token-file ./token
224225 gordon auth login Verify existing token`,
225226 RunE: func(cmd *cobra.Command, args []string) error {
226- if token != "" {
227- return runAuthLoginWithToken(cmd.Context(), token, cmd.OutOrStdout())
227+ if tokenFile != "" {
228+ token, err := readProtectedSecretFile(tokenFile)
229+ if err != nil {
230+ return fmt.Errorf("read token: %w", err)
231+ }
232+ return runAuthLoginWithToken(cmd.Context(), token, cmd.OutOrStdout(), jsonOut)
228233 }
229- return runAuthLoginVerify(cmd.Context(), cmd.OutOrStdout())
234+ return runAuthLoginVerify(cmd.Context(), cmd.OutOrStdout(), jsonOut)
230235 },
231236 }
232237
233- cmd.Flags().StringVarP(&token, "token", "t", "", "Authentication token to store for the remote")
238+ cmd.Flags().StringVar(&tokenFile, "token-file", "", "Read authentication token from a mode 0600 file")
239+ cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
234240
235241 return cmd
236242 }
@@ -247,7 +253,7 @@ func resolveRequiredRemote(flagToken string) (*remote.ResolvedRemote, error) {
247253 return resolved, nil
248254 }
249255
250-func runAuthLoginWithToken(ctx context.Context, token string, out io.Writer) error {
256+func runAuthLoginWithToken(ctx context.Context, token string, out io.Writer, jsonOut bool) error {
251257 token = strings.TrimSpace(token)
252258 if token == "" {
253259 return fmt.Errorf("token cannot be empty")
@@ -264,19 +270,26 @@ func runAuthLoginWithToken(ctx context.Context, token string, out io.Writer) err
264270 defer cancel()
265271 verified := true
266272 if _, err := client.GetStatus(verifyCtx); err != nil {
267- _ = cliWriteLine(out, styles.RenderWarning(fmt.Sprintf("Token verification failed: %v", err)))
273+ if !jsonOut {
274+ _ = cliWriteLine(out, styles.RenderWarning(fmt.Sprintf("Token verification failed: %v", err)))
275+ }
268276 verified = false
269277 }
270278
271279 // Store token — requires a named remote.
272280 if resolved.Name == "" {
273- _ = cliWriteLine(out, styles.RenderWarning("Ad-hoc URL: token verified but cannot be stored. Use 'gordon remotes add' to save this remote."))
274- return nil
281+ if jsonOut {
282+ return writeJSON(out, map[string]any{"remote": resolved.DisplayName(), "stored": false, "verified": verified})
283+ }
284+ return cliWriteLine(out, styles.RenderWarning("Ad-hoc URL: token verified but cannot be stored. Use 'gordon remotes add' to save this remote."))
275285 }
276286 if err := remote.UpdateRemoteToken(resolved.Name, token); err != nil {
277287 return fmt.Errorf("failed to save token: %w", err)
278288 }
279289
290+ if jsonOut {
291+ return writeJSON(out, map[string]any{"remote": resolved.Name, "stored": true, "verified": verified})
292+ }
280293 _ = cliWriteLine(out, "")
281294 _ = cliWriteLine(out, styles.RenderSuccess(fmt.Sprintf("Token stored for remote '%s'", resolved.Name)))
282295 if verified {
@@ -285,13 +298,13 @@ func runAuthLoginWithToken(ctx context.Context, token string, out io.Writer) err
285298 return nil
286299 }
287300
288-func runAuthLoginVerify(ctx context.Context, out io.Writer) error {
301+func runAuthLoginVerify(ctx context.Context, out io.Writer, jsonOut bool) error {
289302 resolved, err := resolveRequiredRemote(tokenFlag)
290303 if err != nil {
291304 return err
292305 }
293306 if resolved.Token == "" {
294- return fmt.Errorf("no token stored for remote '%s'; use: gordon auth login --token <token>", resolved.DisplayName())
307+ return fmt.Errorf("no token stored for remote '%s'; use: gordon auth login --token-file <path>", resolved.DisplayName())
295308 }
296309
297310 client := remote.NewClient(resolved.URL, remoteClientOptions(resolved.Token, resolved.InsecureTLS)...)
@@ -303,8 +316,10 @@ func runAuthLoginVerify(ctx context.Context, out io.Writer) error {
303316 return fmt.Errorf("authentication failed")
304317 }
305318
306- _ = cliWriteLine(out, styles.RenderSuccess(fmt.Sprintf("Authenticated to '%s'", resolved.DisplayName())))
307- return nil
319+ if jsonOut {
320+ return writeJSON(out, map[string]any{"remote": resolved.DisplayName(), "authenticated": true})
321+ }
322+ return cliWriteLine(out, styles.RenderSuccess(fmt.Sprintf("Authenticated to '%s'", resolved.DisplayName())))
308323 }
309324
310325 // newAuthShowTokenCmd creates the show-token command.
@@ -206,31 +206,37 @@ available while Gordon is running.`,
206 206
207 // newAuthLoginCmd creates the login command for remote authentication.207 // newAuthLoginCmd creates the login command for remote authentication.
208 func newAuthLoginCmd() *cobra.Command {208 func newAuthLoginCmd() *cobra.Command {
209- var token string209+ var tokenFile string
210+ var jsonOut bool
210 211
211 cmd := &cobra.Command{212 cmd := &cobra.Command{
212 Use: "login",213 Use: "login",
213 Short: "Authenticate with a Gordon server",214 Short: "Authenticate with a Gordon server",
214 Long: `Store or verify a token for a Gordon server remote.215 Long: `Store or verify a token for a Gordon server remote.
215 216
216-With --token: stores the token and verifies it.217+With --token-file: reads, stores, and verifies a token without exposing it in argv.
217-Without --token: verifies the existing stored token still works.218+Without --token-file: verifies the existing stored token still works.
218 219
219 Generate a token on the server with: gordon auth token generate220 Generate a token on the server with: gordon auth token generate
220 221
221 Examples:222 Examples:
222- gordon auth login --token <token> Store token for active remote223+ gordon auth login --token-file ./token Store token for active remote
223- gordon auth login --remote prod --token <tok> Store token for specific remote224+ gordon auth login --remote prod --token-file ./token
224 gordon auth login Verify existing token`,225 gordon auth login Verify existing token`,
225 RunE: func(cmd *cobra.Command, args []string) error {226 RunE: func(cmd *cobra.Command, args []string) error {
226- if token != "" {227+ if tokenFile != "" {
227- return runAuthLoginWithToken(cmd.Context(), token, cmd.OutOrStdout())228+ token, err := readProtectedSecretFile(tokenFile)
229+ if err != nil {
230+ return fmt.Errorf("read token: %w", err)
231+ }
232+ return runAuthLoginWithToken(cmd.Context(), token, cmd.OutOrStdout(), jsonOut)
228 }233 }
229- return runAuthLoginVerify(cmd.Context(), cmd.OutOrStdout())234+ return runAuthLoginVerify(cmd.Context(), cmd.OutOrStdout(), jsonOut)
230 },235 },
231 }236 }
232 237
233- cmd.Flags().StringVarP(&token, "token", "t", "", "Authentication token to store for the remote")238+ cmd.Flags().StringVar(&tokenFile, "token-file", "", "Read authentication token from a mode 0600 file")
239+ cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
234 240
235 return cmd241 return cmd
236 }242 }
@@ -247,7 +253,7 @@ func resolveRequiredRemote(flagToken string) (*remote.ResolvedRemote, error) {
247 return resolved, nil253 return resolved, nil
248 }254 }
249 255
250-func runAuthLoginWithToken(ctx context.Context, token string, out io.Writer) error {256+func runAuthLoginWithToken(ctx context.Context, token string, out io.Writer, jsonOut bool) error {
251 token = strings.TrimSpace(token)257 token = strings.TrimSpace(token)
252 if token == "" {258 if token == "" {
253 return fmt.Errorf("token cannot be empty")259 return fmt.Errorf("token cannot be empty")
@@ -264,19 +270,26 @@ func runAuthLoginWithToken(ctx context.Context, token string, out io.Writer) err
264 defer cancel()270 defer cancel()
265 verified := true271 verified := true
266 if _, err := client.GetStatus(verifyCtx); err != nil {272 if _, err := client.GetStatus(verifyCtx); err != nil {
267- _ = cliWriteLine(out, styles.RenderWarning(fmt.Sprintf("Token verification failed: %v", err)))273+ if !jsonOut {
274+ _ = cliWriteLine(out, styles.RenderWarning(fmt.Sprintf("Token verification failed: %v", err)))
275+ }
268 verified = false276 verified = false
269 }277 }
270 278
271 // Store token — requires a named remote.279 // Store token — requires a named remote.
272 if resolved.Name == "" {280 if resolved.Name == "" {
273- _ = cliWriteLine(out, styles.RenderWarning("Ad-hoc URL: token verified but cannot be stored. Use 'gordon remotes add' to save this remote."))281+ if jsonOut {
274- return nil282+ return writeJSON(out, map[string]any{"remote": resolved.DisplayName(), "stored": false, "verified": verified})
283+ }
284+ return cliWriteLine(out, styles.RenderWarning("Ad-hoc URL: token verified but cannot be stored. Use 'gordon remotes add' to save this remote."))
275 }285 }
276 if err := remote.UpdateRemoteToken(resolved.Name, token); err != nil {286 if err := remote.UpdateRemoteToken(resolved.Name, token); err != nil {
277 return fmt.Errorf("failed to save token: %w", err)287 return fmt.Errorf("failed to save token: %w", err)
278 }288 }
279 289
290+ if jsonOut {
291+ return writeJSON(out, map[string]any{"remote": resolved.Name, "stored": true, "verified": verified})
292+ }
280 _ = cliWriteLine(out, "")293 _ = cliWriteLine(out, "")
281 _ = cliWriteLine(out, styles.RenderSuccess(fmt.Sprintf("Token stored for remote '%s'", resolved.Name)))294 _ = cliWriteLine(out, styles.RenderSuccess(fmt.Sprintf("Token stored for remote '%s'", resolved.Name)))
282 if verified {295 if verified {
@@ -285,13 +298,13 @@ func runAuthLoginWithToken(ctx context.Context, token string, out io.Writer) err
285 return nil298 return nil
286 }299 }
287 300
288-func runAuthLoginVerify(ctx context.Context, out io.Writer) error {301+func runAuthLoginVerify(ctx context.Context, out io.Writer, jsonOut bool) error {
289 resolved, err := resolveRequiredRemote(tokenFlag)302 resolved, err := resolveRequiredRemote(tokenFlag)
290 if err != nil {303 if err != nil {
291 return err304 return err
292 }305 }
293 if resolved.Token == "" {306 if resolved.Token == "" {
294- return fmt.Errorf("no token stored for remote '%s'; use: gordon auth login --token <token>", resolved.DisplayName())307+ return fmt.Errorf("no token stored for remote '%s'; use: gordon auth login --token-file <path>", resolved.DisplayName())
295 }308 }
296 309
297 client := remote.NewClient(resolved.URL, remoteClientOptions(resolved.Token, resolved.InsecureTLS)...)310 client := remote.NewClient(resolved.URL, remoteClientOptions(resolved.Token, resolved.InsecureTLS)...)
@@ -303,8 +316,10 @@ func runAuthLoginVerify(ctx context.Context, out io.Writer) error {
303 return fmt.Errorf("authentication failed")316 return fmt.Errorf("authentication failed")
304 }317 }
305 318
306- _ = cliWriteLine(out, styles.RenderSuccess(fmt.Sprintf("Authenticated to '%s'", resolved.DisplayName())))319+ if jsonOut {
307- return nil320+ return writeJSON(out, map[string]any{"remote": resolved.DisplayName(), "authenticated": true})
321+ }
322+ return cliWriteLine(out, styles.RenderSuccess(fmt.Sprintf("Authenticated to '%s'", resolved.DisplayName())))
308 }323 }
309 324
310 // newAuthShowTokenCmd creates the show-token command.325 // newAuthShowTokenCmd creates the show-token command.
M internal/adapters/in/cli/auth_test.go +2 -1
@@ -39,8 +39,9 @@ func TestRunAuthLoginWithToken_StoresToken(t *testing.T) {
3939 t.Cleanup(func() { remoteFlag = origRemote })
4040
4141 var out bytes.Buffer
42- err := runAuthLoginWithToken(context.Background(), "token123", &out)
42+ err := runAuthLoginWithToken(context.Background(), "token123", &out, true)
4343 assert.NoError(t, err)
44+ assert.JSONEq(t, `{"remote":"prod","stored":true,"verified":false}`, out.String())
4445
4546 loaded, err := remote.LoadRemotes("")
4647 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.Buffer41 var out bytes.Buffer
42- err := runAuthLoginWithToken(context.Background(), "token123", &out)42+ err := runAuthLoginWithToken(context.Background(), "token123", &out, true)
43 assert.NoError(t, err)43 assert.NoError(t, err)
44+ assert.JSONEq(t, `{"remote":"prod","stored":true,"verified":false}`, out.String())
44 45
45 loaded, err := remote.LoadRemotes("")46 loaded, err := remote.LoadRemotes("")
46 assert.NoError(t, err)47 assert.NoError(t, err)
M internal/adapters/in/cli/remotes.go +43 -20
@@ -154,9 +154,10 @@ func remotesListJSON(out io.Writer, remotes map[string]remote.RemoteEntry, names
154154
155155 // newRemotesAddCmd creates the remotes add command.
156156 func newRemotesAddCmd() *cobra.Command {
157- var token string
157+ var tokenFile string
158158 var tokenEnv string
159159 var insecureTLS bool
160+ var jsonOut bool
160161
161162 cmd := &cobra.Command{
162163 Use: "add <name> <url>",
@@ -165,7 +166,7 @@ func newRemotesAddCmd() *cobra.Command {
165166
166167 Examples:
167168 gordon remotes add prod https://gordon.mydomain.com
168- gordon remotes add prod https://gordon.mydomain.com --token eyJ...
169+ gordon remotes add prod https://gordon.mydomain.com --token-file ./token
169170 gordon remotes add staging https://staging.mydomain.com --token-env STAGING_TOKEN
170171 gordon remotes add dev https://dev.internal --insecure`,
171172 Args: cobra.ExactArgs(2),
@@ -173,8 +174,14 @@ Examples:
173174 name := args[0]
174175 url := normalizeURL(args[1])
175176
176- // If token-env is provided, use that instead of token
177- finalToken := token
177+ var finalToken string
178+ if tokenFile != "" {
179+ var err error
180+ finalToken, err = readProtectedSecretFile(tokenFile)
181+ if err != nil {
182+ return fmt.Errorf("read token: %w", err)
183+ }
184+ }
178185 if tokenEnv != "" {
179186 // Store token_env reference instead of actual token
180187 if err := addRemoteWithEnv(name, url, tokenEnv, insecureTLS); err != nil {
@@ -186,19 +193,23 @@ Examples:
186193 }
187194 }
188195
189- fmt.Println(styles.RenderSuccess(fmt.Sprintf("Remote added: %s -> %s", name, url)))
190-
191- if token == "" && tokenEnv == "" {
192- fmt.Println(styles.Theme.Muted.Render("Tip: Add a token with --token or --token-env for authenticated access"))
196+ if jsonOut {
197+ return writeJSON(cmd.OutOrStdout(), map[string]any{"name": name, "url": url, "added": true})
198+ }
199+ if err := cliWriteLine(cmd.OutOrStdout(), styles.RenderSuccess(fmt.Sprintf("Remote added: %s -> %s", name, url))); err != nil {
200+ return err
201+ }
202+ if tokenFile == "" && tokenEnv == "" {
203+ return cliWriteLine(cmd.OutOrStdout(), styles.Theme.Muted.Render("Tip: Add a token with --token-file or --token-env for authenticated access"))
193204 }
194-
195205 return nil
196206 },
197207 }
198208
199- cmd.Flags().StringVar(&token, "token", "", "Authentication token")
209+ cmd.Flags().StringVar(&tokenFile, "token-file", "", "Read authentication token from a mode 0600 file")
200210 cmd.Flags().StringVar(&tokenEnv, "token-env", "", "Environment variable containing token")
201211 cmd.Flags().BoolVar(&insecureTLS, "insecure", false, "Skip TLS certificate verification")
212+ cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
202213
203214 return cmd
204215 }
@@ -313,8 +324,10 @@ Examples:
313324
314325 // newRemotesSetTokenCmd creates the remotes set-token command.
315326 func newRemotesSetTokenCmd() *cobra.Command {
316- return &cobra.Command{
317- Use: "set-token <name> <token>",
327+ var tokenFile string
328+ var jsonOut bool
329+ cmd := &cobra.Command{
330+ Use: "set-token <name> --token-file <path>",
318331 Short: "Set the token for a remote",
319332 Long: `Set or update the authentication token for a saved remote.
320333
@@ -326,12 +339,14 @@ This is useful when:
326339 For servers with password authentication, use 'gordon auth login' instead.
327340
328341 Examples:
329- gordon remotes set-token prod eyJhbGciOiJIUzI1NiIs...
330- gordon remotes set-token staging $(cat token.txt)`,
331- Args: cobra.ExactArgs(2),
342+ gordon remotes set-token prod --token-file ./token`,
343+ Args: cobra.ExactArgs(1),
332344 RunE: func(cmd *cobra.Command, args []string) error {
333345 name := args[0]
334- token := args[1]
346+ token, err := readProtectedSecretFile(tokenFile)
347+ if err != nil {
348+ return fmt.Errorf("read token: %w", err)
349+ }
335350
336351 // Verify remote exists
337352 remotes, _, err := remote.ListRemotes()
@@ -340,18 +355,26 @@ Examples:
340355 }
341356
342357 if _, exists := remotes[name]; !exists {
343- fmt.Println(styles.RenderError(fmt.Sprintf("Remote '%s' not found", name)))
344- return nil
358+ if jsonOut {
359+ return writeJSON(cmd.OutOrStdout(), map[string]any{"name": name, "updated": false, "error": "remote not found"})
360+ }
361+ return cliWriteLine(cmd.OutOrStdout(), styles.RenderError(fmt.Sprintf("Remote '%s' not found", name)))
345362 }
346363
347364 if err := remote.UpdateRemoteToken(name, token); err != nil {
348365 return fmt.Errorf("failed to update token: %w", err)
349366 }
350367
351- fmt.Println(styles.RenderSuccess(fmt.Sprintf("Token updated for remote '%s'", name)))
352- return nil
368+ if jsonOut {
369+ return writeJSON(cmd.OutOrStdout(), map[string]any{"name": name, "updated": true})
370+ }
371+ return cliWriteLine(cmd.OutOrStdout(), styles.RenderSuccess(fmt.Sprintf("Token updated for remote '%s'", name)))
353372 },
354373 }
374+ cmd.Flags().StringVar(&tokenFile, "token-file", "", "Read authentication token from a mode 0600 file")
375+ cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
376+ _ = cmd.MarkFlagRequired("token-file")
377+ return cmd
355378 }
356379
357380 // normalizeURL ensures the URL has a protocol scheme, defaulting to https.
@@ -154,9 +154,10 @@ func remotesListJSON(out io.Writer, remotes map[string]remote.RemoteEntry, names
154 154
155 // newRemotesAddCmd creates the remotes add command.155 // newRemotesAddCmd creates the remotes add command.
156 func newRemotesAddCmd() *cobra.Command {156 func newRemotesAddCmd() *cobra.Command {
157- var token string157+ var tokenFile string
158 var tokenEnv string158 var tokenEnv string
159 var insecureTLS bool159 var insecureTLS bool
160+ var jsonOut bool
160 161
161 cmd := &cobra.Command{162 cmd := &cobra.Command{
162 Use: "add <name> <url>",163 Use: "add <name> <url>",
@@ -165,7 +166,7 @@ func newRemotesAddCmd() *cobra.Command {
165 166
166 Examples:167 Examples:
167 gordon remotes add prod https://gordon.mydomain.com168 gordon remotes add prod https://gordon.mydomain.com
168- gordon remotes add prod https://gordon.mydomain.com --token eyJ...169+ gordon remotes add prod https://gordon.mydomain.com --token-file ./token
169 gordon remotes add staging https://staging.mydomain.com --token-env STAGING_TOKEN170 gordon remotes add staging https://staging.mydomain.com --token-env STAGING_TOKEN
170 gordon remotes add dev https://dev.internal --insecure`,171 gordon remotes add dev https://dev.internal --insecure`,
171 Args: cobra.ExactArgs(2),172 Args: cobra.ExactArgs(2),
@@ -173,8 +174,14 @@ Examples:
173 name := args[0]174 name := args[0]
174 url := normalizeURL(args[1])175 url := normalizeURL(args[1])
175 176
176- // If token-env is provided, use that instead of token177+ var finalToken string
177- finalToken := token178+ if tokenFile != "" {
179+ var err error
180+ finalToken, err = readProtectedSecretFile(tokenFile)
181+ if err != nil {
182+ return fmt.Errorf("read token: %w", err)
183+ }
184+ }
178 if tokenEnv != "" {185 if tokenEnv != "" {
179 // Store token_env reference instead of actual token186 // Store token_env reference instead of actual token
180 if err := addRemoteWithEnv(name, url, tokenEnv, insecureTLS); err != nil {187 if err := addRemoteWithEnv(name, url, tokenEnv, insecureTLS); err != nil {
@@ -186,19 +193,23 @@ Examples:
186 }193 }
187 }194 }
188 195
189- fmt.Println(styles.RenderSuccess(fmt.Sprintf("Remote added: %s -> %s", name, url)))196+ if jsonOut {
190-197+ return writeJSON(cmd.OutOrStdout(), map[string]any{"name": name, "url": url, "added": true})
191- if token == "" && tokenEnv == "" {198+ }
192- fmt.Println(styles.Theme.Muted.Render("Tip: Add a token with --token or --token-env for authenticated access"))199+ if err := cliWriteLine(cmd.OutOrStdout(), styles.RenderSuccess(fmt.Sprintf("Remote added: %s -> %s", name, url))); err != nil {
200+ return err
201+ }
202+ if tokenFile == "" && tokenEnv == "" {
203+ return cliWriteLine(cmd.OutOrStdout(), styles.Theme.Muted.Render("Tip: Add a token with --token-file or --token-env for authenticated access"))
193 }204 }
194-
195 return nil205 return nil
196 },206 },
197 }207 }
198 208
199- cmd.Flags().StringVar(&token, "token", "", "Authentication token")209+ cmd.Flags().StringVar(&tokenFile, "token-file", "", "Read authentication token from a mode 0600 file")
200 cmd.Flags().StringVar(&tokenEnv, "token-env", "", "Environment variable containing token")210 cmd.Flags().StringVar(&tokenEnv, "token-env", "", "Environment variable containing token")
201 cmd.Flags().BoolVar(&insecureTLS, "insecure", false, "Skip TLS certificate verification")211 cmd.Flags().BoolVar(&insecureTLS, "insecure", false, "Skip TLS certificate verification")
212+ cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
202 213
203 return cmd214 return cmd
204 }215 }
@@ -313,8 +324,10 @@ Examples:
313 324
314 // newRemotesSetTokenCmd creates the remotes set-token command.325 // newRemotesSetTokenCmd creates the remotes set-token command.
315 func newRemotesSetTokenCmd() *cobra.Command {326 func newRemotesSetTokenCmd() *cobra.Command {
316- return &cobra.Command{327+ var tokenFile string
317- Use: "set-token <name> <token>",328+ var jsonOut bool
329+ cmd := &cobra.Command{
330+ Use: "set-token <name> --token-file <path>",
318 Short: "Set the token for a remote",331 Short: "Set the token for a remote",
319 Long: `Set or update the authentication token for a saved remote.332 Long: `Set or update the authentication token for a saved remote.
320 333
@@ -326,12 +339,14 @@ This is useful when:
326 For servers with password authentication, use 'gordon auth login' instead.339 For servers with password authentication, use 'gordon auth login' instead.
327 340
328 Examples:341 Examples:
329- gordon remotes set-token prod eyJhbGciOiJIUzI1NiIs...342+ gordon remotes set-token prod --token-file ./token`,
330- gordon remotes set-token staging $(cat token.txt)`,343+ Args: cobra.ExactArgs(1),
331- Args: cobra.ExactArgs(2),
332 RunE: func(cmd *cobra.Command, args []string) error {344 RunE: func(cmd *cobra.Command, args []string) error {
333 name := args[0]345 name := args[0]
334- token := args[1]346+ token, err := readProtectedSecretFile(tokenFile)
347+ if err != nil {
348+ return fmt.Errorf("read token: %w", err)
349+ }
335 350
336 // Verify remote exists351 // Verify remote exists
337 remotes, _, err := remote.ListRemotes()352 remotes, _, err := remote.ListRemotes()
@@ -340,18 +355,26 @@ Examples:
340 }355 }
341 356
342 if _, exists := remotes[name]; !exists {357 if _, exists := remotes[name]; !exists {
343- fmt.Println(styles.RenderError(fmt.Sprintf("Remote '%s' not found", name)))358+ if jsonOut {
344- return nil359+ return writeJSON(cmd.OutOrStdout(), map[string]any{"name": name, "updated": false, "error": "remote not found"})
360+ }
361+ return cliWriteLine(cmd.OutOrStdout(), styles.RenderError(fmt.Sprintf("Remote '%s' not found", name)))
345 }362 }
346 363
347 if err := remote.UpdateRemoteToken(name, token); err != nil {364 if err := remote.UpdateRemoteToken(name, token); err != nil {
348 return fmt.Errorf("failed to update token: %w", err)365 return fmt.Errorf("failed to update token: %w", err)
349 }366 }
350 367
351- fmt.Println(styles.RenderSuccess(fmt.Sprintf("Token updated for remote '%s'", name)))368+ if jsonOut {
352- return nil369+ return writeJSON(cmd.OutOrStdout(), map[string]any{"name": name, "updated": true})
370+ }
371+ return cliWriteLine(cmd.OutOrStdout(), styles.RenderSuccess(fmt.Sprintf("Token updated for remote '%s'", name)))
353 },372 },
354 }373 }
374+ cmd.Flags().StringVar(&tokenFile, "token-file", "", "Read authentication token from a mode 0600 file")
375+ cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
376+ _ = cmd.MarkFlagRequired("token-file")
377+ return cmd
355 }378 }
356 379
357 // normalizeURL ensures the URL has a protocol scheme, defaulting to https.380 // normalizeURL ensures the URL has a protocol scheme, defaulting to https.
M internal/adapters/in/cli/root.go +13 -2
@@ -38,7 +38,18 @@ const (
3838 // NewRootCmd creates the root command for Gordon CLI.
3939 func NewRootCmd() *cobra.Command {
4040 rootCmd := &cobra.Command{
41- Use: "gordon",
41+ Use: "gordon",
42+ PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
43+ if tokenFlag == "" {
44+ return nil
45+ }
46+ token, err := readProtectedSecretFile(tokenFlag)
47+ if err != nil {
48+ return fmt.Errorf("read remote token: %w", err)
49+ }
50+ tokenFlag = token
51+ return nil
52+ },
4253 Short: "Gordon - A lightweight container deployment platform",
4354 Long: `Gordon is a self-contained container deployment platform that combines
4455 a Docker registry with automatic container deployment capabilities.
@@ -61,7 +72,7 @@ Commands are organized by where they run:
6172
6273 // Add persistent flags for remote targeting
6374 rootCmd.PersistentFlags().StringVarP(&remoteFlag, "remote", "r", "", "Remote name or URL (e.g., prod, https://gordon.mydomain.com)")
64- rootCmd.PersistentFlags().StringVar(&tokenFlag, "token", "", "Authentication token for remote")
75+ rootCmd.PersistentFlags().StringVar(&tokenFlag, "token-file", "", "Read remote authentication token from a mode 0600 file")
6576 rootCmd.PersistentFlags().BoolVar(&insecureTLSFlag, "insecure", false, "Skip TLS certificate verification for remote HTTPS endpoints")
6677
6778 // Server-only commands (must run on the Gordon host)
@@ -38,7 +38,18 @@ const (
38 // NewRootCmd creates the root command for Gordon CLI.38 // NewRootCmd creates the root command for Gordon CLI.
39 func NewRootCmd() *cobra.Command {39 func NewRootCmd() *cobra.Command {
40 rootCmd := &cobra.Command{40 rootCmd := &cobra.Command{
41- Use: "gordon",41+ Use: "gordon",
42+ PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
43+ if tokenFlag == "" {
44+ return nil
45+ }
46+ token, err := readProtectedSecretFile(tokenFlag)
47+ if err != nil {
48+ return fmt.Errorf("read remote token: %w", err)
49+ }
50+ tokenFlag = token
51+ return nil
52+ },
42 Short: "Gordon - A lightweight container deployment platform",53 Short: "Gordon - A lightweight container deployment platform",
43 Long: `Gordon is a self-contained container deployment platform that combines54 Long: `Gordon is a self-contained container deployment platform that combines
44 a Docker registry with automatic container deployment capabilities.55 a Docker registry with automatic container deployment capabilities.
@@ -61,7 +72,7 @@ Commands are organized by where they run:
61 72
62 // Add persistent flags for remote targeting73 // Add persistent flags for remote targeting
63 rootCmd.PersistentFlags().StringVarP(&remoteFlag, "remote", "r", "", "Remote name or URL (e.g., prod, https://gordon.mydomain.com)")74 rootCmd.PersistentFlags().StringVarP(&remoteFlag, "remote", "r", "", "Remote name or URL (e.g., prod, https://gordon.mydomain.com)")
64- rootCmd.PersistentFlags().StringVar(&tokenFlag, "token", "", "Authentication token for remote")75+ rootCmd.PersistentFlags().StringVar(&tokenFlag, "token-file", "", "Read remote authentication token from a mode 0600 file")
65 rootCmd.PersistentFlags().BoolVar(&insecureTLSFlag, "insecure", false, "Skip TLS certificate verification for remote HTTPS endpoints")76 rootCmd.PersistentFlags().BoolVar(&insecureTLSFlag, "insecure", false, "Skip TLS certificate verification for remote HTTPS endpoints")
66 77
67 // Server-only commands (must run on the Gordon host)78 // Server-only commands (must run on the Gordon host)
A internal/adapters/in/cli/secret_input.go +47 -0
@@ -0,0 +1,47 @@
1+package cli
2+
3+import (
4+ "fmt"
5+ "io"
6+ "os"
7+ "strings"
8+
9+ "golang.org/x/sys/unix"
10+)
11+
12+func readProtectedSecretFile(path string) (string, error) {
13+ fd, err := unix.Open(path, unix.O_RDONLY|unix.O_CLOEXEC|unix.O_NOFOLLOW, 0)
14+ if err != nil {
15+ return "", fmt.Errorf("open secret file %s: %w", path, err)
16+ }
17+ if fd < 0 {
18+ return "", fmt.Errorf("open secret file %s: invalid file descriptor %d", path, fd)
19+ }
20+ //nolint:gosec // G115: fd is validated non-negative and originates from unix.Open.
21+ file := os.NewFile(uintptr(fd), path)
22+ if file == nil {
23+ _ = unix.Close(fd)
24+ return "", fmt.Errorf("open secret file %s: invalid file descriptor", path)
25+ }
26+ defer file.Close()
27+
28+ info, err := file.Stat()
29+ if err != nil {
30+ return "", fmt.Errorf("stat secret file %s: %w", path, err)
31+ }
32+ if !info.Mode().IsRegular() {
33+ return "", fmt.Errorf("%s is not a regular file", path)
34+ }
35+ if info.Mode().Perm()&0o077 != 0 {
36+ return "", fmt.Errorf("%s must not be accessible by group or others (use chmod 600)", path)
37+ }
38+ data, err := io.ReadAll(file)
39+ if err != nil {
40+ return "", fmt.Errorf("read secret file %s: %w", path, err)
41+ }
42+ value := strings.TrimSpace(string(data))
43+ if value == "" {
44+ return "", fmt.Errorf("%s is empty", path)
45+ }
46+ return value, nil
47+}
@@ -0,0 +1,47 @@
1+package cli
2+
3+import (
4+ "fmt"
5+ "io"
6+ "os"
7+ "strings"
8+
9+ "golang.org/x/sys/unix"
10+)
11+
12+func readProtectedSecretFile(path string) (string, error) {
13+ fd, err := unix.Open(path, unix.O_RDONLY|unix.O_CLOEXEC|unix.O_NOFOLLOW, 0)
14+ if err != nil {
15+ return "", fmt.Errorf("open secret file %s: %w", path, err)
16+ }
17+ if fd < 0 {
18+ return "", fmt.Errorf("open secret file %s: invalid file descriptor %d", path, fd)
19+ }
20+ //nolint:gosec // G115: fd is validated non-negative and originates from unix.Open.
21+ file := os.NewFile(uintptr(fd), path)
22+ if file == nil {
23+ _ = unix.Close(fd)
24+ return "", fmt.Errorf("open secret file %s: invalid file descriptor", path)
25+ }
26+ defer file.Close()
27+
28+ info, err := file.Stat()
29+ if err != nil {
30+ return "", fmt.Errorf("stat secret file %s: %w", path, err)
31+ }
32+ if !info.Mode().IsRegular() {
33+ return "", fmt.Errorf("%s is not a regular file", path)
34+ }
35+ if info.Mode().Perm()&0o077 != 0 {
36+ return "", fmt.Errorf("%s must not be accessible by group or others (use chmod 600)", path)
37+ }
38+ data, err := io.ReadAll(file)
39+ if err != nil {
40+ return "", fmt.Errorf("read secret file %s: %w", path, err)
41+ }
42+ value := strings.TrimSpace(string(data))
43+ if value == "" {
44+ return "", fmt.Errorf("%s is empty", path)
45+ }
46+ return value, nil
47+}
M internal/adapters/in/cli/secrets.go +22 -16
@@ -222,32 +222,33 @@ func getKeyPrefix(isLastAttachment, isLastKey bool) string {
222222
223223 // newSecretsSetCmd creates the secrets set command.
224224 func newSecretsSetCmd() *cobra.Command {
225- var attachment string
225+ var attachment, fromFile string
226+ var jsonOut bool
226227
227228 cmd := &cobra.Command{
228- Use: "set <domain> <KEY=value>...",
229+ Use: "set <domain> --from-file <path>",
229230 Short: "Set secrets for a domain or attachment",
230231 Long: `Set one or more secrets for a domain or an attachment container.
231232
232-Secrets are specified as KEY=value pairs. Multiple secrets can be set at once.
233+Secrets are read from a mode 0600 file containing one KEY=value pair per line.
233234
234235 Use --attachment to target an attachment service (e.g., postgres, redis) instead
235236 of the main domain container.
236237
237238 Examples:
238- gordon secrets set app.mydomain.com DATABASE_URL=postgres://localhost/db
239- gordon secrets set app.mydomain.com API_KEY=secret123 DEBUG=false
240- gordon secrets set app.mydomain.com --attachment postgres POSTGRES_PASSWORD=secret
241- gordon secrets set app.mydomain.com --attachment redis REDIS_PASSWORD=secret`,
242- Args: cobra.MinimumNArgs(2),
239+ gordon secrets set app.mydomain.com --from-file ./app.env
240+ gordon secrets set app.mydomain.com --attachment postgres --from-file ./postgres.env`,
241+ Args: cobra.ExactArgs(1),
243242 RunE: func(cmd *cobra.Command, args []string) error {
244243 ctx := cmd.Context()
245244 secretDomain := args[0]
246- pairs := args[1:]
245+ content, err := readProtectedSecretFile(fromFile)
246+ if err != nil {
247+ return fmt.Errorf("read secrets: %w", err)
248+ }
247249
248- // Parse KEY=value pairs
249250 secrets := make(map[string]string)
250- for _, pair := range pairs {
251+ for _, pair := range strings.Split(content, "\n") {
251252 parts := strings.SplitN(pair, "=", 2)
252253 if len(parts) != 2 {
253254 return fmt.Errorf("invalid format: %s (expected KEY=value)", pair)
@@ -275,19 +276,24 @@ Examples:
275276 target = fmt.Sprintf("%s [%s]", secretDomain, attachment)
276277 }
277278
279+ if jsonOut {
280+ return writeJSON(cmd.OutOrStdout(), map[string]any{
281+ "domain": secretDomain, "attachment": attachment, "count": len(secrets), "updated": true,
282+ })
283+ }
278284 if len(secrets) == 1 {
279285 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)))
281287 }
282- } else {
283- fmt.Println(styles.RenderSuccess(fmt.Sprintf("Set %d secrets for %s", len(secrets), target)))
284288 }
285-
286- return nil
289+ return cliWriteLine(cmd.OutOrStdout(), styles.RenderSuccess(fmt.Sprintf("Set %d secrets for %s", len(secrets), target)))
287290 },
288291 }
289292
290293 cmd.Flags().StringVarP(&attachment, "attachment", "a", "", "Target an attachment service (e.g., postgres, redis)")
294+ cmd.Flags().StringVar(&fromFile, "from-file", "", "Read KEY=value lines from a mode 0600 file")
295+ cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
296+ _ = cmd.MarkFlagRequired("from-file")
291297
292298 return cmd
293299 }
@@ -222,32 +222,33 @@ func getKeyPrefix(isLastAttachment, isLastKey bool) string {
222 222
223 // newSecretsSetCmd creates the secrets set command.223 // newSecretsSetCmd creates the secrets set command.
224 func newSecretsSetCmd() *cobra.Command {224 func newSecretsSetCmd() *cobra.Command {
225- var attachment string225+ var attachment, fromFile string
226+ var jsonOut bool
226 227
227 cmd := &cobra.Command{228 cmd := &cobra.Command{
228- Use: "set <domain> <KEY=value>...",229+ Use: "set <domain> --from-file <path>",
229 Short: "Set secrets for a domain or attachment",230 Short: "Set secrets for a domain or attachment",
230 Long: `Set one or more secrets for a domain or an attachment container.231 Long: `Set one or more secrets for a domain or an attachment container.
231 232
232-Secrets are specified as KEY=value pairs. Multiple secrets can be set at once.233+Secrets are read from a mode 0600 file containing one KEY=value pair per line.
233 234
234 Use --attachment to target an attachment service (e.g., postgres, redis) instead235 Use --attachment to target an attachment service (e.g., postgres, redis) instead
235 of the main domain container.236 of the main domain container.
236 237
237 Examples:238 Examples:
238- gordon secrets set app.mydomain.com DATABASE_URL=postgres://localhost/db239+ gordon secrets set app.mydomain.com --from-file ./app.env
239- gordon secrets set app.mydomain.com API_KEY=secret123 DEBUG=false240+ gordon secrets set app.mydomain.com --attachment postgres --from-file ./postgres.env`,
240- gordon secrets set app.mydomain.com --attachment postgres POSTGRES_PASSWORD=secret241+ Args: cobra.ExactArgs(1),
241- gordon secrets set app.mydomain.com --attachment redis REDIS_PASSWORD=secret`,
242- Args: cobra.MinimumNArgs(2),
243 RunE: func(cmd *cobra.Command, args []string) error {242 RunE: func(cmd *cobra.Command, args []string) error {
244 ctx := cmd.Context()243 ctx := cmd.Context()
245 secretDomain := args[0]244 secretDomain := args[0]
246- pairs := args[1:]245+ content, err := readProtectedSecretFile(fromFile)
246+ if err != nil {
247+ return fmt.Errorf("read secrets: %w", err)
248+ }
247 249
248- // Parse KEY=value pairs
249 secrets := make(map[string]string)250 secrets := make(map[string]string)
250- for _, pair := range pairs {251+ for _, pair := range strings.Split(content, "\n") {
251 parts := strings.SplitN(pair, "=", 2)252 parts := strings.SplitN(pair, "=", 2)
252 if len(parts) != 2 {253 if len(parts) != 2 {
253 return fmt.Errorf("invalid format: %s (expected KEY=value)", pair)254 return fmt.Errorf("invalid format: %s (expected KEY=value)", pair)
@@ -275,19 +276,24 @@ Examples:
275 target = fmt.Sprintf("%s [%s]", secretDomain, attachment)276 target = fmt.Sprintf("%s [%s]", secretDomain, attachment)
276 }277 }
277 278
279+ if jsonOut {
280+ return writeJSON(cmd.OutOrStdout(), map[string]any{
281+ "domain": secretDomain, "attachment": attachment, "count": len(secrets), "updated": true,
282+ })
283+ }
278 if len(secrets) == 1 {284 if len(secrets) == 1 {
279 for key := range secrets {285 for key := range secrets {
280- fmt.Println(styles.RenderSuccess(fmt.Sprintf("Secret set: %s on %s", key, target)))286+ return cliWriteLine(cmd.OutOrStdout(), styles.RenderSuccess(fmt.Sprintf("Secret set: %s on %s", key, target)))
281 }287 }
282- } else {
283- fmt.Println(styles.RenderSuccess(fmt.Sprintf("Set %d secrets for %s", len(secrets), target)))
284 }288 }
285-289+ return cliWriteLine(cmd.OutOrStdout(), styles.RenderSuccess(fmt.Sprintf("Set %d secrets for %s", len(secrets), target)))
286- return nil
287 },290 },
288 }291 }
289 292
290 cmd.Flags().StringVarP(&attachment, "attachment", "a", "", "Target an attachment service (e.g., postgres, redis)")293 cmd.Flags().StringVarP(&attachment, "attachment", "a", "", "Target an attachment service (e.g., postgres, redis)")
294+ cmd.Flags().StringVar(&fromFile, "from-file", "", "Read KEY=value lines from a mode 0600 file")
295+ cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
296+ _ = cmd.MarkFlagRequired("from-file")
291 297
292 return cmd298 return cmd
293 }299 }
M internal/adapters/in/http/registry/handler.go +5 -1
@@ -370,6 +370,10 @@ func (h *Handler) handlePutManifest(w http.ResponseWriter, r *http.Request) {
370370 digest, err := h.registrySvc.PutManifest(ctx, manifestObj)
371371 if err != nil {
372372 log.Error().Err(err).Str("name", name).Str("reference", reference).Msg("failed to store manifest")
373+ if errors.Is(err, domain.ErrDigestMismatch) || errors.Is(err, domain.ErrInvalidDigest) {
374+ h.sendRegistryError(w, http.StatusBadRequest, "DIGEST_INVALID", "manifest digest does not match content")
375+ return
376+ }
373377 h.sendRegistryError(w, http.StatusInternalServerError, "MANIFEST_INVALID", "failed to store manifest")
374378 return
375379 }
@@ -387,7 +391,7 @@ func (h *Handler) handleGetBlob(w http.ResponseWriter, r *http.Request) {
387391
388392 log.Debug().Str("name", name).Str("digest", digest).Msg("GET blob")
389393
390- path, err := h.registrySvc.GetBlobPath(ctx, digest)
394+ path, err := h.registrySvc.GetBlobPath(ctx, name, digest)
391395 if err != nil {
392396 log.Warn().Err(err).Str("name", name).Str("digest", digest).Msg("blob not found")
393397 h.sendRegistryError(w, http.StatusNotFound, "BLOB_UNKNOWN", "blob not found")
@@ -370,6 +370,10 @@ func (h *Handler) handlePutManifest(w http.ResponseWriter, r *http.Request) {
370 digest, err := h.registrySvc.PutManifest(ctx, manifestObj)370 digest, err := h.registrySvc.PutManifest(ctx, manifestObj)
371 if err != nil {371 if err != nil {
372 log.Error().Err(err).Str("name", name).Str("reference", reference).Msg("failed to store manifest")372 log.Error().Err(err).Str("name", name).Str("reference", reference).Msg("failed to store manifest")
373+ if errors.Is(err, domain.ErrDigestMismatch) || errors.Is(err, domain.ErrInvalidDigest) {
374+ h.sendRegistryError(w, http.StatusBadRequest, "DIGEST_INVALID", "manifest digest does not match content")
375+ return
376+ }
373 h.sendRegistryError(w, http.StatusInternalServerError, "MANIFEST_INVALID", "failed to store manifest")377 h.sendRegistryError(w, http.StatusInternalServerError, "MANIFEST_INVALID", "failed to store manifest")
374 return378 return
375 }379 }
@@ -387,7 +391,7 @@ func (h *Handler) handleGetBlob(w http.ResponseWriter, r *http.Request) {
387 391
388 log.Debug().Str("name", name).Str("digest", digest).Msg("GET blob")392 log.Debug().Str("name", name).Str("digest", digest).Msg("GET blob")
389 393
390- path, err := h.registrySvc.GetBlobPath(ctx, digest)394+ path, err := h.registrySvc.GetBlobPath(ctx, name, digest)
391 if err != nil {395 if err != nil {
392 log.Warn().Err(err).Str("name", name).Str("digest", digest).Msg("blob not found")396 log.Warn().Err(err).Str("name", name).Str("digest", digest).Msg("blob not found")
393 h.sendRegistryError(w, http.StatusNotFound, "BLOB_UNKNOWN", "blob not found")397 h.sendRegistryError(w, http.StatusNotFound, "BLOB_UNKNOWN", "blob not found")
M internal/adapters/in/http/registry/handler_test.go +18 -13
@@ -13,6 +13,7 @@ import (
1313 "github.com/bnema/zerowrap"
1414 "github.com/stretchr/testify/assert"
1515 "github.com/stretchr/testify/mock"
16+ "github.com/stretchr/testify/require"
1617
1718 inmocks "github.com/bnema/gordon/internal/boundaries/in/mocks"
1819 "github.com/bnema/gordon/internal/domain"
@@ -204,6 +205,8 @@ func TestHandler_GetBlob_Success(t *testing.T) {
204205 registrySvc := inmocks.NewMockRegistryService(t)
205206
206207 handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize)
208+ server := httptest.NewServer(handler)
209+ defer server.Close()
207210
208211 // Create a temp file for http.ServeFile
209212 tmpFile, err := os.CreateTemp("", "blob-*")
@@ -214,30 +217,32 @@ func TestHandler_GetBlob_Success(t *testing.T) {
214217 _, err = tmpFile.Write(blobContent)
215218 assert.NoError(t, err)
216219
217- registrySvc.EXPECT().GetBlobPath(mock.Anything, "sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4").Return(tmpFile.Name(), nil)
220+ registrySvc.EXPECT().GetBlobPath(mock.Anything, "myapp", "sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4").Return(tmpFile.Name(), nil)
218221
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)
223227
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)
226230 }
227231
228232 func TestHandler_GetBlob_NotFound(t *testing.T) {
229233 registrySvc := inmocks.NewMockRegistryService(t)
230234
231235 handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize)
236+ server := httptest.NewServer(handler)
237+ defer server.Close()
232238
233- registrySvc.EXPECT().GetBlobPath(mock.Anything, "sha256:0000000000000000000000000000000000000000000000000000000000000000").Return("", assert.AnError)
239+ registrySvc.EXPECT().GetBlobPath(mock.Anything, "myapp", "sha256:0000000000000000000000000000000000000000000000000000000000000000").Return("", assert.AnError)
234240
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()
239244
240- assert.Equal(t, http.StatusNotFound, rec.Code)
245+ assert.Equal(t, http.StatusNotFound, resp.StatusCode)
241246 }
242247
243248 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.ServeFile211 // Create a temp file for http.ServeFile
209 tmpFile, err := os.CreateTemp("", "blob-*")212 tmpFile, err := os.CreateTemp("", "blob-*")
@@ -214,30 +217,32 @@ func TestHandler_GetBlob_Success(t *testing.T) {
214 _, err = tmpFile.Write(blobContent)217 _, err = tmpFile.Write(blobContent)
215 assert.NoError(t, err)218 assert.NoError(t, err)
216 219
217- registrySvc.EXPECT().GetBlobPath(mock.Anything, "sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4").Return(tmpFile.Name(), nil)220+ registrySvc.EXPECT().GetBlobPath(mock.Anything, "myapp", "sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4").Return(tmpFile.Name(), nil)
218 221
219- req := httptest.NewRequest("GET", "/v2/myapp/blobs/sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4", nil)222+ resp, err := server.Client().Get(server.URL + "/v2/myapp/blobs/sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4")
220- rec := httptest.NewRecorder()223+ require.NoError(t, err)
221-224+ defer resp.Body.Close()
222- handler.ServeHTTP(rec, req)225+ body, err := io.ReadAll(resp.Body)
226+ require.NoError(t, err)
223 227
224- assert.Equal(t, http.StatusOK, rec.Code)228+ assert.Equal(t, http.StatusOK, resp.StatusCode)
225- assert.Equal(t, blobContent, rec.Body.Bytes())229+ assert.Equal(t, blobContent, body)
226 }230 }
227 231
228 func TestHandler_GetBlob_NotFound(t *testing.T) {232 func TestHandler_GetBlob_NotFound(t *testing.T) {
229 registrySvc := inmocks.NewMockRegistryService(t)233 registrySvc := inmocks.NewMockRegistryService(t)
230 234
231 handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize)235 handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize)
236+ server := httptest.NewServer(handler)
237+ defer server.Close()
232 238
233- registrySvc.EXPECT().GetBlobPath(mock.Anything, "sha256:0000000000000000000000000000000000000000000000000000000000000000").Return("", assert.AnError)239+ registrySvc.EXPECT().GetBlobPath(mock.Anything, "myapp", "sha256:0000000000000000000000000000000000000000000000000000000000000000").Return("", assert.AnError)
234 240
235- req := httptest.NewRequest("GET", "/v2/myapp/blobs/sha256:0000000000000000000000000000000000000000000000000000000000000000", nil)241+ resp, err := server.Client().Get(server.URL + "/v2/myapp/blobs/sha256:0000000000000000000000000000000000000000000000000000000000000000")
236- rec := httptest.NewRecorder()242+ require.NoError(t, err)
237-243+ defer resp.Body.Close()
238- handler.ServeHTTP(rec, req)
239 244
240- assert.Equal(t, http.StatusNotFound, rec.Code)245+ assert.Equal(t, http.StatusNotFound, resp.StatusCode)
241 }246 }
242 247
243 func TestHandler_BlobRoutes_MethodNotAllowed(t *testing.T) {248 func TestHandler_BlobRoutes_MethodNotAllowed(t *testing.T) {
M internal/adapters/out/docker/runtime.go +45 -4
@@ -6,6 +6,7 @@ import (
66 "bytes"
77 "context"
88 "encoding/base64"
9+ "encoding/binary"
910 "encoding/json"
1011 "errors"
1112 "fmt"
@@ -525,6 +526,7 @@ func (r *Runtime) GetContainerLogs(ctx context.Context, containerID string, foll
525526 ShowStderr: true,
526527 Follow: follow,
527528 Timestamps: true,
529+ Tail: "10000",
528530 })
529531 if err != nil {
530532 return nil, log.WrapErr(err, "failed to get container logs")
@@ -747,6 +749,7 @@ func (r *Runtime) PruneImages(ctx context.Context, danglingOnly bool) (runtimepk
747749 log := zerowrap.FromCtx(ctx)
748750
749751 pruneFilters := filters.NewArgs()
752+ pruneFilters.Add("label", domain.LabelManaged+"=true")
750753 if danglingOnly {
751754 pruneFilters.Add("dangling", "true")
752755 }
@@ -1724,12 +1727,50 @@ func (r *Runtime) ExecInContainer(ctx context.Context, containerID string, cmd [
17241727 }, nil
17251728 }
17261729
1730+const maxExecOutputSize = 8 << 20 // 8 MiB per stream
1731+
1732+type boundedBuffer struct {
1733+ bytes.Buffer
1734+ limit int
1735+}
1736+
1737+func (w *boundedBuffer) Write(p []byte) (int, error) {
1738+ remaining := w.limit - w.Len()
1739+ if remaining <= 0 {
1740+ return 0, fmt.Errorf("%w: limit is %d bytes", domain.ErrExecOutputExceeded, w.limit)
1741+ }
1742+ if len(p) > remaining {
1743+ _, _ = w.Buffer.Write(p[:remaining])
1744+ return remaining, fmt.Errorf("%w: limit is %d bytes", domain.ErrExecOutputExceeded, w.limit)
1745+ }
1746+ return w.Buffer.Write(p)
1747+}
1748+
17271749 func parseExecOutput(reader io.Reader) ([]byte, []byte, error) {
1728- var stdout bytes.Buffer
1729- var stderr bytes.Buffer
1750+ stdout := boundedBuffer{limit: maxExecOutputSize}
1751+ stderr := boundedBuffer{limit: maxExecOutputSize}
1752+ buffers := map[byte]*boundedBuffer{1: &stdout, 2: &stderr}
1753+ var header [8]byte
17301754
1731- if _, err := stdcopy.StdCopy(&stdout, &stderr, reader); err != nil {
1732- return nil, nil, err
1755+ for {
1756+ if _, err := io.ReadFull(reader, header[:]); err != nil {
1757+ if errors.Is(err, io.EOF) {
1758+ break
1759+ }
1760+ return nil, nil, fmt.Errorf("read Docker exec frame header: %w", err)
1761+ }
1762+ output, ok := buffers[header[0]]
1763+ if !ok {
1764+ return nil, nil, fmt.Errorf("invalid Docker exec stream %d", header[0])
1765+ }
1766+ payloadSize := int64(binary.BigEndian.Uint32(header[4:]))
1767+ remaining := int64(output.limit - output.Len())
1768+ if payloadSize > remaining {
1769+ return nil, nil, fmt.Errorf("%w: frame is %d bytes with %d bytes remaining", domain.ErrExecOutputExceeded, payloadSize, remaining)
1770+ }
1771+ if _, err := io.CopyN(output, reader, payloadSize); err != nil {
1772+ return nil, nil, fmt.Errorf("read Docker exec frame payload: %w", err)
1773+ }
17331774 }
17341775
17351776 return stdout.Bytes(), stderr.Bytes(), nil
@@ -6,6 +6,7 @@ import (
6 "bytes"6 "bytes"
7 "context"7 "context"
8 "encoding/base64"8 "encoding/base64"
9+ "encoding/binary"
9 "encoding/json"10 "encoding/json"
10 "errors"11 "errors"
11 "fmt"12 "fmt"
@@ -525,6 +526,7 @@ func (r *Runtime) GetContainerLogs(ctx context.Context, containerID string, foll
525 ShowStderr: true,526 ShowStderr: true,
526 Follow: follow,527 Follow: follow,
527 Timestamps: true,528 Timestamps: true,
529+ Tail: "10000",
528 })530 })
529 if err != nil {531 if err != nil {
530 return nil, log.WrapErr(err, "failed to get container logs")532 return nil, log.WrapErr(err, "failed to get container logs")
@@ -747,6 +749,7 @@ func (r *Runtime) PruneImages(ctx context.Context, danglingOnly bool) (runtimepk
747 log := zerowrap.FromCtx(ctx)749 log := zerowrap.FromCtx(ctx)
748 750
749 pruneFilters := filters.NewArgs()751 pruneFilters := filters.NewArgs()
752+ pruneFilters.Add("label", domain.LabelManaged+"=true")
750 if danglingOnly {753 if danglingOnly {
751 pruneFilters.Add("dangling", "true")754 pruneFilters.Add("dangling", "true")
752 }755 }
@@ -1724,12 +1727,50 @@ func (r *Runtime) ExecInContainer(ctx context.Context, containerID string, cmd [
1724 }, nil1727 }, nil
1725 }1728 }
1726 1729
1730+const maxExecOutputSize = 8 << 20 // 8 MiB per stream
1731+
1732+type boundedBuffer struct {
1733+ bytes.Buffer
1734+ limit int
1735+}
1736+
1737+func (w *boundedBuffer) Write(p []byte) (int, error) {
1738+ remaining := w.limit - w.Len()
1739+ if remaining <= 0 {
1740+ return 0, fmt.Errorf("%w: limit is %d bytes", domain.ErrExecOutputExceeded, w.limit)
1741+ }
1742+ if len(p) > remaining {
1743+ _, _ = w.Buffer.Write(p[:remaining])
1744+ return remaining, fmt.Errorf("%w: limit is %d bytes", domain.ErrExecOutputExceeded, w.limit)
1745+ }
1746+ return w.Buffer.Write(p)
1747+}
1748+
1727 func parseExecOutput(reader io.Reader) ([]byte, []byte, error) {1749 func parseExecOutput(reader io.Reader) ([]byte, []byte, error) {
1728- var stdout bytes.Buffer1750+ stdout := boundedBuffer{limit: maxExecOutputSize}
1729- var stderr bytes.Buffer1751+ stderr := boundedBuffer{limit: maxExecOutputSize}
1752+ buffers := map[byte]*boundedBuffer{1: &stdout, 2: &stderr}
1753+ var header [8]byte
1730 1754
1731- if _, err := stdcopy.StdCopy(&stdout, &stderr, reader); err != nil {1755+ for {
1732- return nil, nil, err1756+ if _, err := io.ReadFull(reader, header[:]); err != nil {
1757+ if errors.Is(err, io.EOF) {
1758+ break
1759+ }
1760+ return nil, nil, fmt.Errorf("read Docker exec frame header: %w", err)
1761+ }
1762+ output, ok := buffers[header[0]]
1763+ if !ok {
1764+ return nil, nil, fmt.Errorf("invalid Docker exec stream %d", header[0])
1765+ }
1766+ payloadSize := int64(binary.BigEndian.Uint32(header[4:]))
1767+ remaining := int64(output.limit - output.Len())
1768+ if payloadSize > remaining {
1769+ return nil, nil, fmt.Errorf("%w: frame is %d bytes with %d bytes remaining", domain.ErrExecOutputExceeded, payloadSize, remaining)
1770+ }
1771+ if _, err := io.CopyN(output, reader, payloadSize); err != nil {
1772+ return nil, nil, fmt.Errorf("read Docker exec frame payload: %w", err)
1773+ }
1733 }1774 }
1734 1775
1735 return stdout.Bytes(), stderr.Bytes(), nil1776 return stdout.Bytes(), stderr.Bytes(), nil
M internal/adapters/out/docker/runtime_exec_test.go +14 -0
@@ -11,6 +11,8 @@ import (
1111
1212 "github.com/stretchr/testify/assert"
1313 "github.com/stretchr/testify/require"
14+
15+ "github.com/bnema/gordon/internal/domain"
1416 )
1517
1618 func TestParseExecOutput_SplitsStdoutAndStderr(t *testing.T) {
@@ -23,6 +25,18 @@ func TestParseExecOutput_SplitsStdoutAndStderr(t *testing.T) {
2325 assert.Equal(t, []byte("warn\n"), stderr)
2426 }
2527
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+
2640 func TestRuntime_ExecInContainer_RejectsEmptyCommand(t *testing.T) {
2741 r := &Runtime{}
2842
@@ -11,6 +11,8 @@ import (
11 11
12 "github.com/stretchr/testify/assert"12 "github.com/stretchr/testify/assert"
13 "github.com/stretchr/testify/require"13 "github.com/stretchr/testify/require"
14+
15+ "github.com/bnema/gordon/internal/domain"
14 )16 )
15 17
16 func TestParseExecOutput_SplitsStdoutAndStderr(t *testing.T) {18 func TestParseExecOutput_SplitsStdoutAndStderr(t *testing.T) {
@@ -23,6 +25,18 @@ func TestParseExecOutput_SplitsStdoutAndStderr(t *testing.T) {
23 assert.Equal(t, []byte("warn\n"), stderr)25 assert.Equal(t, []byte("warn\n"), stderr)
24 }26 }
25 27
28+func TestParseExecOutput_RejectsOversizedFrameBeforeReadingPayload(t *testing.T) {
29+ var header [8]byte
30+ header[0] = 1
31+ binary.BigEndian.PutUint32(header[4:], maxExecOutputSize+1)
32+
33+ stdout, stderr, err := parseExecOutput(bytes.NewReader(header[:]))
34+
35+ assert.ErrorIs(t, err, domain.ErrExecOutputExceeded)
36+ assert.Nil(t, stdout)
37+ assert.Nil(t, stderr)
38+}
39+
26 func TestRuntime_ExecInContainer_RejectsEmptyCommand(t *testing.T) {40 func TestRuntime_ExecInContainer_RejectsEmptyCommand(t *testing.T) {
27 r := &Runtime{}41 r := &Runtime{}
28 42
M internal/adapters/out/ratelimit/memory.go +51 -16
@@ -2,6 +2,7 @@
22 package ratelimit
33
44 import (
5+ "container/list"
56 "context"
67 "sync"
78 "time"
@@ -17,9 +18,21 @@ var _ out.RateLimiter = (*MemoryStore)(nil)
1718
1819 // MemoryStore is an in-memory rate limiter implementation using golang.org/x/time/rate.
1920 // Each unique key gets its own independent rate limiter.
21+type limiterEntry struct {
22+ key string
23+ limiter *rate.Limiter
24+ lastSeen time.Time
25+}
26+
27+const (
28+ maxLimiterEntries = 10000
29+ limiterIdleTTL = 10 * time.Minute
30+)
31+
2032 type MemoryStore struct {
21- limiters map[string]*rate.Limiter
22- mu sync.RWMutex
33+ limiters map[string]*list.Element
34+ lru *list.List
35+ mu sync.Mutex
2336 rps float64
2437 burst int
2538 log zerowrap.Logger
@@ -28,7 +41,8 @@ type MemoryStore struct {
2841 // NewMemoryStore creates a new in-memory rate limiter store.
2942 func NewMemoryStore(rps float64, burst int, log zerowrap.Logger) *MemoryStore {
3043 return &MemoryStore{
31- limiters: make(map[string]*rate.Limiter),
44+ limiters: make(map[string]*list.Element),
45+ lru: list.New(),
3246 rps: rps,
3347 burst: burst,
3448 log: log,
@@ -48,23 +62,44 @@ func (s *MemoryStore) AllowN(_ context.Context, key string, n int) bool {
4862
4963 // getLimiter returns the rate limiter for the given key, creating one if it doesn't exist.
5064 func (s *MemoryStore) getLimiter(key string) *rate.Limiter {
51- s.mu.RLock()
52- limiter, exists := s.limiters[key]
53- s.mu.RUnlock()
54-
55- if exists {
56- return limiter
57- }
58-
65+ now := time.Now()
5966 s.mu.Lock()
6067 defer s.mu.Unlock()
6168
62- // Double-check after acquiring write lock
63- if limiter, exists = s.limiters[key]; exists {
64- return limiter
69+ if element, exists := s.limiters[key]; exists {
70+ entry := element.Value.(*limiterEntry)
71+ entry.lastSeen = now
72+ s.lru.MoveToFront(element)
73+ return entry.limiter
6574 }
6675
67- limiter = rate.NewLimiter(rate.Limit(s.rps), s.burst)
68- s.limiters[key] = limiter
76+ s.evictExpiredOrOldest(now)
77+ limiter := rate.NewLimiter(rate.Limit(s.rps), s.burst)
78+ entry := &limiterEntry{key: key, limiter: limiter, lastSeen: now}
79+ s.limiters[key] = s.lru.PushFront(entry)
6980 return limiter
7081 }
82+
83+func (s *MemoryStore) evictExpiredOrOldest(now time.Time) {
84+ for element := s.lru.Back(); element != nil; {
85+ entry := element.Value.(*limiterEntry)
86+ if now.Sub(entry.lastSeen) <= limiterIdleTTL {
87+ break
88+ }
89+ previous := element.Prev()
90+ s.removeElement(element)
91+ element = previous
92+ }
93+ if len(s.limiters) >= maxLimiterEntries {
94+ s.removeElement(s.lru.Back())
95+ }
96+}
97+
98+func (s *MemoryStore) removeElement(element *list.Element) {
99+ if element == nil {
100+ return
101+ }
102+ entry := element.Value.(*limiterEntry)
103+ delete(s.limiters, entry.key)
104+ s.lru.Remove(element)
105+}
@@ -2,6 +2,7 @@
2 package ratelimit2 package ratelimit
3 3
4 import (4 import (
5+ "container/list"
5 "context"6 "context"
6 "sync"7 "sync"
7 "time"8 "time"
@@ -17,9 +18,21 @@ var _ out.RateLimiter = (*MemoryStore)(nil)
17 18
18 // MemoryStore is an in-memory rate limiter implementation using golang.org/x/time/rate.19 // MemoryStore is an in-memory rate limiter implementation using golang.org/x/time/rate.
19 // Each unique key gets its own independent rate limiter.20 // Each unique key gets its own independent rate limiter.
21+type limiterEntry struct {
22+ key string
23+ limiter *rate.Limiter
24+ lastSeen time.Time
25+}
26+
27+const (
28+ maxLimiterEntries = 10000
29+ limiterIdleTTL = 10 * time.Minute
30+)
31+
20 type MemoryStore struct {32 type MemoryStore struct {
21- limiters map[string]*rate.Limiter33+ limiters map[string]*list.Element
22- mu sync.RWMutex34+ lru *list.List
35+ mu sync.Mutex
23 rps float6436 rps float64
24 burst int37 burst int
25 log zerowrap.Logger38 log zerowrap.Logger
@@ -28,7 +41,8 @@ type MemoryStore struct {
28 // NewMemoryStore creates a new in-memory rate limiter store.41 // NewMemoryStore creates a new in-memory rate limiter store.
29 func NewMemoryStore(rps float64, burst int, log zerowrap.Logger) *MemoryStore {42 func NewMemoryStore(rps float64, burst int, log zerowrap.Logger) *MemoryStore {
30 return &MemoryStore{43 return &MemoryStore{
31- limiters: make(map[string]*rate.Limiter),44+ limiters: make(map[string]*list.Element),
45+ lru: list.New(),
32 rps: rps,46 rps: rps,
33 burst: burst,47 burst: burst,
34 log: log,48 log: log,
@@ -48,23 +62,44 @@ func (s *MemoryStore) AllowN(_ context.Context, key string, n int) bool {
48 62
49 // getLimiter returns the rate limiter for the given key, creating one if it doesn't exist.63 // getLimiter returns the rate limiter for the given key, creating one if it doesn't exist.
50 func (s *MemoryStore) getLimiter(key string) *rate.Limiter {64 func (s *MemoryStore) getLimiter(key string) *rate.Limiter {
51- s.mu.RLock()65+ now := time.Now()
52- limiter, exists := s.limiters[key]
53- s.mu.RUnlock()
54-
55- if exists {
56- return limiter
57- }
58-
59 s.mu.Lock()66 s.mu.Lock()
60 defer s.mu.Unlock()67 defer s.mu.Unlock()
61 68
62- // Double-check after acquiring write lock69+ if element, exists := s.limiters[key]; exists {
63- if limiter, exists = s.limiters[key]; exists {70+ entry := element.Value.(*limiterEntry)
64- return limiter71+ entry.lastSeen = now
72+ s.lru.MoveToFront(element)
73+ return entry.limiter
65 }74 }
66 75
67- limiter = rate.NewLimiter(rate.Limit(s.rps), s.burst)76+ s.evictExpiredOrOldest(now)
68- s.limiters[key] = limiter77+ limiter := rate.NewLimiter(rate.Limit(s.rps), s.burst)
78+ entry := &limiterEntry{key: key, limiter: limiter, lastSeen: now}
79+ s.limiters[key] = s.lru.PushFront(entry)
69 return limiter80 return limiter
70 }81 }
82+
83+func (s *MemoryStore) evictExpiredOrOldest(now time.Time) {
84+ for element := s.lru.Back(); element != nil; {
85+ entry := element.Value.(*limiterEntry)
86+ if now.Sub(entry.lastSeen) <= limiterIdleTTL {
87+ break
88+ }
89+ previous := element.Prev()
90+ s.removeElement(element)
91+ element = previous
92+ }
93+ if len(s.limiters) >= maxLimiterEntries {
94+ s.removeElement(s.lru.Back())
95+ }
96+}
97+
98+func (s *MemoryStore) removeElement(element *list.Element) {
99+ if element == nil {
100+ return
101+ }
102+ entry := element.Value.(*limiterEntry)
103+ delete(s.limiters, entry.key)
104+ s.lru.Remove(element)
105+}
M internal/app/run.go +4 -2
@@ -84,6 +84,7 @@ import (
8484 "github.com/bnema/gordon/internal/usecase/proxy"
8585 "github.com/bnema/gordon/internal/usecase/publictls"
8686 registrySvc "github.com/bnema/gordon/internal/usecase/registry"
87+ "github.com/bnema/gordon/internal/usecase/registrystate"
8788 secretsSvc "github.com/bnema/gordon/internal/usecase/secrets"
8889 servicecfg "github.com/bnema/gordon/internal/usecase/services"
8990 "github.com/bnema/gordon/internal/usecase/traffic"
@@ -747,8 +748,9 @@ func (si *serviceInit) initRuntimeAndProxy() error {
747748 return err
748749 }
749750
750- si.svc.registrySvc = registrySvc.NewService(si.svc.blobStorage, si.svc.manifestStorage, si.svc.eventBus)
751- si.svc.imageSvc = images.NewService(si.svc.runtime, si.svc.manifestStorage, si.svc.blobStorage, si.log)
751+ registryState := registrystate.New()
752+ si.svc.registrySvc = registrySvc.NewService(si.svc.blobStorage, si.svc.manifestStorage, si.svc.eventBus, registryState)
753+ si.svc.imageSvc = images.NewService(si.svc.runtime, si.svc.manifestStorage, si.svc.blobStorage, si.log, registryState)
752754 si.svc.volumeSvc = volumesSvc.NewService(si.svc.runtime)
753755
754756 injectTelemetryMetrics(si.cfg, si.svc, si.log)
@@ -84,6 +84,7 @@ import (
84 "github.com/bnema/gordon/internal/usecase/proxy"84 "github.com/bnema/gordon/internal/usecase/proxy"
85 "github.com/bnema/gordon/internal/usecase/publictls"85 "github.com/bnema/gordon/internal/usecase/publictls"
86 registrySvc "github.com/bnema/gordon/internal/usecase/registry"86 registrySvc "github.com/bnema/gordon/internal/usecase/registry"
87+ "github.com/bnema/gordon/internal/usecase/registrystate"
87 secretsSvc "github.com/bnema/gordon/internal/usecase/secrets"88 secretsSvc "github.com/bnema/gordon/internal/usecase/secrets"
88 servicecfg "github.com/bnema/gordon/internal/usecase/services"89 servicecfg "github.com/bnema/gordon/internal/usecase/services"
89 "github.com/bnema/gordon/internal/usecase/traffic"90 "github.com/bnema/gordon/internal/usecase/traffic"
@@ -747,8 +748,9 @@ func (si *serviceInit) initRuntimeAndProxy() error {
747 return err748 return err
748 }749 }
749 750
750- si.svc.registrySvc = registrySvc.NewService(si.svc.blobStorage, si.svc.manifestStorage, si.svc.eventBus)751+ registryState := registrystate.New()
751- si.svc.imageSvc = images.NewService(si.svc.runtime, si.svc.manifestStorage, si.svc.blobStorage, si.log)752+ si.svc.registrySvc = registrySvc.NewService(si.svc.blobStorage, si.svc.manifestStorage, si.svc.eventBus, registryState)
753+ si.svc.imageSvc = images.NewService(si.svc.runtime, si.svc.manifestStorage, si.svc.blobStorage, si.log, registryState)
752 si.svc.volumeSvc = volumesSvc.NewService(si.svc.runtime)754 si.svc.volumeSvc = volumesSvc.NewService(si.svc.runtime)
753 755
754 injectTelemetryMetrics(si.cfg, si.svc, si.log)756 injectTelemetryMetrics(si.cfg, si.svc, si.log)
M internal/boundaries/in/mocks/mock_registry_service.go +18 -12
@@ -438,8 +438,8 @@ func (_c *MockRegistryService_GetBlob_Call) RunAndReturn(run func(ctx context.Co
438438 }
439439
440440 // GetBlobPath provides a mock function for the type MockRegistryService
441-func (_mock *MockRegistryService) GetBlobPath(ctx context.Context, digest string) (string, error) {
442- ret := _mock.Called(ctx, digest)
441+func (_mock *MockRegistryService) GetBlobPath(ctx context.Context, name string, digest string) (string, error) {
442+ ret := _mock.Called(ctx, name, digest)
443443
444444 if len(ret) == 0 {
445445 panic("no return value specified for GetBlobPath")
@@ -447,16 +447,16 @@ func (_mock *MockRegistryService) GetBlobPath(ctx context.Context, digest string
447447
448448 var r0 string
449449 var r1 error
450- if returnFunc, ok := ret.Get(0).(func(context.Context, string) (string, error)); ok {
451- return returnFunc(ctx, digest)
450+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) (string, error)); ok {
451+ return returnFunc(ctx, name, digest)
452452 }
453- if returnFunc, ok := ret.Get(0).(func(context.Context, string) string); ok {
454- r0 = returnFunc(ctx, digest)
453+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) string); ok {
454+ r0 = returnFunc(ctx, name, digest)
455455 } else {
456456 r0 = ret.Get(0).(string)
457457 }
458- if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok {
459- r1 = returnFunc(ctx, digest)
458+ if returnFunc, ok := ret.Get(1).(func(context.Context, string, string) error); ok {
459+ r1 = returnFunc(ctx, name, digest)
460460 } else {
461461 r1 = ret.Error(1)
462462 }
@@ -470,12 +470,13 @@ type MockRegistryService_GetBlobPath_Call struct {
470470
471471 // GetBlobPath is a helper method to define mock.On call
472472 // - ctx context.Context
473+// - name string
473474 // - digest string
474-func (_e *MockRegistryService_Expecter) GetBlobPath(ctx any, digest any) *MockRegistryService_GetBlobPath_Call {
475- return &MockRegistryService_GetBlobPath_Call{Call: _e.mock.On("GetBlobPath", ctx, digest)}
475+func (_e *MockRegistryService_Expecter) GetBlobPath(ctx any, name any, digest any) *MockRegistryService_GetBlobPath_Call {
476+ return &MockRegistryService_GetBlobPath_Call{Call: _e.mock.On("GetBlobPath", ctx, name, digest)}
476477 }
477478
478-func (_c *MockRegistryService_GetBlobPath_Call) Run(run func(ctx context.Context, digest string)) *MockRegistryService_GetBlobPath_Call {
479+func (_c *MockRegistryService_GetBlobPath_Call) Run(run func(ctx context.Context, name string, digest string)) *MockRegistryService_GetBlobPath_Call {
479480 _c.Call.Run(func(args mock.Arguments) {
480481 var arg0 context.Context
481482 if args[0] != nil {
@@ -485,9 +486,14 @@ func (_c *MockRegistryService_GetBlobPath_Call) Run(run func(ctx context.Context
485486 if args[1] != nil {
486487 arg1 = args[1].(string)
487488 }
489+ var arg2 string
490+ if args[2] != nil {
491+ arg2 = args[2].(string)
492+ }
488493 run(
489494 arg0,
490495 arg1,
496+ arg2,
491497 )
492498 })
493499 return _c
@@ -498,7 +504,7 @@ func (_c *MockRegistryService_GetBlobPath_Call) Return(s string, err error) *Moc
498504 return _c
499505 }
500506
501-func (_c *MockRegistryService_GetBlobPath_Call) RunAndReturn(run func(ctx context.Context, digest string) (string, error)) *MockRegistryService_GetBlobPath_Call {
507+func (_c *MockRegistryService_GetBlobPath_Call) RunAndReturn(run func(ctx context.Context, name string, digest string) (string, error)) *MockRegistryService_GetBlobPath_Call {
502508 _c.Call.Return(run)
503509 return _c
504510 }
@@ -438,8 +438,8 @@ func (_c *MockRegistryService_GetBlob_Call) RunAndReturn(run func(ctx context.Co
438 }438 }
439 439
440 // GetBlobPath provides a mock function for the type MockRegistryService440 // GetBlobPath provides a mock function for the type MockRegistryService
441-func (_mock *MockRegistryService) GetBlobPath(ctx context.Context, digest string) (string, error) {441+func (_mock *MockRegistryService) GetBlobPath(ctx context.Context, name string, digest string) (string, error) {
442- ret := _mock.Called(ctx, digest)442+ ret := _mock.Called(ctx, name, digest)
443 443
444 if len(ret) == 0 {444 if len(ret) == 0 {
445 panic("no return value specified for GetBlobPath")445 panic("no return value specified for GetBlobPath")
@@ -447,16 +447,16 @@ func (_mock *MockRegistryService) GetBlobPath(ctx context.Context, digest string
447 447
448 var r0 string448 var r0 string
449 var r1 error449 var r1 error
450- if returnFunc, ok := ret.Get(0).(func(context.Context, string) (string, error)); ok {450+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) (string, error)); ok {
451- return returnFunc(ctx, digest)451+ return returnFunc(ctx, name, digest)
452 }452 }
453- if returnFunc, ok := ret.Get(0).(func(context.Context, string) string); ok {453+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) string); ok {
454- r0 = returnFunc(ctx, digest)454+ r0 = returnFunc(ctx, name, digest)
455 } else {455 } else {
456 r0 = ret.Get(0).(string)456 r0 = ret.Get(0).(string)
457 }457 }
458- if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok {458+ if returnFunc, ok := ret.Get(1).(func(context.Context, string, string) error); ok {
459- r1 = returnFunc(ctx, digest)459+ r1 = returnFunc(ctx, name, digest)
460 } else {460 } else {
461 r1 = ret.Error(1)461 r1 = ret.Error(1)
462 }462 }
@@ -470,12 +470,13 @@ type MockRegistryService_GetBlobPath_Call struct {
470 470
471 // GetBlobPath is a helper method to define mock.On call471 // GetBlobPath is a helper method to define mock.On call
472 // - ctx context.Context472 // - ctx context.Context
473+// - name string
473 // - digest string474 // - digest string
474-func (_e *MockRegistryService_Expecter) GetBlobPath(ctx any, digest any) *MockRegistryService_GetBlobPath_Call {475+func (_e *MockRegistryService_Expecter) GetBlobPath(ctx any, name any, digest any) *MockRegistryService_GetBlobPath_Call {
475- return &MockRegistryService_GetBlobPath_Call{Call: _e.mock.On("GetBlobPath", ctx, digest)}476+ return &MockRegistryService_GetBlobPath_Call{Call: _e.mock.On("GetBlobPath", ctx, name, digest)}
476 }477 }
477 478
478-func (_c *MockRegistryService_GetBlobPath_Call) Run(run func(ctx context.Context, digest string)) *MockRegistryService_GetBlobPath_Call {479+func (_c *MockRegistryService_GetBlobPath_Call) Run(run func(ctx context.Context, name string, digest string)) *MockRegistryService_GetBlobPath_Call {
479 _c.Call.Run(func(args mock.Arguments) {480 _c.Call.Run(func(args mock.Arguments) {
480 var arg0 context.Context481 var arg0 context.Context
481 if args[0] != nil {482 if args[0] != nil {
@@ -485,9 +486,14 @@ func (_c *MockRegistryService_GetBlobPath_Call) Run(run func(ctx context.Context
485 if args[1] != nil {486 if args[1] != nil {
486 arg1 = args[1].(string)487 arg1 = args[1].(string)
487 }488 }
489+ var arg2 string
490+ if args[2] != nil {
491+ arg2 = args[2].(string)
492+ }
488 run(493 run(
489 arg0,494 arg0,
490 arg1,495 arg1,
496+ arg2,
491 )497 )
492 })498 })
493 return _c499 return _c
@@ -498,7 +504,7 @@ func (_c *MockRegistryService_GetBlobPath_Call) Return(s string, err error) *Moc
498 return _c504 return _c
499 }505 }
500 506
501-func (_c *MockRegistryService_GetBlobPath_Call) RunAndReturn(run func(ctx context.Context, digest string) (string, error)) *MockRegistryService_GetBlobPath_Call {507+func (_c *MockRegistryService_GetBlobPath_Call) RunAndReturn(run func(ctx context.Context, name string, digest string) (string, error)) *MockRegistryService_GetBlobPath_Call {
502 _c.Call.Return(run)508 _c.Call.Return(run)
503 return _c509 return _c
504 }510 }
M internal/boundaries/in/registry.go +1 -1
@@ -16,7 +16,7 @@ type RegistryService interface {
1616
1717 // Blob operations
1818 GetBlob(ctx context.Context, digest string) (io.ReadCloser, error)
19- GetBlobPath(ctx context.Context, digest string) (string, error)
19+ GetBlobPath(ctx context.Context, name, digest string) (string, error)
2020 PutBlob(ctx context.Context, digest string, data io.Reader, size int64) error
2121 BlobExists(ctx context.Context, digest string) bool
2222
@@ -16,7 +16,7 @@ type RegistryService interface {
16 16
17 // Blob operations17 // Blob operations
18 GetBlob(ctx context.Context, digest string) (io.ReadCloser, error)18 GetBlob(ctx context.Context, digest string) (io.ReadCloser, error)
19- GetBlobPath(ctx context.Context, digest string) (string, error)19+ GetBlobPath(ctx context.Context, name, digest string) (string, error)
20 PutBlob(ctx context.Context, digest string, data io.Reader, size int64) error20 PutBlob(ctx context.Context, digest string, data io.Reader, size int64) error
21 BlobExists(ctx context.Context, digest string) bool21 BlobExists(ctx context.Context, digest string) bool
22 22
M internal/domain/auth.go +4 -8
@@ -35,6 +35,7 @@ type TokenClaims struct {
3535 IssuedAt int64 `json:"iat"`
3636 ExpiresAt int64 `json:"exp,omitempty"` // 0 means never expires
3737 Issuer string `json:"iss"`
38+ TokenType string `json:"token_type"`
3839 IsEphemeral bool `json:"-"` // true when token was issued by /auth/token (not stored)
3940 }
4041
@@ -257,17 +258,12 @@ func ScopesGrantRegistryAccess(grantedScopes []string, repoName, action string)
257258 return false
258259 }
259260
260-// ScopesGrantAdminAccess reports whether any of the granted scope strings
261-// authorise the given action on the named admin resource.
262-// It handles the wildcard shorthand ("*") as well as full admin scope format
263-// (admin:resource:actions).
261+// ScopesGrantAdminAccess reports whether an explicit admin scope authorises
262+// the given action on the named admin resource. Untyped shorthand scopes such
263+// as "*" are registry-only and never grant administrative access.
264264 func ScopesGrantAdminAccess(grantedScopes []string, resource, action string) bool {
265265 for _, raw := range grantedScopes {
266266 scopeStr := strings.TrimSpace(raw)
267- if scopeStr == ScopeActionAll {
268- return true
269- }
270-
271267 adminScope, err := ParseAdminScope(scopeStr)
272268 if err != nil {
273269 continue
@@ -35,6 +35,7 @@ type TokenClaims struct {
35 IssuedAt int64 `json:"iat"`35 IssuedAt int64 `json:"iat"`
36 ExpiresAt int64 `json:"exp,omitempty"` // 0 means never expires36 ExpiresAt int64 `json:"exp,omitempty"` // 0 means never expires
37 Issuer string `json:"iss"`37 Issuer string `json:"iss"`
38+ TokenType string `json:"token_type"`
38 IsEphemeral bool `json:"-"` // true when token was issued by /auth/token (not stored)39 IsEphemeral bool `json:"-"` // true when token was issued by /auth/token (not stored)
39 }40 }
40 41
@@ -257,17 +258,12 @@ func ScopesGrantRegistryAccess(grantedScopes []string, repoName, action string)
257 return false258 return false
258 }259 }
259 260
260-// ScopesGrantAdminAccess reports whether any of the granted scope strings261+// ScopesGrantAdminAccess reports whether an explicit admin scope authorises
261-// authorise the given action on the named admin resource.262+// the given action on the named admin resource. Untyped shorthand scopes such
262-// It handles the wildcard shorthand ("*") as well as full admin scope format263+// as "*" are registry-only and never grant administrative access.
263-// (admin:resource:actions).
264 func ScopesGrantAdminAccess(grantedScopes []string, resource, action string) bool {264 func ScopesGrantAdminAccess(grantedScopes []string, resource, action string) bool {
265 for _, raw := range grantedScopes {265 for _, raw := range grantedScopes {
266 scopeStr := strings.TrimSpace(raw)266 scopeStr := strings.TrimSpace(raw)
267- if scopeStr == ScopeActionAll {
268- return true
269- }
270-
271 adminScope, err := ParseAdminScope(scopeStr)267 adminScope, err := ParseAdminScope(scopeStr)
272 if err != nil {268 if err != nil {
273 continue269 continue
M internal/domain/auth_test.go +2 -2
@@ -443,11 +443,11 @@ func TestScopesGrantAdminAccess(t *testing.T) {
443443 want bool
444444 }{
445445 {
446- name: "wildcard shorthand grants any admin access",
446+ name: "wildcard shorthand does not grant admin access",
447447 granted: []string{"*"},
448448 resource: "routes",
449449 action: "read",
450- want: true,
450+ want: false,
451451 },
452452 {
453453 name: "exact admin scope grants matching resource and action",
@@ -443,11 +443,11 @@ func TestScopesGrantAdminAccess(t *testing.T) {
443 want bool443 want bool
444 }{444 }{
445 {445 {
446- name: "wildcard shorthand grants any admin access",446+ name: "wildcard shorthand does not grant admin access",
447 granted: []string{"*"},447 granted: []string{"*"},
448 resource: "routes",448 resource: "routes",
449 action: "read",449 action: "read",
450- want: true,450+ want: false,
451 },451 },
452 {452 {
453 name: "exact admin scope grants matching resource and action",453 name: "exact admin scope grants matching resource and action",
M internal/domain/errors.go +15 -12
@@ -28,13 +28,14 @@ var (
2828 ErrNoTargetAvailable = errors.New("no target available for route")
2929
3030 // 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")
3839
3940 // Network errors
4041 ErrNetworkNotFound = errors.New("network not found")
@@ -58,11 +59,13 @@ var (
5859 ErrRouteConflict = errors.New("route conflicts with existing configuration")
5960
6061 // 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")
6669
6770 // Authentication errors
6871 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 errors30 // 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 errors40 // 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 errors61 // 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 errors70 // Authentication errors
68 ErrInvalidToken = errors.New("invalid token")71 ErrInvalidToken = errors.New("invalid token")
M internal/domain/sanitize.go +11 -1
@@ -1,6 +1,10 @@
11 package domain
22
3-import "strings"
3+import (
4+ "crypto/sha256"
5+ "fmt"
6+ "strings"
7+)
48
59 // SanitizeDomainForContainer makes a domain safe for container naming.
610 // It uses distinct replacements to avoid collisions between domains like
@@ -19,6 +23,12 @@ func SanitizeDomainForContainer(domain string) string {
1923 return result
2024 }
2125
26+// StableResourceName returns a Docker-safe, collision-resistant name fragment.
27+func StableResourceName(value string) string {
28+ digest := sha256.Sum256([]byte(value))
29+ return fmt.Sprintf("%s-%x", SanitizeDomainForContainer(value), digest[:6])
30+}
31+
2232 // SanitizeDomainForContainerLegacy uses the OLD (buggy) sanitization that replaces
2333 // all separators with hyphens, for backwards compatibility.
2434 //
@@ -1,6 +1,10 @@
1 package domain1 package domain
2 2
3-import "strings"3+import (
4+ "crypto/sha256"
5+ "fmt"
6+ "strings"
7+)
4 8
5 // SanitizeDomainForContainer makes a domain safe for container naming.9 // SanitizeDomainForContainer makes a domain safe for container naming.
6 // It uses distinct replacements to avoid collisions between domains like10 // It uses distinct replacements to avoid collisions between domains like
@@ -19,6 +23,12 @@ func SanitizeDomainForContainer(domain string) string {
19 return result23 return result
20 }24 }
21 25
26+// StableResourceName returns a Docker-safe, collision-resistant name fragment.
27+func StableResourceName(value string) string {
28+ digest := sha256.Sum256([]byte(value))
29+ return fmt.Sprintf("%s-%x", SanitizeDomainForContainer(value), digest[:6])
30+}
31+
22 // SanitizeDomainForContainerLegacy uses the OLD (buggy) sanitization that replaces32 // SanitizeDomainForContainerLegacy uses the OLD (buggy) sanitization that replaces
23 // all separators with hyphens, for backwards compatibility.33 // all separators with hyphens, for backwards compatibility.
24 //34 //
M internal/usecase/auth/service.go +29 -20
@@ -200,6 +200,9 @@ func buildTokenClaims(claims jwt.MapClaims) *domain.TokenClaims {
200200 }
201201 }
202202 }
203+ if tokenType, ok := claims["token_type"].(string); ok {
204+ tokenClaims.TokenType = tokenType
205+ }
203206
204207 return tokenClaims
205208 }
@@ -238,6 +241,9 @@ func ensureTokenNotExpired(tokenClaims *domain.TokenClaims, log zerowrap.Logger)
238241 // 2. Have lifetime ≤ MaxAccessTokenLifetime
239242 // 3. Be recently issued (within MaxAccessTokenLifetime) to prevent replay attacks
240243 func (s *Service) isEphemeralAccessToken(claims *domain.TokenClaims) bool {
244+ if claims.TokenType != "access" {
245+ return false // Only tokens minted by GenerateAccessToken may bypass the store
246+ }
241247 if claims.ExpiresAt <= 0 {
242248 return false // Never-expiring tokens require store validation
243249 }
@@ -289,12 +295,13 @@ func (s *Service) GenerateToken(ctx context.Context, subject string, scopes []st
289295
290296 // Build JWT claims
291297 claims := jwt.MapClaims{
292- "jti": tokenID,
293- "sub": subject,
294- "iss": TokenIssuer,
295- "iat": now.Unix(),
296- "nbf": now.Unix(), // SECURITY: Not-before claim for clock skew protection
297- "scopes": scopes,
298+ "jti": tokenID,
299+ "sub": subject,
300+ "iss": TokenIssuer,
301+ "iat": now.Unix(),
302+ "nbf": now.Unix(), // SECURITY: Not-before claim for clock skew protection
303+ "scopes": scopes,
304+ "token_type": "stored",
298305 }
299306
300307 // Set expiry if specified
@@ -349,13 +356,14 @@ func (s *Service) GenerateAccessToken(ctx context.Context, subject string, scope
349356 now := time.Now().UTC()
350357
351358 claims := jwt.MapClaims{
352- "jti": tokenID,
353- "sub": subject,
354- "iss": TokenIssuer,
355- "iat": now.Unix(),
356- "nbf": now.Unix(), // SECURITY: Not-before claim for clock skew protection
357- "scopes": scopes,
358- "exp": now.Add(expiry).Unix(),
359+ "jti": tokenID,
360+ "sub": subject,
361+ "iss": TokenIssuer,
362+ "iat": now.Unix(),
363+ "nbf": now.Unix(), // SECURITY: Not-before claim for clock skew protection
364+ "scopes": scopes,
365+ "exp": now.Add(expiry).Unix(),
366+ "token_type": "access",
359367 }
360368
361369 jwtToken := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
@@ -507,13 +515,14 @@ func (s *Service) ExtendToken(ctx context.Context, tokenString string) (string,
507515 newExpiresAt := now.Add(tokenExtensionTTL)
508516
509517 newClaims := jwt.MapClaims{
510- "jti": tokenClaims.ID, // Reuse existing JTI to avoid invalidating concurrent requests
511- "sub": tokenClaims.Subject,
512- "iss": TokenIssuer,
513- "iat": now.Unix(), // Update to current time (token is being re-issued)
514- "nbf": now.Unix(), // SECURITY: not-before matches issuance time
515- "scopes": tokenClaims.Scopes,
516- "exp": newExpiresAt.Unix(),
518+ "jti": tokenClaims.ID, // Reuse existing JTI to avoid invalidating concurrent requests
519+ "sub": tokenClaims.Subject,
520+ "iss": TokenIssuer,
521+ "iat": now.Unix(), // Update to current time (token is being re-issued)
522+ "nbf": now.Unix(), // SECURITY: not-before matches issuance time
523+ "scopes": tokenClaims.Scopes,
524+ "exp": newExpiresAt.Unix(),
525+ "token_type": "stored",
517526 }
518527
519528 jwtToken := jwt.NewWithClaims(jwt.SigningMethodHS256, newClaims)
@@ -200,6 +200,9 @@ func buildTokenClaims(claims jwt.MapClaims) *domain.TokenClaims {
200 }200 }
201 }201 }
202 }202 }
203+ if tokenType, ok := claims["token_type"].(string); ok {
204+ tokenClaims.TokenType = tokenType
205+ }
203 206
204 return tokenClaims207 return tokenClaims
205 }208 }
@@ -238,6 +241,9 @@ func ensureTokenNotExpired(tokenClaims *domain.TokenClaims, log zerowrap.Logger)
238 // 2. Have lifetime ≤ MaxAccessTokenLifetime241 // 2. Have lifetime ≤ MaxAccessTokenLifetime
239 // 3. Be recently issued (within MaxAccessTokenLifetime) to prevent replay attacks242 // 3. Be recently issued (within MaxAccessTokenLifetime) to prevent replay attacks
240 func (s *Service) isEphemeralAccessToken(claims *domain.TokenClaims) bool {243 func (s *Service) isEphemeralAccessToken(claims *domain.TokenClaims) bool {
244+ if claims.TokenType != "access" {
245+ return false // Only tokens minted by GenerateAccessToken may bypass the store
246+ }
241 if claims.ExpiresAt <= 0 {247 if claims.ExpiresAt <= 0 {
242 return false // Never-expiring tokens require store validation248 return false // Never-expiring tokens require store validation
243 }249 }
@@ -289,12 +295,13 @@ func (s *Service) GenerateToken(ctx context.Context, subject string, scopes []st
289 295
290 // Build JWT claims296 // Build JWT claims
291 claims := jwt.MapClaims{297 claims := jwt.MapClaims{
292- "jti": tokenID,298+ "jti": tokenID,
293- "sub": subject,299+ "sub": subject,
294- "iss": TokenIssuer,300+ "iss": TokenIssuer,
295- "iat": now.Unix(),301+ "iat": now.Unix(),
296- "nbf": now.Unix(), // SECURITY: Not-before claim for clock skew protection302+ "nbf": now.Unix(), // SECURITY: Not-before claim for clock skew protection
297- "scopes": scopes,303+ "scopes": scopes,
304+ "token_type": "stored",
298 }305 }
299 306
300 // Set expiry if specified307 // Set expiry if specified
@@ -349,13 +356,14 @@ func (s *Service) GenerateAccessToken(ctx context.Context, subject string, scope
349 now := time.Now().UTC()356 now := time.Now().UTC()
350 357
351 claims := jwt.MapClaims{358 claims := jwt.MapClaims{
352- "jti": tokenID,359+ "jti": tokenID,
353- "sub": subject,360+ "sub": subject,
354- "iss": TokenIssuer,361+ "iss": TokenIssuer,
355- "iat": now.Unix(),362+ "iat": now.Unix(),
356- "nbf": now.Unix(), // SECURITY: Not-before claim for clock skew protection363+ "nbf": now.Unix(), // SECURITY: Not-before claim for clock skew protection
357- "scopes": scopes,364+ "scopes": scopes,
358- "exp": now.Add(expiry).Unix(),365+ "exp": now.Add(expiry).Unix(),
366+ "token_type": "access",
359 }367 }
360 368
361 jwtToken := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)369 jwtToken := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
@@ -507,13 +515,14 @@ func (s *Service) ExtendToken(ctx context.Context, tokenString string) (string,
507 newExpiresAt := now.Add(tokenExtensionTTL)515 newExpiresAt := now.Add(tokenExtensionTTL)
508 516
509 newClaims := jwt.MapClaims{517 newClaims := jwt.MapClaims{
510- "jti": tokenClaims.ID, // Reuse existing JTI to avoid invalidating concurrent requests518+ "jti": tokenClaims.ID, // Reuse existing JTI to avoid invalidating concurrent requests
511- "sub": tokenClaims.Subject,519+ "sub": tokenClaims.Subject,
512- "iss": TokenIssuer,520+ "iss": TokenIssuer,
513- "iat": now.Unix(), // Update to current time (token is being re-issued)521+ "iat": now.Unix(), // Update to current time (token is being re-issued)
514- "nbf": now.Unix(), // SECURITY: not-before matches issuance time522+ "nbf": now.Unix(), // SECURITY: not-before matches issuance time
515- "scopes": tokenClaims.Scopes,523+ "scopes": tokenClaims.Scopes,
516- "exp": newExpiresAt.Unix(),524+ "exp": newExpiresAt.Unix(),
525+ "token_type": "stored",
517 }526 }
518 527
519 jwtToken := jwt.NewWithClaims(jwt.SigningMethodHS256, newClaims)528 jwtToken := jwt.NewWithClaims(jwt.SigningMethodHS256, newClaims)
M internal/usecase/auth/service_test.go +15 -0
@@ -802,6 +802,7 @@ func TestExtendTokenSlidesExpiry(t *testing.T) {
802802 expectedExpiry := time.Now().Add(24 * time.Hour)
803803 actualExpiry := time.Unix(claims.ExpiresAt, 0)
804804 assert.WithinDuration(t, expectedExpiry, actualExpiry, 5*time.Minute, "expiry should be ~24h from now")
805+ assert.Equal(t, "stored", claims.TokenType)
805806 }
806807
807808 func TestExtendTokenDebounce(t *testing.T) {
@@ -939,6 +940,20 @@ func TestIsEphemeralAccessTokenRejectsFutureIat(t *testing.T) {
939940 }
940941 }
941942
943+func TestIsEphemeralAccessTokenRequiresExplicitTokenType(t *testing.T) {
944+ svc, _ := newTestAuthService(t)
945+ now := time.Now().UTC()
946+
947+ claims := &domain.TokenClaims{
948+ IssuedAt: now.Unix(),
949+ ExpiresAt: now.Add(5 * time.Minute).Unix(),
950+ }
951+
952+ assert.False(t, svc.isEphemeralAccessToken(claims))
953+ claims.TokenType = "access"
954+ assert.True(t, svc.isEphemeralAccessToken(claims))
955+}
956+
942957 func TestExtendTokenSkipsServiceToken(t *testing.T) {
943958 tokenStore := mocks.NewMockTokenStore(t)
944959
@@ -802,6 +802,7 @@ func TestExtendTokenSlidesExpiry(t *testing.T) {
802 expectedExpiry := time.Now().Add(24 * time.Hour)802 expectedExpiry := time.Now().Add(24 * time.Hour)
803 actualExpiry := time.Unix(claims.ExpiresAt, 0)803 actualExpiry := time.Unix(claims.ExpiresAt, 0)
804 assert.WithinDuration(t, expectedExpiry, actualExpiry, 5*time.Minute, "expiry should be ~24h from now")804 assert.WithinDuration(t, expectedExpiry, actualExpiry, 5*time.Minute, "expiry should be ~24h from now")
805+ assert.Equal(t, "stored", claims.TokenType)
805 }806 }
806 807
807 func TestExtendTokenDebounce(t *testing.T) {808 func TestExtendTokenDebounce(t *testing.T) {
@@ -939,6 +940,20 @@ func TestIsEphemeralAccessTokenRejectsFutureIat(t *testing.T) {
939 }940 }
940 }941 }
941 942
943+func TestIsEphemeralAccessTokenRequiresExplicitTokenType(t *testing.T) {
944+ svc, _ := newTestAuthService(t)
945+ now := time.Now().UTC()
946+
947+ claims := &domain.TokenClaims{
948+ IssuedAt: now.Unix(),
949+ ExpiresAt: now.Add(5 * time.Minute).Unix(),
950+ }
951+
952+ assert.False(t, svc.isEphemeralAccessToken(claims))
953+ claims.TokenType = "access"
954+ assert.True(t, svc.isEphemeralAccessToken(claims))
955+}
956+
942 func TestExtendTokenSkipsServiceToken(t *testing.T) {957 func TestExtendTokenSkipsServiceToken(t *testing.T) {
943 tokenStore := mocks.NewMockTokenStore(t)958 tokenStore := mocks.NewMockTokenStore(t)
944 959
M internal/usecase/auto/preview/handler.go +12 -1
@@ -16,6 +16,7 @@ type AutoPreviewHandler struct {
1616 serviceCtx context.Context
1717 config auto.AutoConfigProvider
1818 previewService *Service
19+ jobs chan struct{}
1920 }
2021
2122 func NewAutoPreviewHandler(
@@ -27,6 +28,7 @@ func NewAutoPreviewHandler(
2728 serviceCtx: serviceCtx,
2829 config: config,
2930 previewService: previewService,
31+ jobs: make(chan struct{}, 16),
3032 }
3133 }
3234
@@ -73,8 +75,17 @@ func (h *AutoPreviewHandler) Handle(ctx context.Context, event domain.Event) err
7375 continue
7476 }
7577
76- // Launch async — volume cloning may exceed event bus timeout
78+ // Launch async — volume cloning may exceed event bus timeout. The bounded
79+ // semaphore prevents a push burst from creating an unbounded goroutine backlog.
80+ select {
81+ case h.jobs <- struct{}{}:
82+ case <-ctx.Done():
83+ return fmt.Errorf("schedule preview %s for %s: %w", previewName, baseRoute.Domain, ctx.Err())
84+ case <-h.serviceCtx.Done():
85+ return h.serviceCtx.Err()
86+ }
7787 go func(baseRoute baseRouteInfo, previewDomain string) {
88+ defer func() { <-h.jobs }()
7889 if err := h.previewService.CreatePreview(h.serviceCtx, CreatePreviewRequest{
7990 Name: previewName,
8091 Domain: previewDomain,
@@ -16,6 +16,7 @@ type AutoPreviewHandler struct {
16 serviceCtx context.Context16 serviceCtx context.Context
17 config auto.AutoConfigProvider17 config auto.AutoConfigProvider
18 previewService *Service18 previewService *Service
19+ jobs chan struct{}
19 }20 }
20 21
21 func NewAutoPreviewHandler(22 func NewAutoPreviewHandler(
@@ -27,6 +28,7 @@ func NewAutoPreviewHandler(
27 serviceCtx: serviceCtx,28 serviceCtx: serviceCtx,
28 config: config,29 config: config,
29 previewService: previewService,30 previewService: previewService,
31+ jobs: make(chan struct{}, 16),
30 }32 }
31 }33 }
32 34
@@ -73,8 +75,17 @@ func (h *AutoPreviewHandler) Handle(ctx context.Context, event domain.Event) err
73 continue75 continue
74 }76 }
75 77
76- // Launch async — volume cloning may exceed event bus timeout78+ // Launch async — volume cloning may exceed event bus timeout. The bounded
79+ // semaphore prevents a push burst from creating an unbounded goroutine backlog.
80+ select {
81+ case h.jobs <- struct{}{}:
82+ case <-ctx.Done():
83+ return fmt.Errorf("schedule preview %s for %s: %w", previewName, baseRoute.Domain, ctx.Err())
84+ case <-h.serviceCtx.Done():
85+ return h.serviceCtx.Err()
86+ }
77 go func(baseRoute baseRouteInfo, previewDomain string) {87 go func(baseRoute baseRouteInfo, previewDomain string) {
88+ defer func() { <-h.jobs }()
78 if err := h.previewService.CreatePreview(h.serviceCtx, CreatePreviewRequest{89 if err := h.previewService.CreatePreview(h.serviceCtx, CreatePreviewRequest{
79 Name: previewName,90 Name: previewName,
80 Domain: previewDomain,91 Domain: previewDomain,
M internal/usecase/auto/preview/service.go +22 -8
@@ -92,7 +92,17 @@ func (s *Service) Load(ctx context.Context) error {
9292
9393 func (s *Service) Add(ctx context.Context, p domain.PreviewRoute) error {
9494 s.mu.Lock()
95- s.previews = append(s.previews, p)
95+ replaced := false
96+ for i := range s.previews {
97+ if s.previews[i].Domain == p.Domain {
98+ s.previews[i] = p
99+ replaced = true
100+ break
101+ }
102+ }
103+ if !replaced {
104+ s.previews = append(s.previews, p)
105+ }
96106 cp := make([]domain.PreviewRoute, len(s.previews))
97107 copy(cp, s.previews)
98108 s.mu.Unlock()
@@ -102,7 +112,7 @@ func (s *Service) Add(ctx context.Context, p domain.PreviewRoute) error {
102112 func (s *Service) Update(ctx context.Context, p domain.PreviewRoute) error {
103113 s.mu.Lock()
104114 for i := range s.previews {
105- if s.previews[i].Name == p.Name {
115+ if s.previews[i].Domain == p.Domain {
106116 s.previews[i] = p
107117 cp := make([]domain.PreviewRoute, len(s.previews))
108118 copy(cp, s.previews)
@@ -313,8 +323,8 @@ func (s *Service) qualifyImage(image string) string {
313323 func generateVolumeName(prefix, domainName, volumePath string) string {
314324 return fmt.Sprintf("%s-%s-%s",
315325 prefix,
316- strings.ReplaceAll(domainName, ".", "-"),
317- strings.ReplaceAll(strings.Trim(volumePath, "/"), "/", "-"))
326+ domain.StableResourceName(domainName),
327+ domain.StableResourceName(strings.Trim(volumePath, "/")))
318328 }
319329
320330 func (s *Service) cloneBaseRouteVolumes(ctx context.Context, baseRoute, previewDomain string) ([]string, error) {
@@ -328,7 +338,8 @@ func (s *Service) cloneBaseRouteVolumes(ctx context.Context, baseRoute, previewD
328338 }
329339
330340 log := zerowrap.FromCtx(ctx)
331- basePrefix := prefix + "-" + strings.ReplaceAll(baseRoute, ".", "-") + "-"
341+ basePrefix := prefix + "-" + domain.StableResourceName(baseRoute) + "-"
342+ legacyBasePrefix := prefix + "-" + strings.ReplaceAll(baseRoute, ".", "-") + "-"
332343
333344 vols, err := s.volumeCloner.ListVolumes(ctx)
334345 if err != nil {
@@ -337,7 +348,7 @@ func (s *Service) cloneBaseRouteVolumes(ctx context.Context, baseRoute, previewD
337348
338349 var sourceVols []string
339350 for _, v := range vols {
340- if strings.HasPrefix(v.Name, basePrefix) {
351+ if strings.HasPrefix(v.Name, basePrefix) || strings.HasPrefix(v.Name, legacyBasePrefix) {
341352 sourceVols = append(sourceVols, v.Name)
342353 }
343354 }
@@ -348,6 +359,9 @@ func (s *Service) cloneBaseRouteVolumes(ctx context.Context, baseRoute, previewD
348359
349360 namer := func(sourceVolName string) string {
350361 pathSuffix := strings.TrimPrefix(sourceVolName, basePrefix)
362+ if pathSuffix == sourceVolName {
363+ pathSuffix = strings.TrimPrefix(sourceVolName, legacyBasePrefix)
364+ }
351365 return generateVolumeName(prefix, previewDomain, pathSuffix)
352366 }
353367
@@ -370,11 +384,11 @@ type CreatePreviewRequest struct {
370384
371385 // CreatePreview orchestrates the full preview creation: lock, clone volumes, start containers, register.
372386 func (s *Service) CreatePreview(ctx context.Context, req CreatePreviewRequest) error {
373- lock := s.AcquireNameLock(req.Name)
387+ lock := s.AcquireNameLock(req.Domain)
374388 lock.Lock()
375389 defer func() {
376390 lock.Unlock()
377- s.ReleaseNameLock(req.Name)
391+ s.ReleaseNameLock(req.Domain)
378392 }()
379393
380394 log := zerowrap.FromCtx(ctx)
@@ -92,7 +92,17 @@ func (s *Service) Load(ctx context.Context) error {
92 92
93 func (s *Service) Add(ctx context.Context, p domain.PreviewRoute) error {93 func (s *Service) Add(ctx context.Context, p domain.PreviewRoute) error {
94 s.mu.Lock()94 s.mu.Lock()
95- s.previews = append(s.previews, p)95+ replaced := false
96+ for i := range s.previews {
97+ if s.previews[i].Domain == p.Domain {
98+ s.previews[i] = p
99+ replaced = true
100+ break
101+ }
102+ }
103+ if !replaced {
104+ s.previews = append(s.previews, p)
105+ }
96 cp := make([]domain.PreviewRoute, len(s.previews))106 cp := make([]domain.PreviewRoute, len(s.previews))
97 copy(cp, s.previews)107 copy(cp, s.previews)
98 s.mu.Unlock()108 s.mu.Unlock()
@@ -102,7 +112,7 @@ func (s *Service) Add(ctx context.Context, p domain.PreviewRoute) error {
102 func (s *Service) Update(ctx context.Context, p domain.PreviewRoute) error {112 func (s *Service) Update(ctx context.Context, p domain.PreviewRoute) error {
103 s.mu.Lock()113 s.mu.Lock()
104 for i := range s.previews {114 for i := range s.previews {
105- if s.previews[i].Name == p.Name {115+ if s.previews[i].Domain == p.Domain {
106 s.previews[i] = p116 s.previews[i] = p
107 cp := make([]domain.PreviewRoute, len(s.previews))117 cp := make([]domain.PreviewRoute, len(s.previews))
108 copy(cp, s.previews)118 copy(cp, s.previews)
@@ -313,8 +323,8 @@ func (s *Service) qualifyImage(image string) string {
313 func generateVolumeName(prefix, domainName, volumePath string) string {323 func generateVolumeName(prefix, domainName, volumePath string) string {
314 return fmt.Sprintf("%s-%s-%s",324 return fmt.Sprintf("%s-%s-%s",
315 prefix,325 prefix,
316- strings.ReplaceAll(domainName, ".", "-"),326+ domain.StableResourceName(domainName),
317- strings.ReplaceAll(strings.Trim(volumePath, "/"), "/", "-"))327+ domain.StableResourceName(strings.Trim(volumePath, "/")))
318 }328 }
319 329
320 func (s *Service) cloneBaseRouteVolumes(ctx context.Context, baseRoute, previewDomain string) ([]string, error) {330 func (s *Service) cloneBaseRouteVolumes(ctx context.Context, baseRoute, previewDomain string) ([]string, error) {
@@ -328,7 +338,8 @@ func (s *Service) cloneBaseRouteVolumes(ctx context.Context, baseRoute, previewD
328 }338 }
329 339
330 log := zerowrap.FromCtx(ctx)340 log := zerowrap.FromCtx(ctx)
331- basePrefix := prefix + "-" + strings.ReplaceAll(baseRoute, ".", "-") + "-"341+ basePrefix := prefix + "-" + domain.StableResourceName(baseRoute) + "-"
342+ legacyBasePrefix := prefix + "-" + strings.ReplaceAll(baseRoute, ".", "-") + "-"
332 343
333 vols, err := s.volumeCloner.ListVolumes(ctx)344 vols, err := s.volumeCloner.ListVolumes(ctx)
334 if err != nil {345 if err != nil {
@@ -337,7 +348,7 @@ func (s *Service) cloneBaseRouteVolumes(ctx context.Context, baseRoute, previewD
337 348
338 var sourceVols []string349 var sourceVols []string
339 for _, v := range vols {350 for _, v := range vols {
340- if strings.HasPrefix(v.Name, basePrefix) {351+ if strings.HasPrefix(v.Name, basePrefix) || strings.HasPrefix(v.Name, legacyBasePrefix) {
341 sourceVols = append(sourceVols, v.Name)352 sourceVols = append(sourceVols, v.Name)
342 }353 }
343 }354 }
@@ -348,6 +359,9 @@ func (s *Service) cloneBaseRouteVolumes(ctx context.Context, baseRoute, previewD
348 359
349 namer := func(sourceVolName string) string {360 namer := func(sourceVolName string) string {
350 pathSuffix := strings.TrimPrefix(sourceVolName, basePrefix)361 pathSuffix := strings.TrimPrefix(sourceVolName, basePrefix)
362+ if pathSuffix == sourceVolName {
363+ pathSuffix = strings.TrimPrefix(sourceVolName, legacyBasePrefix)
364+ }
351 return generateVolumeName(prefix, previewDomain, pathSuffix)365 return generateVolumeName(prefix, previewDomain, pathSuffix)
352 }366 }
353 367
@@ -370,11 +384,11 @@ type CreatePreviewRequest struct {
370 384
371 // CreatePreview orchestrates the full preview creation: lock, clone volumes, start containers, register.385 // CreatePreview orchestrates the full preview creation: lock, clone volumes, start containers, register.
372 func (s *Service) CreatePreview(ctx context.Context, req CreatePreviewRequest) error {386 func (s *Service) CreatePreview(ctx context.Context, req CreatePreviewRequest) error {
373- lock := s.AcquireNameLock(req.Name)387+ lock := s.AcquireNameLock(req.Domain)
374 lock.Lock()388 lock.Lock()
375 defer func() {389 defer func() {
376 lock.Unlock()390 lock.Unlock()
377- s.ReleaseNameLock(req.Name)391+ s.ReleaseNameLock(req.Domain)
378 }()392 }()
379 393
380 log := zerowrap.FromCtx(ctx)394 log := zerowrap.FromCtx(ctx)
M internal/usecase/container/service.go +75 -15
@@ -211,7 +211,7 @@ func (s *Service) acquireDomainDeployLock(ctx context.Context, domain string) (f
211211
212212 // buildContainerConfig constructs the container configuration for deployment.
213213 func (s *Service) deploymentContainerName(containerDomain string, existing *domain.Container) string {
214- canonicalName := fmt.Sprintf("gordon-%s", containerDomain)
214+ canonicalName := managedContainerName(containerDomain)
215215 if existing == nil {
216216 return canonicalName
217217 }
@@ -517,7 +517,7 @@ func (s *Service) resolveExistingContainer(ctx context.Context, domainName strin
517517
518518 // Slow path: query runtime for running containers
519519 log := zerowrap.FromCtx(ctx)
520- canonicalName := fmt.Sprintf("gordon-%s", domainName)
520+ canonicalName := managedContainerName(domainName)
521521 candidateNames := map[string]bool{
522522 canonicalName: true,
523523 canonicalName + "-new": true,
@@ -1333,7 +1333,7 @@ func isManagedRouteContainerForDomain(c *domain.Container, domainName string) bo
13331333 if c.Labels[domain.LabelAttachment] == "true" {
13341334 return false
13351335 }
1336- return c.Labels[domain.LabelRoute] == domainName || c.Labels[domain.LabelDomain] == domainName || c.Name == fmt.Sprintf("gordon-%s", domainName)
1336+ return c.Labels[domain.LabelRoute] == domainName || c.Labels[domain.LabelDomain] == domainName || c.Name == managedContainerName(domainName)
13371337 }
13381338
13391339 func isPreservedAttachmentForDomain(c *domain.Container, domainName string) bool {
@@ -1997,7 +1997,7 @@ func (s *Service) cleanupOldContainer(ctx context.Context, old *domain.Container
19971997 }
19981998
19991999 // Rename new container to canonical name
2000- canonicalName := fmt.Sprintf("gordon-%s", domainName)
2000+ canonicalName := managedContainerName(domainName)
20012001 if err := s.runtime.RenameContainer(ctx, newContainerID, canonicalName); err != nil {
20022002 log.Warn().Err(err).Str("canonical_name", canonicalName).Msg("failed to rename container to canonical name")
20032003 }
@@ -2431,14 +2431,10 @@ func (s *Service) pullImage(ctx context.Context, pullRef string, isInternal bool
24312431 }, isInternal); err != nil {
24322432 return log.WrapErr(fmt.Errorf("%w: %w", domain.ErrImagePullFailed, err), "failed to pull image")
24332433 }
2434- case cfg.RegistryAuthEnabled:
2435- if cfg.ServiceTokenUsername == "" || cfg.ServiceToken == "" {
2436- return log.WrapErr(fmt.Errorf("registry service token not configured"), "failed to pull image for registry auth")
2437- }
2438- if err := s.runtime.PullImageWithAuth(ctx, pullRef, cfg.ServiceTokenUsername, cfg.ServiceToken); err != nil {
2439- return log.WrapErr(fmt.Errorf("%w: %w", domain.ErrImagePullFailed, err), "failed to pull image with auth")
2440- }
24412434 default:
2435+ // Never send Gordon's service credentials to an external registry.
2436+ // External pulls are anonymous until per-registry credentials have an
2437+ // explicit trust-boundary-aware configuration path.
24422438 if err := s.runtime.PullImage(ctx, pullRef); err != nil {
24432439 return log.WrapErr(fmt.Errorf("%w: %w", domain.ErrImagePullFailed, err), "failed to pull image")
24442440 }
@@ -2537,6 +2533,21 @@ func (s *Service) setupVolumes(ctx context.Context, domainName, imageRef string)
25372533
25382534 for _, path := range volumePaths {
25392535 name := generateVolumeName(cfg.VolumePrefix, domainName, path)
2536+ legacyName := legacyVolumeName(cfg.VolumePrefix, domainName, path)
2537+ legacyExists, err := s.runtime.VolumeExists(ctx, legacyName)
2538+ if err != nil {
2539+ log.WrapErrWithFields(err, "failed to check legacy volume", map[string]any{"volume": legacyName})
2540+ continue
2541+ }
2542+ if legacyExists {
2543+ owned, ownershipErr := s.legacyVolumeOwnedByDomain(ctx, legacyName, domainName)
2544+ if ownershipErr != nil {
2545+ log.Warn().Err(ownershipErr).Str("volume", legacyName).Msg("could not verify legacy volume ownership; using stable volume name")
2546+ } else if owned {
2547+ volumes[path] = legacyName
2548+ continue
2549+ }
2550+ }
25402551
25412552 exists, err := s.runtime.VolumeExists(ctx, name)
25422553 if err != nil {
@@ -2593,7 +2604,7 @@ func (s *Service) generateNetworkName(identifier string) string {
25932604 cfg := s.config
25942605 s.mu.RUnlock()
25952606
2596- return fmt.Sprintf("%s-%s", cfg.NetworkPrefix, strings.ReplaceAll(identifier, ".", "-"))
2607+ return fmt.Sprintf("%s-%s", cfg.NetworkPrefix, domain.StableResourceName(identifier))
25972608 }
25982609
25992610 func (s *Service) createNetworkIfNeeded(ctx context.Context, networkName string) error {
@@ -2613,7 +2624,11 @@ func (s *Service) createNetworkIfNeeded(ctx context.Context, networkName string)
26132624 s.mu.RLock()
26142625 internal := s.config.NetworkInternal
26152626 s.mu.RUnlock()
2616- if err := s.runtime.CreateNetwork(ctx, networkName, domain.NetworkConfig{Driver: "bridge", Internal: internal}); err != nil {
2627+ if err := s.runtime.CreateNetwork(ctx, networkName, domain.NetworkConfig{
2628+ Driver: "bridge",
2629+ Internal: internal,
2630+ Labels: map[string]string{domain.LabelManaged: "true"},
2631+ }); err != nil {
26172632 return log.WrapErr(err, "failed to create network")
26182633 }
26192634 log.Info().Msg("created network for app isolation")
@@ -2624,7 +2639,7 @@ func (s *Service) createNetworkIfNeeded(ctx context.Context, networkName string)
26242639
26252640 func (s *Service) cleanupOrphanedContainers(ctx context.Context, domainName string, skipContainerID string) error {
26262641 log := zerowrap.FromCtx(ctx)
2627- expectedName := fmt.Sprintf("gordon-%s", domainName)
2642+ expectedName := managedContainerName(domainName)
26282643 expectedNewName := expectedName + "-new"
26292644 expectedNextName := expectedName + "-next"
26302645
@@ -2681,7 +2696,7 @@ func (s *Service) cleanupNetworkIfEmpty(ctx context.Context, networkName string)
26812696 }
26822697
26832698 for _, n := range networks {
2684- if n.Name == networkName && len(n.Containers) == 0 {
2699+ if n.Name == networkName && len(n.Containers) == 0 && n.Labels[domain.LabelManaged] == "true" {
26852700 if err := s.runtime.RemoveNetwork(ctx, networkName); err != nil {
26862701 return err
26872702 }
@@ -3116,6 +3131,9 @@ func (s *Service) resolveExistingAttachment(ctx context.Context, ownerDomain, se
31163131 containerName := fmt.Sprintf("gordon-%s-%s", sanitizeName(ownerDomain), serviceName)
31173132 existingContainer := s.findContainerByName(ctx, containerName)
31183133 if existingContainer != nil {
3134+ if err := validateAttachmentOwnership(existingContainer, ownerDomain, containerName); err != nil {
3135+ return nil, err
3136+ }
31193137 return existingContainer, nil
31203138 }
31213139
@@ -3125,6 +3143,9 @@ func (s *Service) resolveExistingAttachment(ctx context.Context, ownerDomain, se
31253143 return nil, nil
31263144 }
31273145
3146+ if err := validateAttachmentOwnership(existingContainer, ownerDomain, containerNameLegacy); err != nil {
3147+ return nil, err
3148+ }
31283149 log.Info().Str("container_name", containerNameLegacy).Msg("found attachment with legacy naming, will be replaced")
31293150 if err := s.runtime.StopContainer(ctx, existingContainer.ID); err != nil {
31303151 return nil, log.WrapErr(err, "failed to stop legacy attachment container")
@@ -3136,8 +3157,21 @@ func (s *Service) resolveExistingAttachment(ctx context.Context, ownerDomain, se
31363157 return nil, nil
31373158 }
31383159
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+
31393169 // Utility functions
31403170
3171+func managedContainerName(domainName string) string {
3172+ return "gordon-" + domainName
3173+}
3174+
31413175 func normalizeImageRef(image string) string {
31423176 if isDigestRef(image) {
31433177 return image
@@ -3179,12 +3213,38 @@ func normalizeImageRef(image string) string {
31793213 }
31803214
31813215 func generateVolumeName(prefix, domainName, volumePath string) string {
3216+ return fmt.Sprintf("%s-%s-%s",
3217+ prefix,
3218+ domain.StableResourceName(domainName),
3219+ domain.StableResourceName(strings.Trim(volumePath, "/")))
3220+}
3221+
3222+func legacyVolumeName(prefix, domainName, volumePath string) string {
31823223 return fmt.Sprintf("%s-%s-%s",
31833224 prefix,
31843225 strings.ReplaceAll(domainName, ".", "-"),
31853226 strings.ReplaceAll(strings.Trim(volumePath, "/"), "/", "-"))
31863227 }
31873228
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+
31883248 func mergeEnvironmentVariables(dockerfileEnv, userEnv []string) []string {
31893249 envMap := make(map[string]string)
31903250
@@ -211,7 +211,7 @@ func (s *Service) acquireDomainDeployLock(ctx context.Context, domain string) (f
211 211
212 // buildContainerConfig constructs the container configuration for deployment.212 // buildContainerConfig constructs the container configuration for deployment.
213 func (s *Service) deploymentContainerName(containerDomain string, existing *domain.Container) string {213 func (s *Service) deploymentContainerName(containerDomain string, existing *domain.Container) string {
214- canonicalName := fmt.Sprintf("gordon-%s", containerDomain)214+ canonicalName := managedContainerName(containerDomain)
215 if existing == nil {215 if existing == nil {
216 return canonicalName216 return canonicalName
217 }217 }
@@ -517,7 +517,7 @@ func (s *Service) resolveExistingContainer(ctx context.Context, domainName strin
517 517
518 // Slow path: query runtime for running containers518 // Slow path: query runtime for running containers
519 log := zerowrap.FromCtx(ctx)519 log := zerowrap.FromCtx(ctx)
520- canonicalName := fmt.Sprintf("gordon-%s", domainName)520+ canonicalName := managedContainerName(domainName)
521 candidateNames := map[string]bool{521 candidateNames := map[string]bool{
522 canonicalName: true,522 canonicalName: true,
523 canonicalName + "-new": true,523 canonicalName + "-new": true,
@@ -1333,7 +1333,7 @@ func isManagedRouteContainerForDomain(c *domain.Container, domainName string) bo
1333 if c.Labels[domain.LabelAttachment] == "true" {1333 if c.Labels[domain.LabelAttachment] == "true" {
1334 return false1334 return false
1335 }1335 }
1336- return c.Labels[domain.LabelRoute] == domainName || c.Labels[domain.LabelDomain] == domainName || c.Name == fmt.Sprintf("gordon-%s", domainName)1336+ return c.Labels[domain.LabelRoute] == domainName || c.Labels[domain.LabelDomain] == domainName || c.Name == managedContainerName(domainName)
1337 }1337 }
1338 1338
1339 func isPreservedAttachmentForDomain(c *domain.Container, domainName string) bool {1339 func isPreservedAttachmentForDomain(c *domain.Container, domainName string) bool {
@@ -1997,7 +1997,7 @@ func (s *Service) cleanupOldContainer(ctx context.Context, old *domain.Container
1997 }1997 }
1998 1998
1999 // Rename new container to canonical name1999 // Rename new container to canonical name
2000- canonicalName := fmt.Sprintf("gordon-%s", domainName)2000+ canonicalName := managedContainerName(domainName)
2001 if err := s.runtime.RenameContainer(ctx, newContainerID, canonicalName); err != nil {2001 if err := s.runtime.RenameContainer(ctx, newContainerID, canonicalName); err != nil {
2002 log.Warn().Err(err).Str("canonical_name", canonicalName).Msg("failed to rename container to canonical name")2002 log.Warn().Err(err).Str("canonical_name", canonicalName).Msg("failed to rename container to canonical name")
2003 }2003 }
@@ -2431,14 +2431,10 @@ func (s *Service) pullImage(ctx context.Context, pullRef string, isInternal bool
2431 }, isInternal); err != nil {2431 }, isInternal); err != nil {
2432 return log.WrapErr(fmt.Errorf("%w: %w", domain.ErrImagePullFailed, err), "failed to pull image")2432 return log.WrapErr(fmt.Errorf("%w: %w", domain.ErrImagePullFailed, err), "failed to pull image")
2433 }2433 }
2434- case cfg.RegistryAuthEnabled:
2435- if cfg.ServiceTokenUsername == "" || cfg.ServiceToken == "" {
2436- return log.WrapErr(fmt.Errorf("registry service token not configured"), "failed to pull image for registry auth")
2437- }
2438- if err := s.runtime.PullImageWithAuth(ctx, pullRef, cfg.ServiceTokenUsername, cfg.ServiceToken); err != nil {
2439- return log.WrapErr(fmt.Errorf("%w: %w", domain.ErrImagePullFailed, err), "failed to pull image with auth")
2440- }
2441 default:2434 default:
2435+ // Never send Gordon's service credentials to an external registry.
2436+ // External pulls are anonymous until per-registry credentials have an
2437+ // explicit trust-boundary-aware configuration path.
2442 if err := s.runtime.PullImage(ctx, pullRef); err != nil {2438 if err := s.runtime.PullImage(ctx, pullRef); err != nil {
2443 return log.WrapErr(fmt.Errorf("%w: %w", domain.ErrImagePullFailed, err), "failed to pull image")2439 return log.WrapErr(fmt.Errorf("%w: %w", domain.ErrImagePullFailed, err), "failed to pull image")
2444 }2440 }
@@ -2537,6 +2533,21 @@ func (s *Service) setupVolumes(ctx context.Context, domainName, imageRef string)
2537 2533
2538 for _, path := range volumePaths {2534 for _, path := range volumePaths {
2539 name := generateVolumeName(cfg.VolumePrefix, domainName, path)2535 name := generateVolumeName(cfg.VolumePrefix, domainName, path)
2536+ legacyName := legacyVolumeName(cfg.VolumePrefix, domainName, path)
2537+ legacyExists, err := s.runtime.VolumeExists(ctx, legacyName)
2538+ if err != nil {
2539+ log.WrapErrWithFields(err, "failed to check legacy volume", map[string]any{"volume": legacyName})
2540+ continue
2541+ }
2542+ if legacyExists {
2543+ owned, ownershipErr := s.legacyVolumeOwnedByDomain(ctx, legacyName, domainName)
2544+ if ownershipErr != nil {
2545+ log.Warn().Err(ownershipErr).Str("volume", legacyName).Msg("could not verify legacy volume ownership; using stable volume name")
2546+ } else if owned {
2547+ volumes[path] = legacyName
2548+ continue
2549+ }
2550+ }
2540 2551
2541 exists, err := s.runtime.VolumeExists(ctx, name)2552 exists, err := s.runtime.VolumeExists(ctx, name)
2542 if err != nil {2553 if err != nil {
@@ -2593,7 +2604,7 @@ func (s *Service) generateNetworkName(identifier string) string {
2593 cfg := s.config2604 cfg := s.config
2594 s.mu.RUnlock()2605 s.mu.RUnlock()
2595 2606
2596- return fmt.Sprintf("%s-%s", cfg.NetworkPrefix, strings.ReplaceAll(identifier, ".", "-"))2607+ return fmt.Sprintf("%s-%s", cfg.NetworkPrefix, domain.StableResourceName(identifier))
2597 }2608 }
2598 2609
2599 func (s *Service) createNetworkIfNeeded(ctx context.Context, networkName string) error {2610 func (s *Service) createNetworkIfNeeded(ctx context.Context, networkName string) error {
@@ -2613,7 +2624,11 @@ func (s *Service) createNetworkIfNeeded(ctx context.Context, networkName string)
2613 s.mu.RLock()2624 s.mu.RLock()
2614 internal := s.config.NetworkInternal2625 internal := s.config.NetworkInternal
2615 s.mu.RUnlock()2626 s.mu.RUnlock()
2616- if err := s.runtime.CreateNetwork(ctx, networkName, domain.NetworkConfig{Driver: "bridge", Internal: internal}); err != nil {2627+ if err := s.runtime.CreateNetwork(ctx, networkName, domain.NetworkConfig{
2628+ Driver: "bridge",
2629+ Internal: internal,
2630+ Labels: map[string]string{domain.LabelManaged: "true"},
2631+ }); err != nil {
2617 return log.WrapErr(err, "failed to create network")2632 return log.WrapErr(err, "failed to create network")
2618 }2633 }
2619 log.Info().Msg("created network for app isolation")2634 log.Info().Msg("created network for app isolation")
@@ -2624,7 +2639,7 @@ func (s *Service) createNetworkIfNeeded(ctx context.Context, networkName string)
2624 2639
2625 func (s *Service) cleanupOrphanedContainers(ctx context.Context, domainName string, skipContainerID string) error {2640 func (s *Service) cleanupOrphanedContainers(ctx context.Context, domainName string, skipContainerID string) error {
2626 log := zerowrap.FromCtx(ctx)2641 log := zerowrap.FromCtx(ctx)
2627- expectedName := fmt.Sprintf("gordon-%s", domainName)2642+ expectedName := managedContainerName(domainName)
2628 expectedNewName := expectedName + "-new"2643 expectedNewName := expectedName + "-new"
2629 expectedNextName := expectedName + "-next"2644 expectedNextName := expectedName + "-next"
2630 2645
@@ -2681,7 +2696,7 @@ func (s *Service) cleanupNetworkIfEmpty(ctx context.Context, networkName string)
2681 }2696 }
2682 2697
2683 for _, n := range networks {2698 for _, n := range networks {
2684- if n.Name == networkName && len(n.Containers) == 0 {2699+ if n.Name == networkName && len(n.Containers) == 0 && n.Labels[domain.LabelManaged] == "true" {
2685 if err := s.runtime.RemoveNetwork(ctx, networkName); err != nil {2700 if err := s.runtime.RemoveNetwork(ctx, networkName); err != nil {
2686 return err2701 return err
2687 }2702 }
@@ -3116,6 +3131,9 @@ func (s *Service) resolveExistingAttachment(ctx context.Context, ownerDomain, se
3116 containerName := fmt.Sprintf("gordon-%s-%s", sanitizeName(ownerDomain), serviceName)3131 containerName := fmt.Sprintf("gordon-%s-%s", sanitizeName(ownerDomain), serviceName)
3117 existingContainer := s.findContainerByName(ctx, containerName)3132 existingContainer := s.findContainerByName(ctx, containerName)
3118 if existingContainer != nil {3133 if existingContainer != nil {
3134+ if err := validateAttachmentOwnership(existingContainer, ownerDomain, containerName); err != nil {
3135+ return nil, err
3136+ }
3119 return existingContainer, nil3137 return existingContainer, nil
3120 }3138 }
3121 3139
@@ -3125,6 +3143,9 @@ func (s *Service) resolveExistingAttachment(ctx context.Context, ownerDomain, se
3125 return nil, nil3143 return nil, nil
3126 }3144 }
3127 3145
3146+ if err := validateAttachmentOwnership(existingContainer, ownerDomain, containerNameLegacy); err != nil {
3147+ return nil, err
3148+ }
3128 log.Info().Str("container_name", containerNameLegacy).Msg("found attachment with legacy naming, will be replaced")3149 log.Info().Str("container_name", containerNameLegacy).Msg("found attachment with legacy naming, will be replaced")
3129 if err := s.runtime.StopContainer(ctx, existingContainer.ID); err != nil {3150 if err := s.runtime.StopContainer(ctx, existingContainer.ID); err != nil {
3130 return nil, log.WrapErr(err, "failed to stop legacy attachment container")3151 return nil, log.WrapErr(err, "failed to stop legacy attachment container")
@@ -3136,8 +3157,21 @@ func (s *Service) resolveExistingAttachment(ctx context.Context, ownerDomain, se
3136 return nil, nil3157 return nil, nil
3137 }3158 }
3138 3159
3160+func validateAttachmentOwnership(container *domain.Container, ownerDomain, containerName string) error {
3161+ if container.Labels[domain.LabelManaged] != "true" ||
3162+ container.Labels[domain.LabelAttachment] != "true" ||
3163+ container.Labels[domain.LabelAttachedTo] != ownerDomain {
3164+ return fmt.Errorf("%w: attachment name %q is owned by another workload", domain.ErrAttachmentOwnershipMismatch, containerName)
3165+ }
3166+ return nil
3167+}
3168+
3139 // Utility functions3169 // Utility functions
3140 3170
3171+func managedContainerName(domainName string) string {
3172+ return "gordon-" + domainName
3173+}
3174+
3141 func normalizeImageRef(image string) string {3175 func normalizeImageRef(image string) string {
3142 if isDigestRef(image) {3176 if isDigestRef(image) {
3143 return image3177 return image
@@ -3179,12 +3213,38 @@ func normalizeImageRef(image string) string {
3179 }3213 }
3180 3214
3181 func generateVolumeName(prefix, domainName, volumePath string) string {3215 func generateVolumeName(prefix, domainName, volumePath string) string {
3216+ return fmt.Sprintf("%s-%s-%s",
3217+ prefix,
3218+ domain.StableResourceName(domainName),
3219+ domain.StableResourceName(strings.Trim(volumePath, "/")))
3220+}
3221+
3222+func legacyVolumeName(prefix, domainName, volumePath string) string {
3182 return fmt.Sprintf("%s-%s-%s",3223 return fmt.Sprintf("%s-%s-%s",
3183 prefix,3224 prefix,
3184 strings.ReplaceAll(domainName, ".", "-"),3225 strings.ReplaceAll(domainName, ".", "-"),
3185 strings.ReplaceAll(strings.Trim(volumePath, "/"), "/", "-"))3226 strings.ReplaceAll(strings.Trim(volumePath, "/"), "/", "-"))
3186 }3227 }
3187 3228
3229+func (s *Service) legacyVolumeOwnedByDomain(ctx context.Context, volumeName, domainName string) (bool, error) {
3230+ containers, err := s.runtime.ListContainers(ctx, true)
3231+ if err != nil {
3232+ return false, fmt.Errorf("list containers for legacy volume ownership: %w", err)
3233+ }
3234+ for _, container := range containers {
3235+ if container.Labels[domain.LabelManaged] != "true" ||
3236+ (container.Labels[domain.LabelRoute] != domainName && container.Labels[domain.LabelDomain] != domainName) {
3237+ continue
3238+ }
3239+ for _, mount := range container.VolumeMounts {
3240+ if mount.Name == volumeName || mount.Source == volumeName {
3241+ return true, nil
3242+ }
3243+ }
3244+ }
3245+ return false, nil
3246+}
3247+
3188 func mergeEnvironmentVariables(dockerfileEnv, userEnv []string) []string {3248 func mergeEnvironmentVariables(dockerfileEnv, userEnv []string) []string {
3189 envMap := make(map[string]string)3249 envMap := make(map[string]string)
3190 3250
M internal/usecase/container/service_test.go +82 -36
@@ -200,16 +200,15 @@ func TestService_GetNetworkForApp_UsesLiveConfigProvider(t *testing.T) {
200200 nil, Config{NetworkIsolation: true, NetworkPrefix: "gordon"}, provider,
201201 )
202202
203- // Should use group network: generateNetworkName produces "gordon-shared-group"
203+ // Group and per-domain networks use collision-resistant names.
204204 network := svc.getNetworkForApp("app1.example.com")
205- assert.Equal(t, "gordon-shared-group", network)
205+ assert.Equal(t, svc.generateNetworkName("shared-group"), network)
206206
207207 network = svc.getNetworkForApp("app3.example.com")
208- assert.Equal(t, "gordon-shared-group", network)
208+ assert.Equal(t, svc.generateNetworkName("shared-group"), network)
209209
210- // Domain not in any group gets its own network: "gordon-solo-example-com"
211210 network = svc.getNetworkForApp("solo.example.com")
212- assert.Equal(t, "gordon-solo-example-com", network)
211+ assert.Equal(t, svc.generateNetworkName("solo.example.com"), network)
213212 }
214213
215214 func TestService_ManagedContainersMetric_GlobalSeriesOnDeployReplaceAndRemove(t *testing.T) {
@@ -704,7 +703,7 @@ func TestService_Deploy_StrictHealthModeWithoutHealthcheckReturnsHint(t *testing
704703 assert.ErrorContains(t, deployErr.Err, "no healthcheck detected")
705704 }
706705
707-func TestService_PullImage_UsesServiceTokenForExternalPull(t *testing.T) {
706+func TestService_PullImage_DoesNotSendServiceTokenToExternalRegistry(t *testing.T) {
708707 runtime := mocks.NewMockContainerRuntime(t)
709708
710709 config := Config{
@@ -717,30 +716,13 @@ func TestService_PullImage_UsesServiceTokenForExternalPull(t *testing.T) {
717716 svc := NewService(runtime, nil, nil, nil, config, nil)
718717 ctx := testContext()
719718
720- runtime.EXPECT().PullImageWithAuth(mock.Anything, "registry.example.com/myapp:latest", "gordon-service", "service-token").Return(nil)
719+ runtime.EXPECT().PullImage(mock.Anything, "registry.example.com/myapp:latest").Return(nil)
721720
722721 err := svc.pullImage(ctx, "registry.example.com/myapp:latest", false)
723722
724723 assert.NoError(t, err)
725724 }
726725
727-func TestService_PullImage_RequiresServiceTokenForExternalPull(t *testing.T) {
728- runtime := mocks.NewMockContainerRuntime(t)
729-
730- config := Config{
731- AllowedRegistries: []string{"docker.io"},
732- RegistryAuthEnabled: true,
733- }
734-
735- svc := NewService(runtime, nil, nil, nil, config, nil)
736- ctx := testContext()
737-
738- err := svc.pullImage(ctx, "registry.example.com/myapp:latest", false)
739-
740- assert.Error(t, err)
741- assert.Contains(t, err.Error(), "registry service token not configured")
742-}
743-
744726 func TestService_PullImage_InternalRetriesOnConnectionRefused(t *testing.T) {
745727 runtime := mocks.NewMockContainerRuntime(t)
746728
@@ -1041,13 +1023,17 @@ func TestService_Deploy_WithNetworkIsolation(t *testing.T) {
10411023 envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
10421024 runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:latest").Return([]string{}, nil)
10431025
1044- // Network isolation - should check and create network
1045- runtime.EXPECT().NetworkExists(mock.Anything, "gordon-test-example-com").Return(false, nil)
1046- runtime.EXPECT().CreateNetwork(mock.Anything, "gordon-test-example-com", domain.NetworkConfig{Driver: "bridge"}).Return(nil)
1026+ // Network isolation - should check and create an owned network.
1027+ networkName := svc.generateNetworkName("test.example.com")
1028+ runtime.EXPECT().NetworkExists(mock.Anything, networkName).Return(false, nil)
1029+ runtime.EXPECT().CreateNetwork(mock.Anything, networkName, domain.NetworkConfig{
1030+ Driver: "bridge",
1031+ Labels: map[string]string{domain.LabelManaged: "true"},
1032+ }).Return(nil)
10471033
10481034 container := &domain.Container{ID: "container-123", Status: "created"}
10491035 runtime.EXPECT().CreateContainer(mock.Anything, mock.MatchedBy(func(cfg *domain.ContainerConfig) bool {
1050- return cfg.NetworkMode == "gordon-test-example-com"
1036+ return cfg.NetworkMode == networkName
10511037 })).Return(container, nil)
10521038 runtime.EXPECT().StartContainer(mock.Anything, "container-123").Return(nil)
10531039
@@ -1100,10 +1086,14 @@ func TestService_Deploy_WithVolumeAutoCreate(t *testing.T) {
11001086 runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:latest").Return([]string{}, nil)
11011087
11021088 // Volume operations
1089+ dataVolume := generateVolumeName("gordon", "test.example.com", "/data")
1090+ configVolume := generateVolumeName("gordon", "test.example.com", "/config")
11031091 runtime.EXPECT().InspectImageVolumes(mock.Anything, "myapp:latest").Return([]string{"/data", "/config"}, nil)
1104- runtime.EXPECT().VolumeExists(mock.Anything, "gordon-test-example-com-data").Return(false, nil)
1105- runtime.EXPECT().CreateVolume(mock.Anything, "gordon-test-example-com-data").Return(nil)
1106- runtime.EXPECT().VolumeExists(mock.Anything, "gordon-test-example-com-config").Return(true, nil)
1092+ runtime.EXPECT().VolumeExists(mock.Anything, legacyVolumeName("gordon", "test.example.com", "/data")).Return(false, nil)
1093+ runtime.EXPECT().VolumeExists(mock.Anything, dataVolume).Return(false, nil)
1094+ runtime.EXPECT().CreateVolume(mock.Anything, dataVolume).Return(nil)
1095+ runtime.EXPECT().VolumeExists(mock.Anything, legacyVolumeName("gordon", "test.example.com", "/config")).Return(false, nil)
1096+ runtime.EXPECT().VolumeExists(mock.Anything, configVolume).Return(true, nil)
11071097
11081098 container := &domain.Container{ID: "container-123", Status: "created"}
11091099 runtime.EXPECT().CreateContainer(mock.Anything, mock.MatchedBy(func(cfg *domain.ContainerConfig) bool {
@@ -1203,10 +1193,11 @@ func TestService_Remove_WithNetworkCleanup(t *testing.T) {
12031193 }
12041194
12051195 runtime.EXPECT().RemoveContainer(mock.Anything, "container-123", true).Return(nil)
1196+ networkName := svc.generateNetworkName("test.example.com")
12061197 runtime.EXPECT().ListNetworks(mock.Anything).Return([]*domain.NetworkInfo{
1207- {Name: "gordon-test-example-com", Containers: []string{}},
1198+ {Name: networkName, Containers: []string{}, Labels: map[string]string{domain.LabelManaged: "true"}},
12081199 }, nil)
1209- runtime.EXPECT().RemoveNetwork(mock.Anything, "gordon-test-example-com").Return(nil)
1200+ runtime.EXPECT().RemoveNetwork(mock.Anything, networkName).Return(nil)
12101201
12111202 err := svc.Remove(ctx, "container-123", true)
12121203
@@ -1777,14 +1768,14 @@ func TestGenerateVolumeName(t *testing.T) {
17771768 prefix: "gordon",
17781769 domain: "app.example.com",
17791770 volumePath: "/data",
1780- expected: "gordon-app-example-com-data",
1771+ expected: "gordon-app__example__com-28059829b105-data-3a6eb0790f39",
17811772 },
17821773 {
17831774 name: "nested path",
17841775 prefix: "gordon",
17851776 domain: "app.example.com",
17861777 volumePath: "/var/lib/data",
1787- expected: "gordon-app-example-com-var-lib-data",
1778+ expected: "gordon-app__example__com-28059829b105-var--lib--data-323f0a44c820",
17881779 },
17891780 }
17901781
@@ -1796,6 +1787,57 @@ func TestGenerateVolumeName(t *testing.T) {
17961787 }
17971788 }
17981789
1790+func TestService_SetupVolumes_ReusesLegacyVolume(t *testing.T) {
1791+ runtime := mocks.NewMockContainerRuntime(t)
1792+ svc := NewService(runtime, nil, nil, nil, Config{VolumeAutoCreate: true, VolumePrefix: "gordon"}, nil)
1793+ legacyName := legacyVolumeName("gordon", "app.example.com", "/data")
1794+
1795+ runtime.EXPECT().InspectImageVolumes(mock.Anything, "myapp:latest").Return([]string{"/data"}, nil)
1796+ runtime.EXPECT().VolumeExists(mock.Anything, legacyName).Return(true, nil)
1797+ runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{{
1798+ Labels: map[string]string{domain.LabelManaged: "true", domain.LabelRoute: "app.example.com"},
1799+ VolumeMounts: []domain.ContainerVolumeMount{{Name: legacyName}},
1800+ }}, nil)
1801+
1802+ volumes, err := svc.setupVolumes(testContext(), "app.example.com", "myapp:latest")
1803+
1804+ require.NoError(t, err)
1805+ assert.Equal(t, map[string]string{"/data": legacyName}, volumes)
1806+}
1807+
1808+func TestService_SetupVolumes_DoesNotReuseCollidingLegacyVolume(t *testing.T) {
1809+ runtime := mocks.NewMockContainerRuntime(t)
1810+ svc := NewService(runtime, nil, nil, nil, Config{VolumeAutoCreate: true, VolumePrefix: "gordon"}, nil)
1811+ legacyName := legacyVolumeName("gordon", "app.example.com", "/data")
1812+ stableName := generateVolumeName("gordon", "app.example.com", "/data")
1813+ require.Equal(t, legacyName, legacyVolumeName("gordon", "app-example.com", "/data"))
1814+
1815+ runtime.EXPECT().InspectImageVolumes(mock.Anything, "myapp:latest").Return([]string{"/data"}, nil)
1816+ runtime.EXPECT().VolumeExists(mock.Anything, legacyName).Return(true, nil)
1817+ runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{{
1818+ Labels: map[string]string{domain.LabelManaged: "true", domain.LabelRoute: "app-example.com"},
1819+ VolumeMounts: []domain.ContainerVolumeMount{{Name: legacyName}},
1820+ }}, nil)
1821+ runtime.EXPECT().VolumeExists(mock.Anything, stableName).Return(true, nil)
1822+
1823+ volumes, err := svc.setupVolumes(testContext(), "app.example.com", "myapp:latest")
1824+
1825+ require.NoError(t, err)
1826+ assert.Equal(t, map[string]string{"/data": stableName}, volumes)
1827+}
1828+
1829+func TestValidateAttachmentOwnership(t *testing.T) {
1830+ owned := &domain.Container{Labels: map[string]string{
1831+ domain.LabelManaged: "true", domain.LabelAttachment: "true", domain.LabelAttachedTo: "app.example.com",
1832+ }}
1833+ require.NoError(t, validateAttachmentOwnership(owned, "app.example.com", "gordon-app-postgres"))
1834+
1835+ foreign := &domain.Container{Labels: map[string]string{
1836+ domain.LabelManaged: "true", domain.LabelAttachment: "true", domain.LabelAttachedTo: "other.example.com",
1837+ }}
1838+ assert.ErrorIs(t, validateAttachmentOwnership(foreign, "app.example.com", "gordon-app-postgres"), domain.ErrAttachmentOwnershipMismatch)
1839+}
1840+
17991841 func TestMergeEnvironmentVariables(t *testing.T) {
18001842 dockerEnv := []string{"FOO=docker", "BAR=docker"}
18011843 userEnv := []string{"FOO=user", "BAZ=user"}
@@ -4480,7 +4522,11 @@ func TestService_CreateNetworkIfNeeded_UsesInternalOptionWhenConfigured(t *testi
44804522 svc := NewService(runtime, nil, nil, nil, Config{NetworkInternal: true}, nil)
44814523
44824524 runtime.EXPECT().NetworkExists(mock.Anything, "gordon-app").Return(false, nil)
4483- runtime.EXPECT().CreateNetwork(mock.Anything, "gordon-app", domain.NetworkConfig{Driver: "bridge", Internal: true}).Return(nil)
4525+ runtime.EXPECT().CreateNetwork(mock.Anything, "gordon-app", domain.NetworkConfig{
4526+ Driver: "bridge",
4527+ Internal: true,
4528+ Labels: map[string]string{domain.LabelManaged: "true"},
4529+ }).Return(nil)
44844530
44854531 require.NoError(t, svc.createNetworkIfNeeded(testContext(), "gordon-app"))
44864532 }
@@ -200,16 +200,15 @@ func TestService_GetNetworkForApp_UsesLiveConfigProvider(t *testing.T) {
200 nil, Config{NetworkIsolation: true, NetworkPrefix: "gordon"}, provider,200 nil, Config{NetworkIsolation: true, NetworkPrefix: "gordon"}, provider,
201 )201 )
202 202
203- // Should use group network: generateNetworkName produces "gordon-shared-group"203+ // Group and per-domain networks use collision-resistant names.
204 network := svc.getNetworkForApp("app1.example.com")204 network := svc.getNetworkForApp("app1.example.com")
205- assert.Equal(t, "gordon-shared-group", network)205+ assert.Equal(t, svc.generateNetworkName("shared-group"), network)
206 206
207 network = svc.getNetworkForApp("app3.example.com")207 network = svc.getNetworkForApp("app3.example.com")
208- assert.Equal(t, "gordon-shared-group", network)208+ assert.Equal(t, svc.generateNetworkName("shared-group"), network)
209 209
210- // Domain not in any group gets its own network: "gordon-solo-example-com"
211 network = svc.getNetworkForApp("solo.example.com")210 network = svc.getNetworkForApp("solo.example.com")
212- assert.Equal(t, "gordon-solo-example-com", network)211+ assert.Equal(t, svc.generateNetworkName("solo.example.com"), network)
213 }212 }
214 213
215 func TestService_ManagedContainersMetric_GlobalSeriesOnDeployReplaceAndRemove(t *testing.T) {214 func TestService_ManagedContainersMetric_GlobalSeriesOnDeployReplaceAndRemove(t *testing.T) {
@@ -704,7 +703,7 @@ func TestService_Deploy_StrictHealthModeWithoutHealthcheckReturnsHint(t *testing
704 assert.ErrorContains(t, deployErr.Err, "no healthcheck detected")703 assert.ErrorContains(t, deployErr.Err, "no healthcheck detected")
705 }704 }
706 705
707-func TestService_PullImage_UsesServiceTokenForExternalPull(t *testing.T) {706+func TestService_PullImage_DoesNotSendServiceTokenToExternalRegistry(t *testing.T) {
708 runtime := mocks.NewMockContainerRuntime(t)707 runtime := mocks.NewMockContainerRuntime(t)
709 708
710 config := Config{709 config := Config{
@@ -717,30 +716,13 @@ func TestService_PullImage_UsesServiceTokenForExternalPull(t *testing.T) {
717 svc := NewService(runtime, nil, nil, nil, config, nil)716 svc := NewService(runtime, nil, nil, nil, config, nil)
718 ctx := testContext()717 ctx := testContext()
719 718
720- runtime.EXPECT().PullImageWithAuth(mock.Anything, "registry.example.com/myapp:latest", "gordon-service", "service-token").Return(nil)719+ runtime.EXPECT().PullImage(mock.Anything, "registry.example.com/myapp:latest").Return(nil)
721 720
722 err := svc.pullImage(ctx, "registry.example.com/myapp:latest", false)721 err := svc.pullImage(ctx, "registry.example.com/myapp:latest", false)
723 722
724 assert.NoError(t, err)723 assert.NoError(t, err)
725 }724 }
726 725
727-func TestService_PullImage_RequiresServiceTokenForExternalPull(t *testing.T) {
728- runtime := mocks.NewMockContainerRuntime(t)
729-
730- config := Config{
731- AllowedRegistries: []string{"docker.io"},
732- RegistryAuthEnabled: true,
733- }
734-
735- svc := NewService(runtime, nil, nil, nil, config, nil)
736- ctx := testContext()
737-
738- err := svc.pullImage(ctx, "registry.example.com/myapp:latest", false)
739-
740- assert.Error(t, err)
741- assert.Contains(t, err.Error(), "registry service token not configured")
742-}
743-
744 func TestService_PullImage_InternalRetriesOnConnectionRefused(t *testing.T) {726 func TestService_PullImage_InternalRetriesOnConnectionRefused(t *testing.T) {
745 runtime := mocks.NewMockContainerRuntime(t)727 runtime := mocks.NewMockContainerRuntime(t)
746 728
@@ -1041,13 +1023,17 @@ func TestService_Deploy_WithNetworkIsolation(t *testing.T) {
1041 envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)1023 envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
1042 runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:latest").Return([]string{}, nil)1024 runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:latest").Return([]string{}, nil)
1043 1025
1044- // Network isolation - should check and create network1026+ // Network isolation - should check and create an owned network.
1045- runtime.EXPECT().NetworkExists(mock.Anything, "gordon-test-example-com").Return(false, nil)1027+ networkName := svc.generateNetworkName("test.example.com")
1046- runtime.EXPECT().CreateNetwork(mock.Anything, "gordon-test-example-com", domain.NetworkConfig{Driver: "bridge"}).Return(nil)1028+ runtime.EXPECT().NetworkExists(mock.Anything, networkName).Return(false, nil)
1029+ runtime.EXPECT().CreateNetwork(mock.Anything, networkName, domain.NetworkConfig{
1030+ Driver: "bridge",
1031+ Labels: map[string]string{domain.LabelManaged: "true"},
1032+ }).Return(nil)
1047 1033
1048 container := &domain.Container{ID: "container-123", Status: "created"}1034 container := &domain.Container{ID: "container-123", Status: "created"}
1049 runtime.EXPECT().CreateContainer(mock.Anything, mock.MatchedBy(func(cfg *domain.ContainerConfig) bool {1035 runtime.EXPECT().CreateContainer(mock.Anything, mock.MatchedBy(func(cfg *domain.ContainerConfig) bool {
1050- return cfg.NetworkMode == "gordon-test-example-com"1036+ return cfg.NetworkMode == networkName
1051 })).Return(container, nil)1037 })).Return(container, nil)
1052 runtime.EXPECT().StartContainer(mock.Anything, "container-123").Return(nil)1038 runtime.EXPECT().StartContainer(mock.Anything, "container-123").Return(nil)
1053 1039
@@ -1100,10 +1086,14 @@ func TestService_Deploy_WithVolumeAutoCreate(t *testing.T) {
1100 runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:latest").Return([]string{}, nil)1086 runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:latest").Return([]string{}, nil)
1101 1087
1102 // Volume operations1088 // Volume operations
1089+ dataVolume := generateVolumeName("gordon", "test.example.com", "/data")
1090+ configVolume := generateVolumeName("gordon", "test.example.com", "/config")
1103 runtime.EXPECT().InspectImageVolumes(mock.Anything, "myapp:latest").Return([]string{"/data", "/config"}, nil)1091 runtime.EXPECT().InspectImageVolumes(mock.Anything, "myapp:latest").Return([]string{"/data", "/config"}, nil)
1104- runtime.EXPECT().VolumeExists(mock.Anything, "gordon-test-example-com-data").Return(false, nil)1092+ runtime.EXPECT().VolumeExists(mock.Anything, legacyVolumeName("gordon", "test.example.com", "/data")).Return(false, nil)
1105- runtime.EXPECT().CreateVolume(mock.Anything, "gordon-test-example-com-data").Return(nil)1093+ runtime.EXPECT().VolumeExists(mock.Anything, dataVolume).Return(false, nil)
1106- runtime.EXPECT().VolumeExists(mock.Anything, "gordon-test-example-com-config").Return(true, nil)1094+ runtime.EXPECT().CreateVolume(mock.Anything, dataVolume).Return(nil)
1095+ runtime.EXPECT().VolumeExists(mock.Anything, legacyVolumeName("gordon", "test.example.com", "/config")).Return(false, nil)
1096+ runtime.EXPECT().VolumeExists(mock.Anything, configVolume).Return(true, nil)
1107 1097
1108 container := &domain.Container{ID: "container-123", Status: "created"}1098 container := &domain.Container{ID: "container-123", Status: "created"}
1109 runtime.EXPECT().CreateContainer(mock.Anything, mock.MatchedBy(func(cfg *domain.ContainerConfig) bool {1099 runtime.EXPECT().CreateContainer(mock.Anything, mock.MatchedBy(func(cfg *domain.ContainerConfig) bool {
@@ -1203,10 +1193,11 @@ func TestService_Remove_WithNetworkCleanup(t *testing.T) {
1203 }1193 }
1204 1194
1205 runtime.EXPECT().RemoveContainer(mock.Anything, "container-123", true).Return(nil)1195 runtime.EXPECT().RemoveContainer(mock.Anything, "container-123", true).Return(nil)
1196+ networkName := svc.generateNetworkName("test.example.com")
1206 runtime.EXPECT().ListNetworks(mock.Anything).Return([]*domain.NetworkInfo{1197 runtime.EXPECT().ListNetworks(mock.Anything).Return([]*domain.NetworkInfo{
1207- {Name: "gordon-test-example-com", Containers: []string{}},1198+ {Name: networkName, Containers: []string{}, Labels: map[string]string{domain.LabelManaged: "true"}},
1208 }, nil)1199 }, nil)
1209- runtime.EXPECT().RemoveNetwork(mock.Anything, "gordon-test-example-com").Return(nil)1200+ runtime.EXPECT().RemoveNetwork(mock.Anything, networkName).Return(nil)
1210 1201
1211 err := svc.Remove(ctx, "container-123", true)1202 err := svc.Remove(ctx, "container-123", true)
1212 1203
@@ -1777,14 +1768,14 @@ func TestGenerateVolumeName(t *testing.T) {
1777 prefix: "gordon",1768 prefix: "gordon",
1778 domain: "app.example.com",1769 domain: "app.example.com",
1779 volumePath: "/data",1770 volumePath: "/data",
1780- expected: "gordon-app-example-com-data",1771+ expected: "gordon-app__example__com-28059829b105-data-3a6eb0790f39",
1781 },1772 },
1782 {1773 {
1783 name: "nested path",1774 name: "nested path",
1784 prefix: "gordon",1775 prefix: "gordon",
1785 domain: "app.example.com",1776 domain: "app.example.com",
1786 volumePath: "/var/lib/data",1777 volumePath: "/var/lib/data",
1787- expected: "gordon-app-example-com-var-lib-data",1778+ expected: "gordon-app__example__com-28059829b105-var--lib--data-323f0a44c820",
1788 },1779 },
1789 }1780 }
1790 1781
@@ -1796,6 +1787,57 @@ func TestGenerateVolumeName(t *testing.T) {
1796 }1787 }
1797 }1788 }
1798 1789
1790+func TestService_SetupVolumes_ReusesLegacyVolume(t *testing.T) {
1791+ runtime := mocks.NewMockContainerRuntime(t)
1792+ svc := NewService(runtime, nil, nil, nil, Config{VolumeAutoCreate: true, VolumePrefix: "gordon"}, nil)
1793+ legacyName := legacyVolumeName("gordon", "app.example.com", "/data")
1794+
1795+ runtime.EXPECT().InspectImageVolumes(mock.Anything, "myapp:latest").Return([]string{"/data"}, nil)
1796+ runtime.EXPECT().VolumeExists(mock.Anything, legacyName).Return(true, nil)
1797+ runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{{
1798+ Labels: map[string]string{domain.LabelManaged: "true", domain.LabelRoute: "app.example.com"},
1799+ VolumeMounts: []domain.ContainerVolumeMount{{Name: legacyName}},
1800+ }}, nil)
1801+
1802+ volumes, err := svc.setupVolumes(testContext(), "app.example.com", "myapp:latest")
1803+
1804+ require.NoError(t, err)
1805+ assert.Equal(t, map[string]string{"/data": legacyName}, volumes)
1806+}
1807+
1808+func TestService_SetupVolumes_DoesNotReuseCollidingLegacyVolume(t *testing.T) {
1809+ runtime := mocks.NewMockContainerRuntime(t)
1810+ svc := NewService(runtime, nil, nil, nil, Config{VolumeAutoCreate: true, VolumePrefix: "gordon"}, nil)
1811+ legacyName := legacyVolumeName("gordon", "app.example.com", "/data")
1812+ stableName := generateVolumeName("gordon", "app.example.com", "/data")
1813+ require.Equal(t, legacyName, legacyVolumeName("gordon", "app-example.com", "/data"))
1814+
1815+ runtime.EXPECT().InspectImageVolumes(mock.Anything, "myapp:latest").Return([]string{"/data"}, nil)
1816+ runtime.EXPECT().VolumeExists(mock.Anything, legacyName).Return(true, nil)
1817+ runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{{
1818+ Labels: map[string]string{domain.LabelManaged: "true", domain.LabelRoute: "app-example.com"},
1819+ VolumeMounts: []domain.ContainerVolumeMount{{Name: legacyName}},
1820+ }}, nil)
1821+ runtime.EXPECT().VolumeExists(mock.Anything, stableName).Return(true, nil)
1822+
1823+ volumes, err := svc.setupVolumes(testContext(), "app.example.com", "myapp:latest")
1824+
1825+ require.NoError(t, err)
1826+ assert.Equal(t, map[string]string{"/data": stableName}, volumes)
1827+}
1828+
1829+func TestValidateAttachmentOwnership(t *testing.T) {
1830+ owned := &domain.Container{Labels: map[string]string{
1831+ domain.LabelManaged: "true", domain.LabelAttachment: "true", domain.LabelAttachedTo: "app.example.com",
1832+ }}
1833+ require.NoError(t, validateAttachmentOwnership(owned, "app.example.com", "gordon-app-postgres"))
1834+
1835+ foreign := &domain.Container{Labels: map[string]string{
1836+ domain.LabelManaged: "true", domain.LabelAttachment: "true", domain.LabelAttachedTo: "other.example.com",
1837+ }}
1838+ assert.ErrorIs(t, validateAttachmentOwnership(foreign, "app.example.com", "gordon-app-postgres"), domain.ErrAttachmentOwnershipMismatch)
1839+}
1840+
1799 func TestMergeEnvironmentVariables(t *testing.T) {1841 func TestMergeEnvironmentVariables(t *testing.T) {
1800 dockerEnv := []string{"FOO=docker", "BAR=docker"}1842 dockerEnv := []string{"FOO=docker", "BAR=docker"}
1801 userEnv := []string{"FOO=user", "BAZ=user"}1843 userEnv := []string{"FOO=user", "BAZ=user"}
@@ -4480,7 +4522,11 @@ func TestService_CreateNetworkIfNeeded_UsesInternalOptionWhenConfigured(t *testi
4480 svc := NewService(runtime, nil, nil, nil, Config{NetworkInternal: true}, nil)4522 svc := NewService(runtime, nil, nil, nil, Config{NetworkInternal: true}, nil)
4481 4523
4482 runtime.EXPECT().NetworkExists(mock.Anything, "gordon-app").Return(false, nil)4524 runtime.EXPECT().NetworkExists(mock.Anything, "gordon-app").Return(false, nil)
4483- runtime.EXPECT().CreateNetwork(mock.Anything, "gordon-app", domain.NetworkConfig{Driver: "bridge", Internal: true}).Return(nil)4525+ runtime.EXPECT().CreateNetwork(mock.Anything, "gordon-app", domain.NetworkConfig{
4526+ Driver: "bridge",
4527+ Internal: true,
4528+ Labels: map[string]string{domain.LabelManaged: "true"},
4529+ }).Return(nil)
4484 4530
4485 require.NoError(t, svc.createNetworkIfNeeded(testContext(), "gordon-app"))4531 require.NoError(t, svc.createNetworkIfNeeded(testContext(), "gordon-app"))
4486 }4532 }
M internal/usecase/images/service.go +18 -0
@@ -7,12 +7,14 @@ import (
77 "fmt"
88 "sort"
99 "strings"
10+ "sync"
1011 "time"
1112
1213 "github.com/bnema/zerowrap"
1314
1415 "github.com/bnema/gordon/internal/boundaries/out"
1516 "github.com/bnema/gordon/internal/domain"
17+ "github.com/bnema/gordon/internal/usecase/registrystate"
1618 "github.com/bnema/gordon/pkg/runtime"
1719 "github.com/bnema/gordon/pkg/validation"
1820 )
@@ -23,6 +25,8 @@ type Service struct {
2325 manifestStorage out.ManifestStorage
2426 blobStorage out.BlobStorage
2527 log zerowrap.Logger
28+ mutationMu *sync.RWMutex
29+ registryState *registrystate.State
2630 }
2731
2832 type imageRuntime interface {
@@ -36,12 +40,19 @@ func NewService(
3640 manifestStorage out.ManifestStorage,
3741 blobStorage out.BlobStorage,
3842 log zerowrap.Logger,
43+ states ...*registrystate.State,
3944 ) *Service {
45+ registryState := registrystate.New()
46+ if len(states) > 0 && states[0] != nil {
47+ registryState = states[0]
48+ }
4049 return &Service{
4150 runtime: rt,
4251 manifestStorage: manifestStorage,
4352 blobStorage: blobStorage,
4453 log: log,
54+ mutationMu: &registryState.MutationMu,
55+ registryState: registryState,
4556 }
4657 }
4758
@@ -194,6 +205,9 @@ func (s *Service) PruneRuntime(ctx context.Context) (domain.ImagePruneReport, er
194205 // It keeps the "latest" tag when present and keeps keepLast most-recent
195206 // non-latest tags from tagInfos.
196207 func (s *Service) PruneRegistry(ctx context.Context, keepLast int) (domain.ImagePruneReport, error) {
208+ s.mutationMu.Lock()
209+ defer s.mutationMu.Unlock()
210+
197211 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
198212 zerowrap.FieldLayer: "usecase",
199213 zerowrap.FieldUseCase: "PruneRegistry",
@@ -234,6 +248,10 @@ func (s *Service) PruneRegistry(ctx context.Context, keepLast int) (domain.Image
234248 }
235249 }
236250
251+ for digest := range s.registryState.PendingDigests(time.Now().UTC()) {
252+ referencedDigests[digest] = struct{}{}
253+ }
254+
237255 blobs, err := s.blobStorage.ListBlobs()
238256 if err != nil {
239257 return domain.ImagePruneReport{}, log.WrapErr(err, "failed to list blobs")
@@ -7,12 +7,14 @@ import (
7 "fmt"7 "fmt"
8 "sort"8 "sort"
9 "strings"9 "strings"
10+ "sync"
10 "time"11 "time"
11 12
12 "github.com/bnema/zerowrap"13 "github.com/bnema/zerowrap"
13 14
14 "github.com/bnema/gordon/internal/boundaries/out"15 "github.com/bnema/gordon/internal/boundaries/out"
15 "github.com/bnema/gordon/internal/domain"16 "github.com/bnema/gordon/internal/domain"
17+ "github.com/bnema/gordon/internal/usecase/registrystate"
16 "github.com/bnema/gordon/pkg/runtime"18 "github.com/bnema/gordon/pkg/runtime"
17 "github.com/bnema/gordon/pkg/validation"19 "github.com/bnema/gordon/pkg/validation"
18 )20 )
@@ -23,6 +25,8 @@ type Service struct {
23 manifestStorage out.ManifestStorage25 manifestStorage out.ManifestStorage
24 blobStorage out.BlobStorage26 blobStorage out.BlobStorage
25 log zerowrap.Logger27 log zerowrap.Logger
28+ mutationMu *sync.RWMutex
29+ registryState *registrystate.State
26 }30 }
27 31
28 type imageRuntime interface {32 type imageRuntime interface {
@@ -36,12 +40,19 @@ func NewService(
36 manifestStorage out.ManifestStorage,40 manifestStorage out.ManifestStorage,
37 blobStorage out.BlobStorage,41 blobStorage out.BlobStorage,
38 log zerowrap.Logger,42 log zerowrap.Logger,
43+ states ...*registrystate.State,
39 ) *Service {44 ) *Service {
45+ registryState := registrystate.New()
46+ if len(states) > 0 && states[0] != nil {
47+ registryState = states[0]
48+ }
40 return &Service{49 return &Service{
41 runtime: rt,50 runtime: rt,
42 manifestStorage: manifestStorage,51 manifestStorage: manifestStorage,
43 blobStorage: blobStorage,52 blobStorage: blobStorage,
44 log: log,53 log: log,
54+ mutationMu: &registryState.MutationMu,
55+ registryState: registryState,
45 }56 }
46 }57 }
47 58
@@ -194,6 +205,9 @@ func (s *Service) PruneRuntime(ctx context.Context) (domain.ImagePruneReport, er
194 // It keeps the "latest" tag when present and keeps keepLast most-recent205 // It keeps the "latest" tag when present and keeps keepLast most-recent
195 // non-latest tags from tagInfos.206 // non-latest tags from tagInfos.
196 func (s *Service) PruneRegistry(ctx context.Context, keepLast int) (domain.ImagePruneReport, error) {207 func (s *Service) PruneRegistry(ctx context.Context, keepLast int) (domain.ImagePruneReport, error) {
208+ s.mutationMu.Lock()
209+ defer s.mutationMu.Unlock()
210+
197 ctx = zerowrap.CtxWithFields(ctx, map[string]any{211 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
198 zerowrap.FieldLayer: "usecase",212 zerowrap.FieldLayer: "usecase",
199 zerowrap.FieldUseCase: "PruneRegistry",213 zerowrap.FieldUseCase: "PruneRegistry",
@@ -234,6 +248,10 @@ func (s *Service) PruneRegistry(ctx context.Context, keepLast int) (domain.Image
234 }248 }
235 }249 }
236 250
251+ for digest := range s.registryState.PendingDigests(time.Now().UTC()) {
252+ referencedDigests[digest] = struct{}{}
253+ }
254+
237 blobs, err := s.blobStorage.ListBlobs()255 blobs, err := s.blobStorage.ListBlobs()
238 if err != nil {256 if err != nil {
239 return domain.ImagePruneReport{}, log.WrapErr(err, "failed to list blobs")257 return domain.ImagePruneReport{}, log.WrapErr(err, "failed to list blobs")
M internal/usecase/images/service_test.go +19 -0
@@ -13,6 +13,7 @@ import (
1313
1414 "github.com/bnema/gordon/internal/boundaries/out"
1515 "github.com/bnema/gordon/internal/domain"
16+ "github.com/bnema/gordon/internal/usecase/registrystate"
1617 pkgruntime "github.com/bnema/gordon/pkg/runtime"
1718 )
1819
@@ -332,6 +333,24 @@ func TestService_PruneRegistry_GarbageCollectsUnreferencedBlobs(t *testing.T) {
332333 assert.Equal(t, []string{"sha256:orphan"}, blobStorage.deletedBlobs)
333334 }
334335
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+
335354 func TestService_PruneRegistry_PreservesSharedBlobs(t *testing.T) {
336355 manifestStorage := newFakeManifestStorage()
337356 manifestStorage.repositories = []string{"gordon/api"}
@@ -13,6 +13,7 @@ import (
13 13
14 "github.com/bnema/gordon/internal/boundaries/out"14 "github.com/bnema/gordon/internal/boundaries/out"
15 "github.com/bnema/gordon/internal/domain"15 "github.com/bnema/gordon/internal/domain"
16+ "github.com/bnema/gordon/internal/usecase/registrystate"
16 pkgruntime "github.com/bnema/gordon/pkg/runtime"17 pkgruntime "github.com/bnema/gordon/pkg/runtime"
17 )18 )
18 19
@@ -332,6 +333,24 @@ func TestService_PruneRegistry_GarbageCollectsUnreferencedBlobs(t *testing.T) {
332 assert.Equal(t, []string{"sha256:orphan"}, blobStorage.deletedBlobs)333 assert.Equal(t, []string{"sha256:orphan"}, blobStorage.deletedBlobs)
333 }334 }
334 335
336+func TestService_PruneRegistry_PreservesPendingBlob(t *testing.T) {
337+ manifestStorage := newFakeManifestStorage()
338+ manifestStorage.repositories = []string{"gordon/api"}
339+ manifestStorage.tagsByRepo["gordon/api"] = []string{"latest"}
340+ manifestStorage.modTimes[manifestRefKey("gordon/api", "latest")] = time.Now().UTC()
341+ manifestStorage.manifests[manifestRefKey("gordon/api", "latest")] = mustManifestJSON(t, "sha256:cfg-live", "sha256:layer-live")
342+ blobStorage := &fakeBlobStorage{blobs: []string{"sha256:pending"}}
343+ state := registrystate.New()
344+ state.AddPending("sha256:pending", time.Now().UTC())
345+ svc := NewService(&fakeRuntime{}, manifestStorage, blobStorage, zerowrap.Default(), state)
346+
347+ report, err := svc.PruneRegistry(context.Background(), 1)
348+
349+ require.NoError(t, err)
350+ assert.Zero(t, report.Registry.BlobsRemoved)
351+ assert.Empty(t, blobStorage.deletedBlobs)
352+}
353+
335 func TestService_PruneRegistry_PreservesSharedBlobs(t *testing.T) {354 func TestService_PruneRegistry_PreservesSharedBlobs(t *testing.T) {
336 manifestStorage := newFakeManifestStorage()355 manifestStorage := newFakeManifestStorage()
337 manifestStorage.repositories = []string{"gordon/api"}356 manifestStorage.repositories = []string{"gordon/api"}
M internal/usecase/registry/service.go +176 -4
@@ -4,6 +4,9 @@ package registry
44 import (
55 "context"
66 "crypto/sha256"
7+ "crypto/sha512"
8+ "encoding/json"
9+ "errors"
710 "fmt"
811 "io"
912 "strings"
@@ -19,6 +22,7 @@ import (
1922 "github.com/bnema/gordon/internal/adapters/out/telemetry"
2023 "github.com/bnema/gordon/internal/boundaries/out"
2124 "github.com/bnema/gordon/internal/domain"
25+ "github.com/bnema/gordon/internal/usecase/registrystate"
2226 "github.com/bnema/gordon/pkg/validation"
2327 )
2428
@@ -31,6 +35,8 @@ type Service struct {
3135 eventBus out.EventPublisher
3236 metrics *telemetry.Metrics
3337 suppressedImages sync.Map // imageName -> *time.Timer
38+ mutationMu *sync.RWMutex
39+ registryState *registrystate.State
3440 }
3541
3642 // SetMetrics sets the telemetry metrics for the registry service.
@@ -43,11 +49,18 @@ func NewService(
4349 blobStorage out.BlobStorage,
4450 manifestStorage out.ManifestStorage,
4551 eventBus out.EventPublisher,
52+ states ...*registrystate.State,
4653 ) *Service {
54+ registryState := registrystate.New()
55+ if len(states) > 0 && states[0] != nil {
56+ registryState = states[0]
57+ }
4758 return &Service{
4859 blobStorage: blobStorage,
4960 manifestStorage: manifestStorage,
5061 eventBus: eventBus,
62+ mutationMu: &registryState.MutationMu,
63+ registryState: registryState,
5164 }
5265 }
5366
@@ -155,6 +168,9 @@ func (s *Service) GetManifest(ctx context.Context, name, reference string) (*dom
155168
156169 // PutManifest stores a manifest and returns the calculated digest.
157170 func (s *Service) PutManifest(ctx context.Context, manifest *domain.Manifest) (string, error) {
171+ s.mutationMu.RLock()
172+ defer s.mutationMu.RUnlock()
173+
158174 ctx, span := registryTracer.Start(ctx, "registry.put_manifest",
159175 trace.WithAttributes(
160176 attribute.String("name", manifest.Name),
@@ -171,12 +187,25 @@ func (s *Service) PutManifest(ctx context.Context, manifest *domain.Manifest) (s
171187 })
172188 log := zerowrap.FromCtx(ctx)
173189
174- // Calculate digest
190+ // Calculate and verify the digest before mutating storage. A digest-addressed
191+ // manifest must match its URL reference or clients could later retrieve
192+ // attacker-controlled bytes under a trusted content address.
175193 digest := fmt.Sprintf("sha256:%x", sha256.Sum256(manifest.Data))
194+ if validation.IsDigest(manifest.Reference) {
195+ matches, err := manifestDigestMatches(manifest.Reference, manifest.Data)
196+ if err != nil {
197+ return "", fmt.Errorf("validate manifest digest: %w", err)
198+ }
199+ if !matches {
200+ return "", fmt.Errorf("%w: manifest content does not match %s", domain.ErrDigestMismatch, manifest.Reference)
201+ }
202+ digest = manifest.Reference
203+ }
176204
177205 if err := s.manifestStorage.PutManifest(manifest.Name, manifest.Reference, manifest.ContentType, manifest.Data); err != nil {
178206 return "", log.WrapErr(err, "failed to store manifest")
179207 }
208+ s.registryState.MarkPublished(manifestReferencedDigests(manifest.Data))
180209
181210 // Record push metrics
182211 if s.metrics != nil {
@@ -191,7 +220,7 @@ func (s *Service) PutManifest(ctx context.Context, manifest *domain.Manifest) (s
191220 // Publish image pushed event only for tag references (not digests).
192221 // A docker push sends manifests by both digest and tag; firing only on
193222 // tag prevents duplicate deploy triggers for the same push.
194- if s.eventBus != nil && !strings.HasPrefix(manifest.Reference, "sha256:") {
223+ if s.eventBus != nil && !validation.IsDigest(manifest.Reference) {
195224 if s.IsDeployEventSuppressed(manifest.Name) {
196225 log.Info().Str("image", manifest.Name).Msg("skipping image.pushed event: CLI deploy intent active")
197226 } else {
@@ -212,6 +241,9 @@ func (s *Service) PutManifest(ctx context.Context, manifest *domain.Manifest) (s
212241
213242 // DeleteManifest removes a manifest.
214243 func (s *Service) DeleteManifest(ctx context.Context, name, reference string) error {
244+ s.mutationMu.RLock()
245+ defer s.mutationMu.RUnlock()
246+
215247 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
216248 zerowrap.FieldLayer: "usecase",
217249 zerowrap.FieldUseCase: "DeleteManifest",
@@ -245,15 +277,25 @@ func (s *Service) GetBlob(ctx context.Context, digest string) (io.ReadCloser, er
245277 return reader, nil
246278 }
247279
248-// GetBlobPath returns the filesystem path to a blob for direct serving.
249-func (s *Service) GetBlobPath(ctx context.Context, digest string) (string, error) {
280+// GetBlobPath returns the filesystem path to a blob only when a manifest in
281+// the requested repository references it.
282+func (s *Service) GetBlobPath(ctx context.Context, name, digest string) (string, error) {
250283 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
251284 zerowrap.FieldLayer: "usecase",
252285 zerowrap.FieldUseCase: "GetBlobPath",
286+ "name": name,
253287 "digest": digest,
254288 })
255289 log := zerowrap.FromCtx(ctx)
256290
291+ referenced, err := s.repositoryReferencesDigest(name, digest)
292+ if err != nil {
293+ return "", log.WrapErr(err, "failed to verify blob ownership")
294+ }
295+ if !referenced {
296+ return "", domain.ErrBlobNotFound
297+ }
298+
257299 path, err := s.blobStorage.GetBlobPath(digest)
258300 if err != nil {
259301 return "", log.WrapErr(err, "failed to get blob path")
@@ -262,8 +304,137 @@ func (s *Service) GetBlobPath(ctx context.Context, digest string) (string, error
262304 return path, nil
263305 }
264306
307+type manifestDescriptor struct {
308+ Digest string `json:"digest"`
309+}
310+
311+type manifestReferences struct {
312+ Config manifestDescriptor `json:"config"`
313+ Layers []manifestDescriptor `json:"layers"`
314+ Manifests []manifestDescriptor `json:"manifests"`
315+ Blobs []manifestDescriptor `json:"blobs"`
316+ Subject *manifestDescriptor `json:"subject"`
317+}
318+
319+const maxManifestTraversal = 10000
320+
321+func (s *Service) repositoryReferencesDigest(name, target string) (bool, error) {
322+ tags, err := s.manifestStorage.ListTags(name)
323+ if err != nil {
324+ if errors.Is(err, domain.ErrManifestNotFound) {
325+ return false, nil
326+ }
327+ return false, fmt.Errorf("list tags for repository %s: %w", name, err)
328+ }
329+
330+ if len(tags) > maxManifestTraversal {
331+ return false, fmt.Errorf("%w: repository %s exceeds manifest traversal limit", domain.ErrBlobNotFound, name)
332+ }
333+ queue := append([]string(nil), tags...)
334+ seen := make(map[string]struct{}, len(queue))
335+ for len(queue) > 0 {
336+ reference := queue[0]
337+ queue = queue[1:]
338+ if _, ok := seen[reference]; ok {
339+ continue
340+ }
341+ seen[reference] = struct{}{}
342+ if len(seen) > maxManifestTraversal {
343+ return false, fmt.Errorf("%w: repository %s exceeds manifest traversal limit", domain.ErrBlobNotFound, name)
344+ }
345+
346+ data, _, err := s.manifestStorage.GetManifest(name, reference)
347+ if err != nil {
348+ return false, fmt.Errorf("get manifest %s for repository %s: %w", reference, name, err)
349+ }
350+ var refs manifestReferences
351+ if err := json.Unmarshal(data, &refs); err != nil {
352+ return false, fmt.Errorf("decode manifest %s: %w", reference, err)
353+ }
354+
355+ if manifestReferencesTarget(refs, target) {
356+ return true, nil
357+ }
358+ nestedManifests := refs.Manifests
359+ if refs.Subject != nil && refs.Subject.Digest != "" {
360+ nestedManifests = append(append([]manifestDescriptor(nil), refs.Manifests...), *refs.Subject)
361+ }
362+ if len(seen)+len(queue)+len(nestedManifests) > maxManifestTraversal {
363+ return false, fmt.Errorf("%w: repository %s exceeds manifest traversal limit", domain.ErrBlobNotFound, name)
364+ }
365+ queue = appendManifestDigests(queue, nestedManifests)
366+ }
367+ return false, nil
368+}
369+
370+func manifestReferencesTarget(refs manifestReferences, target string) bool {
371+ return refs.Config.Digest == target ||
372+ descriptorListContains(refs.Layers, target) ||
373+ descriptorListContains(refs.Manifests, target) ||
374+ descriptorListContains(refs.Blobs, target) ||
375+ (refs.Subject != nil && refs.Subject.Digest == target)
376+}
377+
378+func appendManifestDigests(queue []string, descriptors []manifestDescriptor) []string {
379+ for _, descriptor := range descriptors {
380+ if descriptor.Digest != "" {
381+ queue = append(queue, descriptor.Digest)
382+ }
383+ }
384+ return queue
385+}
386+
387+func manifestReferencedDigests(data []byte) []string {
388+ var refs manifestReferences
389+ if json.Unmarshal(data, &refs) != nil {
390+ return nil
391+ }
392+ digests := make([]string, 0, 1+len(refs.Layers)+len(refs.Manifests)+len(refs.Blobs))
393+ if refs.Config.Digest != "" {
394+ digests = append(digests, refs.Config.Digest)
395+ }
396+ for _, descriptors := range [][]manifestDescriptor{refs.Layers, refs.Manifests, refs.Blobs} {
397+ for _, descriptor := range descriptors {
398+ if descriptor.Digest != "" {
399+ digests = append(digests, descriptor.Digest)
400+ }
401+ }
402+ }
403+ if refs.Subject != nil && refs.Subject.Digest != "" {
404+ digests = append(digests, refs.Subject.Digest)
405+ }
406+ return digests
407+}
408+
409+func descriptorListContains(descriptors []manifestDescriptor, digest string) bool {
410+ for _, descriptor := range descriptors {
411+ if descriptor.Digest == digest {
412+ return true
413+ }
414+ }
415+ return false
416+}
417+
418+func manifestDigestMatches(reference string, data []byte) (bool, error) {
419+ algorithm, _, ok := strings.Cut(reference, ":")
420+ if !ok {
421+ return false, domain.ErrInvalidDigest
422+ }
423+ switch algorithm {
424+ case "sha256":
425+ return reference == fmt.Sprintf("sha256:%x", sha256.Sum256(data)), nil
426+ case "sha512":
427+ return reference == fmt.Sprintf("sha512:%x", sha512.Sum512(data)), nil
428+ default:
429+ return false, domain.ErrInvalidDigest
430+ }
431+}
432+
265433 // PutBlob stores a blob with the given digest.
266434 func (s *Service) PutBlob(ctx context.Context, digest string, data io.Reader, size int64) error {
435+ s.mutationMu.RLock()
436+ defer s.mutationMu.RUnlock()
437+
267438 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
268439 zerowrap.FieldLayer: "usecase",
269440 zerowrap.FieldUseCase: "PutBlob",
@@ -275,6 +446,7 @@ func (s *Service) PutBlob(ctx context.Context, digest string, data io.Reader, si
275446 if err := s.blobStorage.PutBlob(digest, data, size); err != nil {
276447 return log.WrapErr(err, "failed to store blob")
277448 }
449+ s.registryState.AddPending(digest, time.Now().UTC())
278450
279451 log.Info().Msg("blob stored")
280452 return nil
@@ -4,6 +4,9 @@ package registry
4 import (4 import (
5 "context"5 "context"
6 "crypto/sha256"6 "crypto/sha256"
7+ "crypto/sha512"
8+ "encoding/json"
9+ "errors"
7 "fmt"10 "fmt"
8 "io"11 "io"
9 "strings"12 "strings"
@@ -19,6 +22,7 @@ import (
19 "github.com/bnema/gordon/internal/adapters/out/telemetry"22 "github.com/bnema/gordon/internal/adapters/out/telemetry"
20 "github.com/bnema/gordon/internal/boundaries/out"23 "github.com/bnema/gordon/internal/boundaries/out"
21 "github.com/bnema/gordon/internal/domain"24 "github.com/bnema/gordon/internal/domain"
25+ "github.com/bnema/gordon/internal/usecase/registrystate"
22 "github.com/bnema/gordon/pkg/validation"26 "github.com/bnema/gordon/pkg/validation"
23 )27 )
24 28
@@ -31,6 +35,8 @@ type Service struct {
31 eventBus out.EventPublisher35 eventBus out.EventPublisher
32 metrics *telemetry.Metrics36 metrics *telemetry.Metrics
33 suppressedImages sync.Map // imageName -> *time.Timer37 suppressedImages sync.Map // imageName -> *time.Timer
38+ mutationMu *sync.RWMutex
39+ registryState *registrystate.State
34 }40 }
35 41
36 // SetMetrics sets the telemetry metrics for the registry service.42 // SetMetrics sets the telemetry metrics for the registry service.
@@ -43,11 +49,18 @@ func NewService(
43 blobStorage out.BlobStorage,49 blobStorage out.BlobStorage,
44 manifestStorage out.ManifestStorage,50 manifestStorage out.ManifestStorage,
45 eventBus out.EventPublisher,51 eventBus out.EventPublisher,
52+ states ...*registrystate.State,
46 ) *Service {53 ) *Service {
54+ registryState := registrystate.New()
55+ if len(states) > 0 && states[0] != nil {
56+ registryState = states[0]
57+ }
47 return &Service{58 return &Service{
48 blobStorage: blobStorage,59 blobStorage: blobStorage,
49 manifestStorage: manifestStorage,60 manifestStorage: manifestStorage,
50 eventBus: eventBus,61 eventBus: eventBus,
62+ mutationMu: &registryState.MutationMu,
63+ registryState: registryState,
51 }64 }
52 }65 }
53 66
@@ -155,6 +168,9 @@ func (s *Service) GetManifest(ctx context.Context, name, reference string) (*dom
155 168
156 // PutManifest stores a manifest and returns the calculated digest.169 // PutManifest stores a manifest and returns the calculated digest.
157 func (s *Service) PutManifest(ctx context.Context, manifest *domain.Manifest) (string, error) {170 func (s *Service) PutManifest(ctx context.Context, manifest *domain.Manifest) (string, error) {
171+ s.mutationMu.RLock()
172+ defer s.mutationMu.RUnlock()
173+
158 ctx, span := registryTracer.Start(ctx, "registry.put_manifest",174 ctx, span := registryTracer.Start(ctx, "registry.put_manifest",
159 trace.WithAttributes(175 trace.WithAttributes(
160 attribute.String("name", manifest.Name),176 attribute.String("name", manifest.Name),
@@ -171,12 +187,25 @@ func (s *Service) PutManifest(ctx context.Context, manifest *domain.Manifest) (s
171 })187 })
172 log := zerowrap.FromCtx(ctx)188 log := zerowrap.FromCtx(ctx)
173 189
174- // Calculate digest190+ // Calculate and verify the digest before mutating storage. A digest-addressed
191+ // manifest must match its URL reference or clients could later retrieve
192+ // attacker-controlled bytes under a trusted content address.
175 digest := fmt.Sprintf("sha256:%x", sha256.Sum256(manifest.Data))193 digest := fmt.Sprintf("sha256:%x", sha256.Sum256(manifest.Data))
194+ if validation.IsDigest(manifest.Reference) {
195+ matches, err := manifestDigestMatches(manifest.Reference, manifest.Data)
196+ if err != nil {
197+ return "", fmt.Errorf("validate manifest digest: %w", err)
198+ }
199+ if !matches {
200+ return "", fmt.Errorf("%w: manifest content does not match %s", domain.ErrDigestMismatch, manifest.Reference)
201+ }
202+ digest = manifest.Reference
203+ }
176 204
177 if err := s.manifestStorage.PutManifest(manifest.Name, manifest.Reference, manifest.ContentType, manifest.Data); err != nil {205 if err := s.manifestStorage.PutManifest(manifest.Name, manifest.Reference, manifest.ContentType, manifest.Data); err != nil {
178 return "", log.WrapErr(err, "failed to store manifest")206 return "", log.WrapErr(err, "failed to store manifest")
179 }207 }
208+ s.registryState.MarkPublished(manifestReferencedDigests(manifest.Data))
180 209
181 // Record push metrics210 // Record push metrics
182 if s.metrics != nil {211 if s.metrics != nil {
@@ -191,7 +220,7 @@ func (s *Service) PutManifest(ctx context.Context, manifest *domain.Manifest) (s
191 // Publish image pushed event only for tag references (not digests).220 // Publish image pushed event only for tag references (not digests).
192 // A docker push sends manifests by both digest and tag; firing only on221 // A docker push sends manifests by both digest and tag; firing only on
193 // tag prevents duplicate deploy triggers for the same push.222 // tag prevents duplicate deploy triggers for the same push.
194- if s.eventBus != nil && !strings.HasPrefix(manifest.Reference, "sha256:") {223+ if s.eventBus != nil && !validation.IsDigest(manifest.Reference) {
195 if s.IsDeployEventSuppressed(manifest.Name) {224 if s.IsDeployEventSuppressed(manifest.Name) {
196 log.Info().Str("image", manifest.Name).Msg("skipping image.pushed event: CLI deploy intent active")225 log.Info().Str("image", manifest.Name).Msg("skipping image.pushed event: CLI deploy intent active")
197 } else {226 } else {
@@ -212,6 +241,9 @@ func (s *Service) PutManifest(ctx context.Context, manifest *domain.Manifest) (s
212 241
213 // DeleteManifest removes a manifest.242 // DeleteManifest removes a manifest.
214 func (s *Service) DeleteManifest(ctx context.Context, name, reference string) error {243 func (s *Service) DeleteManifest(ctx context.Context, name, reference string) error {
244+ s.mutationMu.RLock()
245+ defer s.mutationMu.RUnlock()
246+
215 ctx = zerowrap.CtxWithFields(ctx, map[string]any{247 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
216 zerowrap.FieldLayer: "usecase",248 zerowrap.FieldLayer: "usecase",
217 zerowrap.FieldUseCase: "DeleteManifest",249 zerowrap.FieldUseCase: "DeleteManifest",
@@ -245,15 +277,25 @@ func (s *Service) GetBlob(ctx context.Context, digest string) (io.ReadCloser, er
245 return reader, nil277 return reader, nil
246 }278 }
247 279
248-// GetBlobPath returns the filesystem path to a blob for direct serving.280+// GetBlobPath returns the filesystem path to a blob only when a manifest in
249-func (s *Service) GetBlobPath(ctx context.Context, digest string) (string, error) {281+// the requested repository references it.
282+func (s *Service) GetBlobPath(ctx context.Context, name, digest string) (string, error) {
250 ctx = zerowrap.CtxWithFields(ctx, map[string]any{283 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
251 zerowrap.FieldLayer: "usecase",284 zerowrap.FieldLayer: "usecase",
252 zerowrap.FieldUseCase: "GetBlobPath",285 zerowrap.FieldUseCase: "GetBlobPath",
286+ "name": name,
253 "digest": digest,287 "digest": digest,
254 })288 })
255 log := zerowrap.FromCtx(ctx)289 log := zerowrap.FromCtx(ctx)
256 290
291+ referenced, err := s.repositoryReferencesDigest(name, digest)
292+ if err != nil {
293+ return "", log.WrapErr(err, "failed to verify blob ownership")
294+ }
295+ if !referenced {
296+ return "", domain.ErrBlobNotFound
297+ }
298+
257 path, err := s.blobStorage.GetBlobPath(digest)299 path, err := s.blobStorage.GetBlobPath(digest)
258 if err != nil {300 if err != nil {
259 return "", log.WrapErr(err, "failed to get blob path")301 return "", log.WrapErr(err, "failed to get blob path")
@@ -262,8 +304,137 @@ func (s *Service) GetBlobPath(ctx context.Context, digest string) (string, error
262 return path, nil304 return path, nil
263 }305 }
264 306
307+type manifestDescriptor struct {
308+ Digest string `json:"digest"`
309+}
310+
311+type manifestReferences struct {
312+ Config manifestDescriptor `json:"config"`
313+ Layers []manifestDescriptor `json:"layers"`
314+ Manifests []manifestDescriptor `json:"manifests"`
315+ Blobs []manifestDescriptor `json:"blobs"`
316+ Subject *manifestDescriptor `json:"subject"`
317+}
318+
319+const maxManifestTraversal = 10000
320+
321+func (s *Service) repositoryReferencesDigest(name, target string) (bool, error) {
322+ tags, err := s.manifestStorage.ListTags(name)
323+ if err != nil {
324+ if errors.Is(err, domain.ErrManifestNotFound) {
325+ return false, nil
326+ }
327+ return false, fmt.Errorf("list tags for repository %s: %w", name, err)
328+ }
329+
330+ if len(tags) > maxManifestTraversal {
331+ return false, fmt.Errorf("%w: repository %s exceeds manifest traversal limit", domain.ErrBlobNotFound, name)
332+ }
333+ queue := append([]string(nil), tags...)
334+ seen := make(map[string]struct{}, len(queue))
335+ for len(queue) > 0 {
336+ reference := queue[0]
337+ queue = queue[1:]
338+ if _, ok := seen[reference]; ok {
339+ continue
340+ }
341+ seen[reference] = struct{}{}
342+ if len(seen) > maxManifestTraversal {
343+ return false, fmt.Errorf("%w: repository %s exceeds manifest traversal limit", domain.ErrBlobNotFound, name)
344+ }
345+
346+ data, _, err := s.manifestStorage.GetManifest(name, reference)
347+ if err != nil {
348+ return false, fmt.Errorf("get manifest %s for repository %s: %w", reference, name, err)
349+ }
350+ var refs manifestReferences
351+ if err := json.Unmarshal(data, &refs); err != nil {
352+ return false, fmt.Errorf("decode manifest %s: %w", reference, err)
353+ }
354+
355+ if manifestReferencesTarget(refs, target) {
356+ return true, nil
357+ }
358+ nestedManifests := refs.Manifests
359+ if refs.Subject != nil && refs.Subject.Digest != "" {
360+ nestedManifests = append(append([]manifestDescriptor(nil), refs.Manifests...), *refs.Subject)
361+ }
362+ if len(seen)+len(queue)+len(nestedManifests) > maxManifestTraversal {
363+ return false, fmt.Errorf("%w: repository %s exceeds manifest traversal limit", domain.ErrBlobNotFound, name)
364+ }
365+ queue = appendManifestDigests(queue, nestedManifests)
366+ }
367+ return false, nil
368+}
369+
370+func manifestReferencesTarget(refs manifestReferences, target string) bool {
371+ return refs.Config.Digest == target ||
372+ descriptorListContains(refs.Layers, target) ||
373+ descriptorListContains(refs.Manifests, target) ||
374+ descriptorListContains(refs.Blobs, target) ||
375+ (refs.Subject != nil && refs.Subject.Digest == target)
376+}
377+
378+func appendManifestDigests(queue []string, descriptors []manifestDescriptor) []string {
379+ for _, descriptor := range descriptors {
380+ if descriptor.Digest != "" {
381+ queue = append(queue, descriptor.Digest)
382+ }
383+ }
384+ return queue
385+}
386+
387+func manifestReferencedDigests(data []byte) []string {
388+ var refs manifestReferences
389+ if json.Unmarshal(data, &refs) != nil {
390+ return nil
391+ }
392+ digests := make([]string, 0, 1+len(refs.Layers)+len(refs.Manifests)+len(refs.Blobs))
393+ if refs.Config.Digest != "" {
394+ digests = append(digests, refs.Config.Digest)
395+ }
396+ for _, descriptors := range [][]manifestDescriptor{refs.Layers, refs.Manifests, refs.Blobs} {
397+ for _, descriptor := range descriptors {
398+ if descriptor.Digest != "" {
399+ digests = append(digests, descriptor.Digest)
400+ }
401+ }
402+ }
403+ if refs.Subject != nil && refs.Subject.Digest != "" {
404+ digests = append(digests, refs.Subject.Digest)
405+ }
406+ return digests
407+}
408+
409+func descriptorListContains(descriptors []manifestDescriptor, digest string) bool {
410+ for _, descriptor := range descriptors {
411+ if descriptor.Digest == digest {
412+ return true
413+ }
414+ }
415+ return false
416+}
417+
418+func manifestDigestMatches(reference string, data []byte) (bool, error) {
419+ algorithm, _, ok := strings.Cut(reference, ":")
420+ if !ok {
421+ return false, domain.ErrInvalidDigest
422+ }
423+ switch algorithm {
424+ case "sha256":
425+ return reference == fmt.Sprintf("sha256:%x", sha256.Sum256(data)), nil
426+ case "sha512":
427+ return reference == fmt.Sprintf("sha512:%x", sha512.Sum512(data)), nil
428+ default:
429+ return false, domain.ErrInvalidDigest
430+ }
431+}
432+
265 // PutBlob stores a blob with the given digest.433 // PutBlob stores a blob with the given digest.
266 func (s *Service) PutBlob(ctx context.Context, digest string, data io.Reader, size int64) error {434 func (s *Service) PutBlob(ctx context.Context, digest string, data io.Reader, size int64) error {
435+ s.mutationMu.RLock()
436+ defer s.mutationMu.RUnlock()
437+
267 ctx = zerowrap.CtxWithFields(ctx, map[string]any{438 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
268 zerowrap.FieldLayer: "usecase",439 zerowrap.FieldLayer: "usecase",
269 zerowrap.FieldUseCase: "PutBlob",440 zerowrap.FieldUseCase: "PutBlob",
@@ -275,6 +446,7 @@ func (s *Service) PutBlob(ctx context.Context, digest string, data io.Reader, si
275 if err := s.blobStorage.PutBlob(digest, data, size); err != nil {446 if err := s.blobStorage.PutBlob(digest, data, size); err != nil {
276 return log.WrapErr(err, "failed to store blob")447 return log.WrapErr(err, "failed to store blob")
277 }448 }
449+ s.registryState.AddPending(digest, time.Now().UTC())
278 450
279 log.Info().Msg("blob stored")451 log.Info().Msg("blob stored")
280 return nil452 return nil
M internal/usecase/registry/service_test.go +103 -0
@@ -3,10 +3,13 @@ package registry
33 import (
44 "bytes"
55 "context"
6+ "crypto/sha512"
67 "errors"
8+ "fmt"
79 "io"
810 "strings"
911 "testing"
12+ "time"
1013
1114 "github.com/bnema/zerowrap"
1215 "github.com/stretchr/testify/assert"
@@ -15,6 +18,7 @@ import (
1518
1619 "github.com/bnema/gordon/internal/boundaries/out/mocks"
1720 "github.com/bnema/gordon/internal/domain"
21+ "github.com/bnema/gordon/internal/usecase/registrystate"
1822 )
1923
2024 func testContext() context.Context {
@@ -84,6 +88,105 @@ func TestService_PutManifest_Success(t *testing.T) {
8488 assert.True(t, strings.HasPrefix(digest, "sha256:"))
8589 }
8690
91+func TestService_PutManifest_SHA512DigestDoesNotPublishEvent(t *testing.T) {
92+ blobStorage := mocks.NewMockBlobStorage(t)
93+ manifestStorage := mocks.NewMockManifestStorage(t)
94+ eventBus := mocks.NewMockEventPublisher(t)
95+ state := registrystate.New()
96+ state.AddPending("sha256:config", time.Now().UTC())
97+ svc := NewService(blobStorage, manifestStorage, eventBus, state)
98+ data := []byte(`{"schemaVersion":2,"config":{"digest":"sha256:config"}}`)
99+ reference := fmt.Sprintf("sha512:%x", sha512.Sum512(data))
100+ manifest := &domain.Manifest{Name: "myapp", Reference: reference, ContentType: "application/vnd.oci.image.manifest.v1+json", Data: data}
101+
102+ manifestStorage.EXPECT().PutManifest("myapp", reference, manifest.ContentType, data).Return(nil)
103+
104+ digest, err := svc.PutManifest(testContext(), manifest)
105+
106+ require.NoError(t, err)
107+ assert.Equal(t, reference, digest)
108+ assert.Empty(t, state.PendingDigests(time.Now().UTC()))
109+}
110+
111+func TestService_PutManifest_RejectsDigestMismatch(t *testing.T) {
112+ blobStorage := mocks.NewMockBlobStorage(t)
113+ manifestStorage := mocks.NewMockManifestStorage(t)
114+ eventBus := mocks.NewMockEventPublisher(t)
115+ svc := NewService(blobStorage, manifestStorage, eventBus)
116+
117+ manifest := &domain.Manifest{
118+ Name: "myapp",
119+ Reference: "sha256:0000000000000000000000000000000000000000000000000000000000000000",
120+ ContentType: "application/vnd.oci.image.manifest.v1+json",
121+ Data: []byte(`{"schemaVersion":2}`),
122+ }
123+
124+ _, err := svc.PutManifest(testContext(), manifest)
125+
126+ require.Error(t, err)
127+ assert.ErrorIs(t, err, domain.ErrDigestMismatch)
128+}
129+
130+func TestService_GetBlobPath_RequiresRepositoryReference(t *testing.T) {
131+ const digest = "sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4"
132+ blobStorage := mocks.NewMockBlobStorage(t)
133+ manifestStorage := mocks.NewMockManifestStorage(t)
134+ svc := NewService(blobStorage, manifestStorage, nil)
135+
136+ manifestStorage.EXPECT().ListTags("allowed").Return([]string{"latest"}, nil)
137+ manifestStorage.EXPECT().GetManifest("allowed", "latest").Return(
138+ []byte(`{"schemaVersion":2,"layers":[{"digest":"`+digest+`"}]}`),
139+ "application/vnd.oci.image.manifest.v1+json", nil,
140+ )
141+ blobStorage.EXPECT().GetBlobPath(digest).Return("/registry/blob", nil)
142+
143+ path, err := svc.GetBlobPath(testContext(), "allowed", digest)
144+ require.NoError(t, err)
145+ assert.Equal(t, "/registry/blob", path)
146+
147+ manifestStorage.EXPECT().ListTags("denied").Return([]string{"latest"}, nil)
148+ manifestStorage.EXPECT().GetManifest("denied", "latest").Return(
149+ []byte(`{"schemaVersion":2,"layers":[]}`),
150+ "application/vnd.oci.image.manifest.v1+json", nil,
151+ )
152+
153+ _, err = svc.GetBlobPath(testContext(), "denied", digest)
154+ assert.ErrorIs(t, err, domain.ErrBlobNotFound)
155+}
156+
157+func TestService_GetBlobPath_FollowsSubjectManifest(t *testing.T) {
158+ const target = "sha256:target"
159+ blobStorage := mocks.NewMockBlobStorage(t)
160+ manifestStorage := mocks.NewMockManifestStorage(t)
161+ svc := NewService(blobStorage, manifestStorage, nil)
162+
163+ manifestStorage.EXPECT().ListTags("artifacts").Return([]string{"latest"}, nil)
164+ manifestStorage.EXPECT().GetManifest("artifacts", "latest").Return(
165+ []byte(`{"subject":{"digest":"sha256:subject"}}`), "application/vnd.oci.artifact.manifest.v1+json", nil,
166+ )
167+ manifestStorage.EXPECT().GetManifest("artifacts", "sha256:subject").Return(
168+ []byte(`{"config":{"digest":"`+target+`"}}`), "application/vnd.oci.image.manifest.v1+json", nil,
169+ )
170+ blobStorage.EXPECT().GetBlobPath(target).Return("/registry/target", nil)
171+
172+ path, err := svc.GetBlobPath(testContext(), "artifacts", target)
173+
174+ require.NoError(t, err)
175+ assert.Equal(t, "/registry/target", path)
176+}
177+
178+func TestService_GetBlobPath_BoundsManifestTraversal(t *testing.T) {
179+ blobStorage := mocks.NewMockBlobStorage(t)
180+ manifestStorage := mocks.NewMockManifestStorage(t)
181+ svc := NewService(blobStorage, manifestStorage, nil)
182+ tags := make([]string, maxManifestTraversal+1)
183+ manifestStorage.EXPECT().ListTags("busy").Return(tags, nil)
184+
185+ _, err := svc.GetBlobPath(testContext(), "busy", "sha256:target")
186+
187+ assert.ErrorIs(t, err, domain.ErrBlobNotFound)
188+}
189+
87190 func TestService_PutManifest_StorageError(t *testing.T) {
88191 blobStorage := mocks.NewMockBlobStorage(t)
89192 manifestStorage := mocks.NewMockManifestStorage(t)
@@ -3,10 +3,13 @@ package registry
3 import (3 import (
4 "bytes"4 "bytes"
5 "context"5 "context"
6+ "crypto/sha512"
6 "errors"7 "errors"
8+ "fmt"
7 "io"9 "io"
8 "strings"10 "strings"
9 "testing"11 "testing"
12+ "time"
10 13
11 "github.com/bnema/zerowrap"14 "github.com/bnema/zerowrap"
12 "github.com/stretchr/testify/assert"15 "github.com/stretchr/testify/assert"
@@ -15,6 +18,7 @@ import (
15 18
16 "github.com/bnema/gordon/internal/boundaries/out/mocks"19 "github.com/bnema/gordon/internal/boundaries/out/mocks"
17 "github.com/bnema/gordon/internal/domain"20 "github.com/bnema/gordon/internal/domain"
21+ "github.com/bnema/gordon/internal/usecase/registrystate"
18 )22 )
19 23
20 func testContext() context.Context {24 func testContext() context.Context {
@@ -84,6 +88,105 @@ func TestService_PutManifest_Success(t *testing.T) {
84 assert.True(t, strings.HasPrefix(digest, "sha256:"))88 assert.True(t, strings.HasPrefix(digest, "sha256:"))
85 }89 }
86 90
91+func TestService_PutManifest_SHA512DigestDoesNotPublishEvent(t *testing.T) {
92+ blobStorage := mocks.NewMockBlobStorage(t)
93+ manifestStorage := mocks.NewMockManifestStorage(t)
94+ eventBus := mocks.NewMockEventPublisher(t)
95+ state := registrystate.New()
96+ state.AddPending("sha256:config", time.Now().UTC())
97+ svc := NewService(blobStorage, manifestStorage, eventBus, state)
98+ data := []byte(`{"schemaVersion":2,"config":{"digest":"sha256:config"}}`)
99+ reference := fmt.Sprintf("sha512:%x", sha512.Sum512(data))
100+ manifest := &domain.Manifest{Name: "myapp", Reference: reference, ContentType: "application/vnd.oci.image.manifest.v1+json", Data: data}
101+
102+ manifestStorage.EXPECT().PutManifest("myapp", reference, manifest.ContentType, data).Return(nil)
103+
104+ digest, err := svc.PutManifest(testContext(), manifest)
105+
106+ require.NoError(t, err)
107+ assert.Equal(t, reference, digest)
108+ assert.Empty(t, state.PendingDigests(time.Now().UTC()))
109+}
110+
111+func TestService_PutManifest_RejectsDigestMismatch(t *testing.T) {
112+ blobStorage := mocks.NewMockBlobStorage(t)
113+ manifestStorage := mocks.NewMockManifestStorage(t)
114+ eventBus := mocks.NewMockEventPublisher(t)
115+ svc := NewService(blobStorage, manifestStorage, eventBus)
116+
117+ manifest := &domain.Manifest{
118+ Name: "myapp",
119+ Reference: "sha256:0000000000000000000000000000000000000000000000000000000000000000",
120+ ContentType: "application/vnd.oci.image.manifest.v1+json",
121+ Data: []byte(`{"schemaVersion":2}`),
122+ }
123+
124+ _, err := svc.PutManifest(testContext(), manifest)
125+
126+ require.Error(t, err)
127+ assert.ErrorIs(t, err, domain.ErrDigestMismatch)
128+}
129+
130+func TestService_GetBlobPath_RequiresRepositoryReference(t *testing.T) {
131+ const digest = "sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4"
132+ blobStorage := mocks.NewMockBlobStorage(t)
133+ manifestStorage := mocks.NewMockManifestStorage(t)
134+ svc := NewService(blobStorage, manifestStorage, nil)
135+
136+ manifestStorage.EXPECT().ListTags("allowed").Return([]string{"latest"}, nil)
137+ manifestStorage.EXPECT().GetManifest("allowed", "latest").Return(
138+ []byte(`{"schemaVersion":2,"layers":[{"digest":"`+digest+`"}]}`),
139+ "application/vnd.oci.image.manifest.v1+json", nil,
140+ )
141+ blobStorage.EXPECT().GetBlobPath(digest).Return("/registry/blob", nil)
142+
143+ path, err := svc.GetBlobPath(testContext(), "allowed", digest)
144+ require.NoError(t, err)
145+ assert.Equal(t, "/registry/blob", path)
146+
147+ manifestStorage.EXPECT().ListTags("denied").Return([]string{"latest"}, nil)
148+ manifestStorage.EXPECT().GetManifest("denied", "latest").Return(
149+ []byte(`{"schemaVersion":2,"layers":[]}`),
150+ "application/vnd.oci.image.manifest.v1+json", nil,
151+ )
152+
153+ _, err = svc.GetBlobPath(testContext(), "denied", digest)
154+ assert.ErrorIs(t, err, domain.ErrBlobNotFound)
155+}
156+
157+func TestService_GetBlobPath_FollowsSubjectManifest(t *testing.T) {
158+ const target = "sha256:target"
159+ blobStorage := mocks.NewMockBlobStorage(t)
160+ manifestStorage := mocks.NewMockManifestStorage(t)
161+ svc := NewService(blobStorage, manifestStorage, nil)
162+
163+ manifestStorage.EXPECT().ListTags("artifacts").Return([]string{"latest"}, nil)
164+ manifestStorage.EXPECT().GetManifest("artifacts", "latest").Return(
165+ []byte(`{"subject":{"digest":"sha256:subject"}}`), "application/vnd.oci.artifact.manifest.v1+json", nil,
166+ )
167+ manifestStorage.EXPECT().GetManifest("artifacts", "sha256:subject").Return(
168+ []byte(`{"config":{"digest":"`+target+`"}}`), "application/vnd.oci.image.manifest.v1+json", nil,
169+ )
170+ blobStorage.EXPECT().GetBlobPath(target).Return("/registry/target", nil)
171+
172+ path, err := svc.GetBlobPath(testContext(), "artifacts", target)
173+
174+ require.NoError(t, err)
175+ assert.Equal(t, "/registry/target", path)
176+}
177+
178+func TestService_GetBlobPath_BoundsManifestTraversal(t *testing.T) {
179+ blobStorage := mocks.NewMockBlobStorage(t)
180+ manifestStorage := mocks.NewMockManifestStorage(t)
181+ svc := NewService(blobStorage, manifestStorage, nil)
182+ tags := make([]string, maxManifestTraversal+1)
183+ manifestStorage.EXPECT().ListTags("busy").Return(tags, nil)
184+
185+ _, err := svc.GetBlobPath(testContext(), "busy", "sha256:target")
186+
187+ assert.ErrorIs(t, err, domain.ErrBlobNotFound)
188+}
189+
87 func TestService_PutManifest_StorageError(t *testing.T) {190 func TestService_PutManifest_StorageError(t *testing.T) {
88 blobStorage := mocks.NewMockBlobStorage(t)191 blobStorage := mocks.NewMockBlobStorage(t)
89 manifestStorage := mocks.NewMockManifestStorage(t)192 manifestStorage := mocks.NewMockManifestStorage(t)
A internal/usecase/registrystate/state.go +57 -0
@@ -0,0 +1,57 @@
1+// Package registrystate coordinates registry mutations and garbage collection.
2+package registrystate
3+
4+import (
5+ "sync"
6+ "time"
7+)
8+
9+// PendingBlobTTL protects uploaded blobs while clients publish their manifest.
10+const PendingBlobTTL = 24 * time.Hour
11+
12+// State is shared by registry writes and registry garbage collection.
13+type State struct {
14+ MutationMu sync.RWMutex
15+
16+ pendingMu sync.Mutex
17+ pending map[string]time.Time
18+}
19+
20+// New creates empty registry coordination state.
21+func New() *State {
22+ return &State{pending: make(map[string]time.Time)}
23+}
24+
25+// AddPending records a blob that has been uploaded but not yet referenced by a manifest.
26+func (s *State) AddPending(digest string, now time.Time) {
27+ s.pendingMu.Lock()
28+ defer s.pendingMu.Unlock()
29+ if s.pending == nil {
30+ s.pending = make(map[string]time.Time)
31+ }
32+ s.pending[digest] = now
33+}
34+
35+// MarkPublished removes blobs now owned by a published manifest from the pending set.
36+func (s *State) MarkPublished(digests []string) {
37+ s.pendingMu.Lock()
38+ defer s.pendingMu.Unlock()
39+ for _, digest := range digests {
40+ delete(s.pending, digest)
41+ }
42+}
43+
44+// PendingDigests returns non-expired pending blobs and drops abandoned entries.
45+func (s *State) PendingDigests(now time.Time) map[string]struct{} {
46+ s.pendingMu.Lock()
47+ defer s.pendingMu.Unlock()
48+ result := make(map[string]struct{}, len(s.pending))
49+ for digest, createdAt := range s.pending {
50+ if now.Sub(createdAt) > PendingBlobTTL {
51+ delete(s.pending, digest)
52+ continue
53+ }
54+ result[digest] = struct{}{}
55+ }
56+ return result
57+}
@@ -0,0 +1,57 @@
1+// Package registrystate coordinates registry mutations and garbage collection.
2+package registrystate
3+
4+import (
5+ "sync"
6+ "time"
7+)
8+
9+// PendingBlobTTL protects uploaded blobs while clients publish their manifest.
10+const PendingBlobTTL = 24 * time.Hour
11+
12+// State is shared by registry writes and registry garbage collection.
13+type State struct {
14+ MutationMu sync.RWMutex
15+
16+ pendingMu sync.Mutex
17+ pending map[string]time.Time
18+}
19+
20+// New creates empty registry coordination state.
21+func New() *State {
22+ return &State{pending: make(map[string]time.Time)}
23+}
24+
25+// AddPending records a blob that has been uploaded but not yet referenced by a manifest.
26+func (s *State) AddPending(digest string, now time.Time) {
27+ s.pendingMu.Lock()
28+ defer s.pendingMu.Unlock()
29+ if s.pending == nil {
30+ s.pending = make(map[string]time.Time)
31+ }
32+ s.pending[digest] = now
33+}
34+
35+// MarkPublished removes blobs now owned by a published manifest from the pending set.
36+func (s *State) MarkPublished(digests []string) {
37+ s.pendingMu.Lock()
38+ defer s.pendingMu.Unlock()
39+ for _, digest := range digests {
40+ delete(s.pending, digest)
41+ }
42+}
43+
44+// PendingDigests returns non-expired pending blobs and drops abandoned entries.
45+func (s *State) PendingDigests(now time.Time) map[string]struct{} {
46+ s.pendingMu.Lock()
47+ defer s.pendingMu.Unlock()
48+ result := make(map[string]struct{}, len(s.pending))
49+ for digest, createdAt := range s.pending {
50+ if now.Sub(createdAt) > PendingBlobTTL {
51+ delete(s.pending, digest)
52+ continue
53+ }
54+ result[digest] = struct{}{}
55+ }
56+ return result
57+}
A internal/usecase/registrystate/state_test.go +21 -0
@@ -0,0 +1,21 @@
1+package registrystate
2+
3+import (
4+ "testing"
5+ "time"
6+
7+ "github.com/stretchr/testify/assert"
8+)
9+
10+func TestStateTracksPublishesAndExpiresPendingBlobs(t *testing.T) {
11+ now := time.Now().UTC()
12+ state := New()
13+ state.AddPending("sha256:published", now)
14+ state.AddPending("sha256:pending", now)
15+ state.AddPending("sha256:expired", now.Add(-PendingBlobTTL-time.Second))
16+
17+ state.MarkPublished([]string{"sha256:published"})
18+ pending := state.PendingDigests(now)
19+
20+ assert.Equal(t, map[string]struct{}{"sha256:pending": {}}, pending)
21+}
@@ -0,0 +1,21 @@
1+package registrystate
2+
3+import (
4+ "testing"
5+ "time"
6+
7+ "github.com/stretchr/testify/assert"
8+)
9+
10+func TestStateTracksPublishesAndExpiresPendingBlobs(t *testing.T) {
11+ now := time.Now().UTC()
12+ state := New()
13+ state.AddPending("sha256:published", now)
14+ state.AddPending("sha256:pending", now)
15+ state.AddPending("sha256:expired", now.Add(-PendingBlobTTL-time.Second))
16+
17+ state.MarkPublished([]string{"sha256:published"})
18+ pending := state.PendingDigests(now)
19+
20+ assert.Equal(t, map[string]struct{}{"sha256:pending": {}}, pending)
21+}
M internal/usecase/services/service.go +41 -4
@@ -2,10 +2,12 @@ package services
22
33 import (
44 "bufio"
5+ "bytes"
56 "context"
67 "crypto/sha256"
78 "encoding/hex"
89 "encoding/json"
10+ "errors"
911 "fmt"
1012 "io"
1113 "maps"
@@ -521,7 +523,7 @@ func tcpReadinessAddress(svc domain.StandaloneService) (string, error) {
521523 }
522524
523525 func (s *Service) waitLogReadiness(ctx context.Context, containerID string, svc domain.StandaloneService) error {
524- ticker := time.NewTicker(10 * time.Millisecond)
526+ ticker := time.NewTicker(250 * time.Millisecond)
525527 defer ticker.Stop()
526528 var lastErr error
527529 for {
@@ -532,6 +534,9 @@ func (s *Service) waitLogReadiness(ctx context.Context, containerID string, svc
532534 if err == nil && found {
533535 return nil
534536 }
537+ if errors.Is(err, domain.ErrReadinessLogSizeExceeded) {
538+ return err
539+ }
535540 if err != nil {
536541 lastErr = err
537542 }
@@ -551,17 +556,49 @@ func logReadinessTimeoutError(serviceName string, timeoutErr, lastErr error) err
551556 return fmt.Errorf("standalone service %q log readiness timed out: %w", serviceName, timeoutErr)
552557 }
553558
559+const maxReadinessLogSize = 1 << 20 // 1 MiB
560+
554561 func (s *Service) logContains(ctx context.Context, containerID, path, contains string) (bool, error) {
555562 reader, err := s.runtime.CopyFromContainer(ctx, containerID, path)
556563 if err != nil {
557564 return false, err
558565 }
559566 defer reader.Close()
560- content, err := io.ReadAll(reader)
561- if err != nil {
567+ if contains == "" {
568+ return true, nil
569+ }
570+
571+ needle := []byte(contains)
572+ buffer := make([]byte, 32*1024)
573+ carry := make([]byte, 0, len(needle)-1)
574+ remaining := maxReadinessLogSize
575+ for remaining > 0 {
576+ readSize := min(len(buffer), remaining)
577+ n, readErr := reader.Read(buffer[:readSize])
578+ if n > 0 {
579+ window := append(carry, buffer[:n]...)
580+ if bytes.Contains(window, needle) {
581+ return true, nil
582+ }
583+ overlap := min(len(needle)-1, len(window))
584+ carry = append(carry[:0], window[len(window)-overlap:]...)
585+ remaining -= n
586+ }
587+ if errors.Is(readErr, io.EOF) {
588+ return false, nil
589+ }
590+ if readErr != nil {
591+ return false, readErr
592+ }
593+ }
594+
595+ var extra [1]byte
596+ if _, err := reader.Read(extra[:]); errors.Is(err, io.EOF) {
597+ return false, nil
598+ } else if err != nil {
562599 return false, err
563600 }
564- return strings.Contains(string(content), contains), nil
601+ return false, fmt.Errorf("%w: limit is %d bytes", domain.ErrReadinessLogSizeExceeded, maxReadinessLogSize)
565602 }
566603
567604 func normalizeCleanup(cleanup domain.StandaloneServiceCleanup) domain.StandaloneServiceCleanup {
@@ -2,10 +2,12 @@ package services
2 2
3 import (3 import (
4 "bufio"4 "bufio"
5+ "bytes"
5 "context"6 "context"
6 "crypto/sha256"7 "crypto/sha256"
7 "encoding/hex"8 "encoding/hex"
8 "encoding/json"9 "encoding/json"
10+ "errors"
9 "fmt"11 "fmt"
10 "io"12 "io"
11 "maps"13 "maps"
@@ -521,7 +523,7 @@ func tcpReadinessAddress(svc domain.StandaloneService) (string, error) {
521 }523 }
522 524
523 func (s *Service) waitLogReadiness(ctx context.Context, containerID string, svc domain.StandaloneService) error {525 func (s *Service) waitLogReadiness(ctx context.Context, containerID string, svc domain.StandaloneService) error {
524- ticker := time.NewTicker(10 * time.Millisecond)526+ ticker := time.NewTicker(250 * time.Millisecond)
525 defer ticker.Stop()527 defer ticker.Stop()
526 var lastErr error528 var lastErr error
527 for {529 for {
@@ -532,6 +534,9 @@ func (s *Service) waitLogReadiness(ctx context.Context, containerID string, svc
532 if err == nil && found {534 if err == nil && found {
533 return nil535 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 = err541 lastErr = err
537 }542 }
@@ -551,17 +556,49 @@ func logReadinessTimeoutError(serviceName string, timeoutErr, lastErr error) err
551 return fmt.Errorf("standalone service %q log readiness timed out: %w", serviceName, timeoutErr)556 return fmt.Errorf("standalone service %q log readiness timed out: %w", serviceName, timeoutErr)
552 }557 }
553 558
559+const maxReadinessLogSize = 1 << 20 // 1 MiB
560+
554 func (s *Service) logContains(ctx context.Context, containerID, path, contains string) (bool, error) {561 func (s *Service) logContains(ctx context.Context, containerID, path, contains string) (bool, error) {
555 reader, err := s.runtime.CopyFromContainer(ctx, containerID, path)562 reader, err := s.runtime.CopyFromContainer(ctx, containerID, path)
556 if err != nil {563 if err != nil {
557 return false, err564 return false, err
558 }565 }
559 defer reader.Close()566 defer reader.Close()
560- content, err := io.ReadAll(reader)567+ if contains == "" {
561- if err != nil {568+ return true, nil
569+ }
570+
571+ needle := []byte(contains)
572+ buffer := make([]byte, 32*1024)
573+ carry := make([]byte, 0, len(needle)-1)
574+ remaining := maxReadinessLogSize
575+ for remaining > 0 {
576+ readSize := min(len(buffer), remaining)
577+ n, readErr := reader.Read(buffer[:readSize])
578+ if n > 0 {
579+ window := append(carry, buffer[:n]...)
580+ if bytes.Contains(window, needle) {
581+ return true, nil
582+ }
583+ overlap := min(len(needle)-1, len(window))
584+ carry = append(carry[:0], window[len(window)-overlap:]...)
585+ remaining -= n
586+ }
587+ if errors.Is(readErr, io.EOF) {
588+ return false, nil
589+ }
590+ if readErr != nil {
591+ return false, readErr
592+ }
593+ }
594+
595+ var extra [1]byte
596+ if _, err := reader.Read(extra[:]); errors.Is(err, io.EOF) {
597+ return false, nil
598+ } else if err != nil {
562 return false, err599 return false, err
563 }600 }
564- return strings.Contains(string(content), contains), nil601+ return false, fmt.Errorf("%w: limit is %d bytes", domain.ErrReadinessLogSizeExceeded, maxReadinessLogSize)
565 }602 }
566 603
567 func normalizeCleanup(cleanup domain.StandaloneServiceCleanup) domain.StandaloneServiceCleanup {604 func normalizeCleanup(cleanup domain.StandaloneServiceCleanup) domain.StandaloneServiceCleanup {
M internal/usecase/services/service_test.go +26 -0
@@ -387,6 +387,32 @@ func TestWaitLogReadinessReadsContainerFileUntilTextAppears(t *testing.T) {
387387 require.NoError(t, err)
388388 }
389389
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+
390416 func TestWaitReadinessHonorsContextTimeout(t *testing.T) {
391417 svc := sampleService()
392418 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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close