detached 271c36a

fix(security): remediate codex scan findings

commit
271c36a4cc32fabd91b20f93cd006bdd2a01b181
author
brice · · 2026-08-10 20:50 UTC
parent
58a92f3
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
31 files · +638 -217
M .github/actions/deploy/README.md +1 -13
@@ -64,9 +64,6 @@ jobs:
6464 image: myapp # Custom image name
6565 dockerfile: ./docker/Dockerfile # Custom Dockerfile path
6666 context: . # Build context
67- build-args: | # Build arguments
68- NODE_ENV=production
69- API_URL=https://api.example.com
7067 platforms: linux/amd64,linux/arm64 # Multi-platform
7168 push-latest: 'true' # Also push :latest
7269
@@ -88,7 +85,6 @@ jobs:
8885 | `tag` | Override image tag | No | Git tag or short SHA |
8986 | `dockerfile` | Path to Dockerfile | No | `./Dockerfile` |
9087 | `context` | Build context path | No | `.` |
91-| `build-args` | Build arguments (one per line) | No | - |
9288 | `platforms` | Target platforms | No | - |
9389 | `push-latest` | Also push with `:latest` tag | No | `true` |
9490 | `cache-from` | Cache source | No | `type=gha` |
@@ -204,15 +200,7 @@ jobs:
204200
205201 ### Build with Secrets (BuildKit)
206202
207-```yaml
208-- uses: bnema/gordon/.github/actions/deploy@main
209- with:
210- registry: registry.mydomain.com
211- username: ${{ secrets.GORDON_USERNAME }}
212- password: ${{ secrets.GORDON_TOKEN }}
213- build-args: |
214- NPM_TOKEN=${{ secrets.NPM_TOKEN }}
215-```
203+The deploy action intentionally does not accept build arguments because CI secrets passed as build arguments can remain in image metadata or layers. For secret-bearing builds, use `docker/build-push-action` with its BuildKit `secrets` input, reference the secret with `RUN --mount=type=secret` in the Dockerfile, and pin every action to a reviewed commit SHA.
216204
217205 ## Troubleshooting
218206
@@ -64,9 +64,6 @@ jobs:
64 image: myapp # Custom image name64 image: myapp # Custom image name
65 dockerfile: ./docker/Dockerfile # Custom Dockerfile path65 dockerfile: ./docker/Dockerfile # Custom Dockerfile path
66 context: . # Build context66 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-platform67 platforms: linux/amd64,linux/arm64 # Multi-platform
71 push-latest: 'true' # Also push :latest68 push-latest: 'true' # Also push :latest
72 69
@@ -88,7 +85,6 @@ jobs:
88 | `tag` | Override image tag | No | Git tag or short SHA |85 | `tag` | Override image tag | No | Git tag or short SHA |
89 | `dockerfile` | Path to Dockerfile | No | `./Dockerfile` |86 | `dockerfile` | Path to Dockerfile | No | `./Dockerfile` |
90 | `context` | Build context path | No | `.` |87 | `context` | Build context path | No | `.` |
91-| `build-args` | Build arguments (one per line) | No | - |
92 | `platforms` | Target platforms | No | - |88 | `platforms` | Target platforms | No | - |
93 | `push-latest` | Also push with `:latest` tag | No | `true` |89 | `push-latest` | Also push with `:latest` tag | No | `true` |
94 | `cache-from` | Cache source | No | `type=gha` |90 | `cache-from` | Cache source | No | `type=gha` |
@@ -204,15 +200,7 @@ jobs:
204 200
205 ### Build with Secrets (BuildKit)201 ### Build with Secrets (BuildKit)
206 202
207-```yaml203+The deploy action intentionally does not accept build arguments because CI secrets passed as build arguments can remain in image metadata or layers. For secret-bearing builds, use `docker/build-push-action` with its BuildKit `secrets` input, reference the secret with `RUN --mount=type=secret` in the Dockerfile, and pin every action to a reviewed commit SHA.
208-- uses: bnema/gordon/.github/actions/deploy@main
209- with:
210- registry: registry.mydomain.com
211- username: ${{ secrets.GORDON_USERNAME }}
212- password: ${{ secrets.GORDON_TOKEN }}
213- build-args: |
214- NPM_TOKEN=${{ secrets.NPM_TOKEN }}
215-```
216 204
217 ## Troubleshooting205 ## Troubleshooting
218 206
M .github/actions/deploy/action.yml +34 -26
@@ -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,25 +65,31 @@ 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: |
8284 # Determine image name
83- IMAGE_NAME="${{ inputs.image }}"
85+ IMAGE_NAME="${INPUT_IMAGE}"
8486 if [[ -z "$IMAGE_NAME" ]]; then
8587 IMAGE_NAME="${GITHUB_REPOSITORY#*/}"
8688 fi
8789
8890 # Extract tag from ref (or override)
89- if [[ -n "${{ inputs.tag }}" ]]; then
90- TAG="${{ inputs.tag }}"
91+ if [[ -n "${INPUT_TAG}" ]]; then
92+ TAG="${INPUT_TAG}"
9193 elif [[ "$GITHUB_REF" == refs/tags/* ]]; then
9294 TAG="${GITHUB_REF#refs/tags/}"
9395 else
@@ -95,23 +97,21 @@ runs:
9597 fi
9698
9799 # Full image reference
98- FULL_IMAGE="${{ inputs.registry }}/${IMAGE_NAME}:${TAG}"
100+ FULL_IMAGE="${INPUT_REGISTRY}/${IMAGE_NAME}:${TAG}"
99101
100- echo "image=${FULL_IMAGE}" >> $GITHUB_OUTPUT
101- echo "image_name=${IMAGE_NAME}" >> $GITHUB_OUTPUT
102- echo "tag=${TAG}" >> $GITHUB_OUTPUT
103- echo "registry=${{ inputs.registry }}" >> $GITHUB_OUTPUT
102+ printf 'image=%s\nimage_name=%s\ntag=%s\nregistry=%s\n' \
103+ "${FULL_IMAGE}" "${IMAGE_NAME}" "${TAG}" "${INPUT_REGISTRY}" >> "${GITHUB_OUTPUT}"
104104
105105 echo "Building: ${FULL_IMAGE}"
106106
107107 - name: Set up QEMU
108- uses: docker/setup-qemu-action@v3
108+ uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3
109109
110110 - name: Set up Docker Buildx
111- uses: docker/setup-buildx-action@v3
111+ uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
112112
113113 - name: Log in to Gordon registry
114- uses: docker/login-action@v3
114+ uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
115115 with:
116116 registry: ${{ inputs.registry }}
117117 username: ${{ inputs.username }}
@@ -120,16 +120,21 @@ runs:
120120 - name: Build tags
121121 id: tags
122122 shell: bash
123+ env:
124+ META_IMAGE: ${{ steps.meta.outputs.image }}
125+ META_IMAGE_NAME: ${{ steps.meta.outputs.image_name }}
126+ INPUT_REGISTRY: ${{ inputs.registry }}
127+ INPUT_PUSH_LATEST: ${{ inputs.push-latest }}
123128 run: |
124- TAGS="${{ steps.meta.outputs.image }}"
125- if [[ "${{ inputs.push-latest }}" == "true" ]]; then
126- TAGS="${TAGS}"$'\n'"${{ inputs.registry }}/${{ steps.meta.outputs.image_name }}:latest"
129+ TAGS="${META_IMAGE}"
130+ if [[ "${INPUT_PUSH_LATEST}" == "true" ]]; then
131+ TAGS="${TAGS}"$'\n'"${INPUT_REGISTRY}/${META_IMAGE_NAME}:latest"
127132 fi
128133 printf "tags<<EOF\n%s\nEOF\n" "$TAGS" >> "$GITHUB_OUTPUT"
129134
130135 - name: Build and push
131136 id: push
132- uses: docker/build-push-action@v6
137+ uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6
133138 env:
134139 DOCKER_BUILD_SUMMARY: false
135140 with:
@@ -137,7 +142,6 @@ runs:
137142 file: ${{ inputs.dockerfile }}
138143 push: true
139144 tags: ${{ steps.tags.outputs.tags }}
140- build-args: ${{ inputs.build-args }}
141145 platforms: ${{ inputs.platforms }}
142146 cache-from: ${{ inputs.cache-from }}
143147 cache-to: ${{ inputs.cache-to }}
@@ -145,13 +149,17 @@ runs:
145149
146150 - name: Summary
147151 shell: bash
152+ env:
153+ META_IMAGE: ${{ steps.meta.outputs.image }}
154+ META_TAG: ${{ steps.meta.outputs.tag }}
155+ PUSH_DIGEST: ${{ steps.push.outputs.digest }}
148156 run: |
149- echo "## Gordon Deploy Summary" >> $GITHUB_STEP_SUMMARY
157+ echo "## Gordon Deploy Summary" >> "$GITHUB_STEP_SUMMARY"
150158 echo "" >> $GITHUB_STEP_SUMMARY
151159 echo "| Field | Value |" >> $GITHUB_STEP_SUMMARY
152160 echo "|-------|-------|" >> $GITHUB_STEP_SUMMARY
153- echo "| Image | \`${{ steps.meta.outputs.image }}\` |" >> $GITHUB_STEP_SUMMARY
154- echo "| Tag | \`${{ steps.meta.outputs.tag }}\` |" >> $GITHUB_STEP_SUMMARY
155- echo "| Digest | \`${{ steps.push.outputs.digest }}\` |" >> $GITHUB_STEP_SUMMARY
161+ printf '| Image | `%s` |\n' "$META_IMAGE" >> "$GITHUB_STEP_SUMMARY"
162+ printf '| Tag | `%s` |\n' "$META_TAG" >> "$GITHUB_STEP_SUMMARY"
163+ printf '| Digest | `%s` |\n' "$PUSH_DIGEST" >> "$GITHUB_STEP_SUMMARY"
156164 echo "" >> $GITHUB_STEP_SUMMARY
157165 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,25 +65,31 @@ 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 # Determine image name
83- IMAGE_NAME="${{ inputs.image }}"85+ IMAGE_NAME="${INPUT_IMAGE}"
84 if [[ -z "$IMAGE_NAME" ]]; then86 if [[ -z "$IMAGE_NAME" ]]; then
85 IMAGE_NAME="${GITHUB_REPOSITORY#*/}"87 IMAGE_NAME="${GITHUB_REPOSITORY#*/}"
86 fi88 fi
87 89
88 # Extract tag from ref (or override)90 # Extract tag from ref (or override)
89- if [[ -n "${{ inputs.tag }}" ]]; then91+ if [[ -n "${INPUT_TAG}" ]]; then
90- TAG="${{ inputs.tag }}"92+ TAG="${INPUT_TAG}"
91 elif [[ "$GITHUB_REF" == refs/tags/* ]]; then93 elif [[ "$GITHUB_REF" == refs/tags/* ]]; then
92 TAG="${GITHUB_REF#refs/tags/}"94 TAG="${GITHUB_REF#refs/tags/}"
93 else95 else
@@ -95,23 +97,21 @@ runs:
95 fi97 fi
96 98
97 # Full image reference99 # Full image reference
98- FULL_IMAGE="${{ inputs.registry }}/${IMAGE_NAME}:${TAG}"100+ FULL_IMAGE="${INPUT_REGISTRY}/${IMAGE_NAME}:${TAG}"
99 101
100- echo "image=${FULL_IMAGE}" >> $GITHUB_OUTPUT102+ printf 'image=%s\nimage_name=%s\ntag=%s\nregistry=%s\n' \
101- echo "image_name=${IMAGE_NAME}" >> $GITHUB_OUTPUT103+ "${FULL_IMAGE}" "${IMAGE_NAME}" "${TAG}" "${INPUT_REGISTRY}" >> "${GITHUB_OUTPUT}"
102- echo "tag=${TAG}" >> $GITHUB_OUTPUT
103- echo "registry=${{ inputs.registry }}" >> $GITHUB_OUTPUT
104 104
105 echo "Building: ${FULL_IMAGE}"105 echo "Building: ${FULL_IMAGE}"
106 106
107 - name: Set up QEMU107 - name: Set up QEMU
108- uses: docker/setup-qemu-action@v3108+ uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3
109 109
110 - name: Set up Docker Buildx110 - name: Set up Docker Buildx
111- uses: docker/setup-buildx-action@v3111+ uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
112 112
113 - name: Log in to Gordon registry113 - name: Log in to Gordon registry
114- uses: docker/login-action@v3114+ uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
115 with:115 with:
116 registry: ${{ inputs.registry }}116 registry: ${{ inputs.registry }}
117 username: ${{ inputs.username }}117 username: ${{ inputs.username }}
@@ -120,16 +120,21 @@ runs:
120 - name: Build tags120 - name: Build tags
121 id: tags121 id: tags
122 shell: bash122 shell: bash
123+ env:
124+ META_IMAGE: ${{ steps.meta.outputs.image }}
125+ META_IMAGE_NAME: ${{ steps.meta.outputs.image_name }}
126+ INPUT_REGISTRY: ${{ inputs.registry }}
127+ INPUT_PUSH_LATEST: ${{ inputs.push-latest }}
123 run: |128 run: |
124- TAGS="${{ steps.meta.outputs.image }}"129+ TAGS="${META_IMAGE}"
125- if [[ "${{ inputs.push-latest }}" == "true" ]]; then130+ if [[ "${INPUT_PUSH_LATEST}" == "true" ]]; then
126- TAGS="${TAGS}"$'\n'"${{ inputs.registry }}/${{ steps.meta.outputs.image_name }}:latest"131+ TAGS="${TAGS}"$'\n'"${INPUT_REGISTRY}/${META_IMAGE_NAME}:latest"
127 fi132 fi
128 printf "tags<<EOF\n%s\nEOF\n" "$TAGS" >> "$GITHUB_OUTPUT"133 printf "tags<<EOF\n%s\nEOF\n" "$TAGS" >> "$GITHUB_OUTPUT"
129 134
130 - name: Build and push135 - name: Build and push
131 id: push136 id: push
132- uses: docker/build-push-action@v6137+ uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6
133 env:138 env:
134 DOCKER_BUILD_SUMMARY: false139 DOCKER_BUILD_SUMMARY: false
135 with:140 with:
@@ -137,7 +142,6 @@ runs:
137 file: ${{ inputs.dockerfile }}142 file: ${{ inputs.dockerfile }}
138 push: true143 push: true
139 tags: ${{ steps.tags.outputs.tags }}144 tags: ${{ steps.tags.outputs.tags }}
140- build-args: ${{ inputs.build-args }}
141 platforms: ${{ inputs.platforms }}145 platforms: ${{ inputs.platforms }}
142 cache-from: ${{ inputs.cache-from }}146 cache-from: ${{ inputs.cache-from }}
143 cache-to: ${{ inputs.cache-to }}147 cache-to: ${{ inputs.cache-to }}
@@ -145,13 +149,17 @@ runs:
145 149
146 - name: Summary150 - name: Summary
147 shell: bash151 shell: bash
152+ env:
153+ META_IMAGE: ${{ steps.meta.outputs.image }}
154+ META_TAG: ${{ steps.meta.outputs.tag }}
155+ PUSH_DIGEST: ${{ steps.push.outputs.digest }}
148 run: |156 run: |
149- echo "## Gordon Deploy Summary" >> $GITHUB_STEP_SUMMARY157+ echo "## Gordon Deploy Summary" >> "$GITHUB_STEP_SUMMARY"
150 echo "" >> $GITHUB_STEP_SUMMARY158 echo "" >> $GITHUB_STEP_SUMMARY
151 echo "| Field | Value |" >> $GITHUB_STEP_SUMMARY159 echo "| Field | Value |" >> $GITHUB_STEP_SUMMARY
152 echo "|-------|-------|" >> $GITHUB_STEP_SUMMARY160 echo "|-------|-------|" >> $GITHUB_STEP_SUMMARY
153- echo "| Image | \`${{ steps.meta.outputs.image }}\` |" >> $GITHUB_STEP_SUMMARY161+ printf '| Image | `%s` |\n' "$META_IMAGE" >> "$GITHUB_STEP_SUMMARY"
154- echo "| Tag | \`${{ steps.meta.outputs.tag }}\` |" >> $GITHUB_STEP_SUMMARY162+ printf '| Tag | `%s` |\n' "$META_TAG" >> "$GITHUB_STEP_SUMMARY"
155- echo "| Digest | \`${{ steps.push.outputs.digest }}\` |" >> $GITHUB_STEP_SUMMARY163+ printf '| Digest | `%s` |\n' "$PUSH_DIGEST" >> "$GITHUB_STEP_SUMMARY"
156 echo "" >> $GITHUB_STEP_SUMMARY164 echo "" >> $GITHUB_STEP_SUMMARY
157 echo "Container deployed to Gordon registry and will be automatically deployed." >> $GITHUB_STEP_SUMMARY165 echo "Container deployed to Gordon registry and will be automatically deployed." >> $GITHUB_STEP_SUMMARY
M .github/workflows/release.yml +3 -3
@@ -13,16 +13,16 @@ jobs:
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
1919
20- - uses: actions/setup-go@v5
20+ - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
2121 with:
2222 go-version: stable
2323
2424 - name: Run GoReleaser
25- uses: goreleaser/goreleaser-action@v6
25+ uses: goreleaser/goreleaser-action@e435ccd777264be153ace6237001ef4d979d3a7a # v6
2626 with:
2727 distribution: goreleaser
2828 version: "~> v2"
@@ -13,16 +13,16 @@ 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 19
20- - uses: actions/setup-go@v520+ - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
21 with:21 with:
22 go-version: stable22 go-version: stable
23 23
24 - name: Run GoReleaser24 - name: Run GoReleaser
25- uses: goreleaser/goreleaser-action@v625+ uses: goreleaser/goreleaser-action@e435ccd777264be153ace6237001ef4d979d3a7a # v6
26 with:26 with:
27 distribution: goreleaser27 distribution: goreleaser
28 version: "~> v2"28 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 +16 -9
@@ -147,21 +147,28 @@ 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+ docker pull "$GORDON_REGISTRY/myapp:$VERSION"
163+ docker tag "$GORDON_REGISTRY/myapp:$VERSION" "$GORDON_REGISTRY/myapp:latest"
164+ docker push "$GORDON_REGISTRY/myapp:latest"
160165
161166 - name: Summary
167+ env:
168+ VERSION: ${{ inputs.version }}
162169 run: |
163- echo "## Rollback Complete" >> $GITHUB_STEP_SUMMARY
164- echo "Rolled back to version: ${{ github.event.inputs.version }}" >> $GITHUB_STEP_SUMMARY
170+ echo "## Rollback Complete" >> "$GITHUB_STEP_SUMMARY"
171+ printf 'Rolled back to version: %s\n' "$VERSION" >> "$GITHUB_STEP_SUMMARY"
165172 ```
166173
167174 ### Rollback Script
@@ -147,21 +147,28 @@ 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+ docker pull "$GORDON_REGISTRY/myapp:$VERSION"
158- ${{ secrets.GORDON_REGISTRY }}/myapp:latest163+ docker tag "$GORDON_REGISTRY/myapp:$VERSION" "$GORDON_REGISTRY/myapp:latest"
159- docker push ${{ secrets.GORDON_REGISTRY }}/myapp:latest164+ docker push "$GORDON_REGISTRY/myapp:latest"
160 165
161 - name: Summary166 - name: Summary
167+ env:
168+ VERSION: ${{ inputs.version }}
162 run: |169 run: |
163- echo "## Rollback Complete" >> $GITHUB_STEP_SUMMARY170+ echo "## Rollback Complete" >> "$GITHUB_STEP_SUMMARY"
164- echo "Rolled back to version: ${{ github.event.inputs.version }}" >> $GITHUB_STEP_SUMMARY171+ printf 'Rolled back to version: %s\n' "$VERSION" >> "$GITHUB_STEP_SUMMARY"
165 ```172 ```
166 173
167 ### Rollback Script174 ### Rollback Script
M docs/installation.md +14 -4
@@ -15,13 +15,22 @@ 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+ less "$installer"
24+ bash "$installer"
25+)
1926 ```
2027
21-This script automatically detects your OS (Linux/macOS) and architecture (amd64/arm64), downloads the appropriate binary from GitHub releases, and installs it to `/usr/local/bin`.
28+Download and inspect the installer before executing it. The installer verifies the release archive checksum before installation and automatically detects your OS (Linux/macOS) and architecture (amd64/arm64), downloads the appropriate binary from GitHub releases, and installs it to `/usr/local/bin`.
2229
2330 ### Manual Installation
2431
32+Manual archive installation requires downloading the matching `checksums.txt` release asset and verifying the archive's SHA-256 checksum before extraction. The checksum-verifying installer above is recommended.
33+
2534 **Linux (x86_64)**
2635 ```bash
2736 wget https://github.com/bnema/gordon/releases/latest/download/gordon_linux_amd64.tar.gz
@@ -74,8 +83,9 @@ sudo mv gordon /usr/local/bin/
7483 ### Docker
7584
7685 ```bash
77-# Install Docker
78-curl -fsSL https://get.docker.com | sh
86+# Install Docker from your distribution's signed package repository.
87+sudo apt update
88+sudo apt install docker.io
7989
8090 # Add user to docker group
8191 sudo usermod -aG docker $USER
@@ -15,13 +15,22 @@ Detailed installation guide for production environments.
15 ### Quick Install (Recommended)15 ### Quick Install (Recommended)
16 16
17 ```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+ less "$installer"
24+ bash "$installer"
25+)
19 ```26 ```
20 27
21-This script automatically detects your OS (Linux/macOS) and architecture (amd64/arm64), downloads the appropriate binary from GitHub releases, and installs it to `/usr/local/bin`.28+Download and inspect the installer before executing it. The installer verifies the release archive checksum before installation and automatically detects your OS (Linux/macOS) and architecture (amd64/arm64), downloads the appropriate binary from GitHub releases, and installs it to `/usr/local/bin`.
22 29
23 ### Manual Installation30 ### Manual Installation
24 31
32+Manual archive installation requires downloading the matching `checksums.txt` release asset and verifying the archive's SHA-256 checksum before extraction. The checksum-verifying installer above is recommended.
33+
25 **Linux (x86_64)**34 **Linux (x86_64)**
26 ```bash35 ```bash
27 wget https://github.com/bnema/gordon/releases/latest/download/gordon_linux_amd64.tar.gz36 wget https://github.com/bnema/gordon/releases/latest/download/gordon_linux_amd64.tar.gz
@@ -74,8 +83,9 @@ sudo mv gordon /usr/local/bin/
74 ### Docker83 ### Docker
75 84
76 ```bash85 ```bash
77-# Install Docker86+# Install Docker from your distribution's signed package repository.
78-curl -fsSL https://get.docker.com | sh87+sudo apt update
88+sudo apt install docker.io
79 89
80 # Add user to docker group90 # Add user to docker group
81 sudo usermod -aG docker $USER91 sudo usermod -aG docker $USER
M internal/adapters/in/cli/auth.go +12 -8
@@ -206,31 +206,35 @@ 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
210210
211211 cmd := &cobra.Command{
212212 Use: "login",
213213 Short: "Authenticate with a Gordon server",
214214 Long: `Store or verify a token for a Gordon server remote.
215215
216-With --token: stores the token and verifies it.
217-Without --token: verifies the existing stored token still works.
216+With --token-file: reads, stores, and verifies a token without exposing it in argv.
217+Without --token-file: verifies the existing stored token still works.
218218
219219 Generate a token on the server with: gordon auth token generate
220220
221221 Examples:
222- gordon auth login --token <token> Store token for active remote
223- gordon auth login --remote prod --token <tok> Store token for specific remote
222+ gordon auth login --token-file ./token Store token for active remote
223+ gordon auth login --remote prod --token-file ./token
224224 gordon auth login Verify existing token`,
225225 RunE: func(cmd *cobra.Command, args []string) error {
226- if token != "" {
226+ if tokenFile != "" {
227+ token, err := readProtectedSecretFile(tokenFile)
228+ if err != nil {
229+ return fmt.Errorf("read token: %w", err)
230+ }
227231 return runAuthLoginWithToken(cmd.Context(), token, cmd.OutOrStdout())
228232 }
229233 return runAuthLoginVerify(cmd.Context(), cmd.OutOrStdout())
230234 },
231235 }
232236
233- cmd.Flags().StringVarP(&token, "token", "t", "", "Authentication token to store for the remote")
237+ cmd.Flags().StringVar(&tokenFile, "token-file", "", "Read authentication token from a mode 0600 file")
234238
235239 return cmd
236240 }
@@ -291,7 +295,7 @@ func runAuthLoginVerify(ctx context.Context, out io.Writer) error {
291295 return err
292296 }
293297 if resolved.Token == "" {
294- return fmt.Errorf("no token stored for remote '%s'; use: gordon auth login --token <token>", resolved.DisplayName())
298+ return fmt.Errorf("no token stored for remote '%s'; use: gordon auth login --token-file <path>", resolved.DisplayName())
295299 }
296300
297301 client := remote.NewClient(resolved.URL, remoteClientOptions(resolved.Token, resolved.InsecureTLS)...)
@@ -206,31 +206,35 @@ available while Gordon is running.`,
206 206
207 // newAuthLoginCmd creates the login command for remote authentication.207 // newAuthLoginCmd creates the login command for remote authentication.
208 func newAuthLoginCmd() *cobra.Command {208 func newAuthLoginCmd() *cobra.Command {
209- var token string209+ var tokenFile string
210 210
211 cmd := &cobra.Command{211 cmd := &cobra.Command{
212 Use: "login",212 Use: "login",
213 Short: "Authenticate with a Gordon server",213 Short: "Authenticate with a Gordon server",
214 Long: `Store or verify a token for a Gordon server remote.214 Long: `Store or verify a token for a Gordon server remote.
215 215
216-With --token: stores the token and verifies it.216+With --token-file: reads, stores, and verifies a token without exposing it in argv.
217-Without --token: verifies the existing stored token still works.217+Without --token-file: verifies the existing stored token still works.
218 218
219 Generate a token on the server with: gordon auth token generate219 Generate a token on the server with: gordon auth token generate
220 220
221 Examples:221 Examples:
222- gordon auth login --token <token> Store token for active remote222+ gordon auth login --token-file ./token Store token for active remote
223- gordon auth login --remote prod --token <tok> Store token for specific remote223+ gordon auth login --remote prod --token-file ./token
224 gordon auth login Verify existing token`,224 gordon auth login Verify existing token`,
225 RunE: func(cmd *cobra.Command, args []string) error {225 RunE: func(cmd *cobra.Command, args []string) error {
226- if token != "" {226+ if tokenFile != "" {
227+ token, err := readProtectedSecretFile(tokenFile)
228+ if err != nil {
229+ return fmt.Errorf("read token: %w", err)
230+ }
227 return runAuthLoginWithToken(cmd.Context(), token, cmd.OutOrStdout())231 return runAuthLoginWithToken(cmd.Context(), token, cmd.OutOrStdout())
228 }232 }
229 return runAuthLoginVerify(cmd.Context(), cmd.OutOrStdout())233 return runAuthLoginVerify(cmd.Context(), cmd.OutOrStdout())
230 },234 },
231 }235 }
232 236
233- cmd.Flags().StringVarP(&token, "token", "t", "", "Authentication token to store for the remote")237+ cmd.Flags().StringVar(&tokenFile, "token-file", "", "Read authentication token from a mode 0600 file")
234 238
235 return cmd239 return cmd
236 }240 }
@@ -291,7 +295,7 @@ func runAuthLoginVerify(ctx context.Context, out io.Writer) error {
291 return err295 return err
292 }296 }
293 if resolved.Token == "" {297 if resolved.Token == "" {
294- return fmt.Errorf("no token stored for remote '%s'; use: gordon auth login --token <token>", resolved.DisplayName())298+ return fmt.Errorf("no token stored for remote '%s'; use: gordon auth login --token-file <path>", resolved.DisplayName())
295 }299 }
296 300
297 client := remote.NewClient(resolved.URL, remoteClientOptions(resolved.Token, resolved.InsecureTLS)...)301 client := remote.NewClient(resolved.URL, remoteClientOptions(resolved.Token, resolved.InsecureTLS)...)
M internal/adapters/in/cli/remotes.go +25 -13
@@ -154,7 +154,7 @@ func remotesListJSON(out io.Writer, remotes map[string]remote.RemoteEntry, names
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
160160
@@ -165,7 +165,7 @@ func newRemotesAddCmd() *cobra.Command {
165165
166166 Examples:
167167 gordon remotes add prod https://gordon.mydomain.com
168- gordon remotes add prod https://gordon.mydomain.com --token eyJ...
168+ gordon remotes add prod https://gordon.mydomain.com --token-file ./token
169169 gordon remotes add staging https://staging.mydomain.com --token-env STAGING_TOKEN
170170 gordon remotes add dev https://dev.internal --insecure`,
171171 Args: cobra.ExactArgs(2),
@@ -173,8 +173,14 @@ Examples:
173173 name := args[0]
174174 url := normalizeURL(args[1])
175175
176- // If token-env is provided, use that instead of token
177- finalToken := token
176+ var finalToken string
177+ if tokenFile != "" {
178+ var err error
179+ finalToken, err = readProtectedSecretFile(tokenFile)
180+ if err != nil {
181+ return fmt.Errorf("read token: %w", err)
182+ }
183+ }
178184 if tokenEnv != "" {
179185 // Store token_env reference instead of actual token
180186 if err := addRemoteWithEnv(name, url, tokenEnv, insecureTLS); err != nil {
@@ -188,15 +194,15 @@ Examples:
188194
189195 fmt.Println(styles.RenderSuccess(fmt.Sprintf("Remote added: %s -> %s", name, url)))
190196
191- if token == "" && tokenEnv == "" {
192- fmt.Println(styles.Theme.Muted.Render("Tip: Add a token with --token or --token-env for authenticated access"))
197+ if tokenFile == "" && tokenEnv == "" {
198+ fmt.Println(styles.Theme.Muted.Render("Tip: Add a token with --token-file or --token-env for authenticated access"))
193199 }
194200
195201 return nil
196202 },
197203 }
198204
199- cmd.Flags().StringVar(&token, "token", "", "Authentication token")
205+ cmd.Flags().StringVar(&tokenFile, "token-file", "", "Read authentication token from a mode 0600 file")
200206 cmd.Flags().StringVar(&tokenEnv, "token-env", "", "Environment variable containing token")
201207 cmd.Flags().BoolVar(&insecureTLS, "insecure", false, "Skip TLS certificate verification")
202208
@@ -313,8 +319,9 @@ Examples:
313319
314320 // newRemotesSetTokenCmd creates the remotes set-token command.
315321 func newRemotesSetTokenCmd() *cobra.Command {
316- return &cobra.Command{
317- Use: "set-token <name> <token>",
322+ var tokenFile string
323+ cmd := &cobra.Command{
324+ Use: "set-token <name> --token-file <path>",
318325 Short: "Set the token for a remote",
319326 Long: `Set or update the authentication token for a saved remote.
320327
@@ -326,12 +333,14 @@ This is useful when:
326333 For servers with password authentication, use 'gordon auth login' instead.
327334
328335 Examples:
329- gordon remotes set-token prod eyJhbGciOiJIUzI1NiIs...
330- gordon remotes set-token staging $(cat token.txt)`,
331- Args: cobra.ExactArgs(2),
336+ gordon remotes set-token prod --token-file ./token`,
337+ Args: cobra.ExactArgs(1),
332338 RunE: func(cmd *cobra.Command, args []string) error {
333339 name := args[0]
334- token := args[1]
340+ token, err := readProtectedSecretFile(tokenFile)
341+ if err != nil {
342+ return fmt.Errorf("read token: %w", err)
343+ }
335344
336345 // Verify remote exists
337346 remotes, _, err := remote.ListRemotes()
@@ -352,6 +361,9 @@ Examples:
352361 return nil
353362 },
354363 }
364+ cmd.Flags().StringVar(&tokenFile, "token-file", "", "Read authentication token from a mode 0600 file")
365+ _ = cmd.MarkFlagRequired("token-file")
366+ return cmd
355367 }
356368
357369 // normalizeURL ensures the URL has a protocol scheme, defaulting to https.
@@ -154,7 +154,7 @@ func remotesListJSON(out io.Writer, remotes map[string]remote.RemoteEntry, names
154 154
155 // newRemotesAddCmd creates the remotes add command.155 // newRemotesAddCmd creates the remotes add command.
156 func newRemotesAddCmd() *cobra.Command {156 func newRemotesAddCmd() *cobra.Command {
157- var token string157+ var tokenFile string
158 var tokenEnv string158 var tokenEnv string
159 var insecureTLS bool159 var insecureTLS bool
160 160
@@ -165,7 +165,7 @@ func newRemotesAddCmd() *cobra.Command {
165 165
166 Examples:166 Examples:
167 gordon remotes add prod https://gordon.mydomain.com167 gordon remotes add prod https://gordon.mydomain.com
168- gordon remotes add prod https://gordon.mydomain.com --token eyJ...168+ gordon remotes add prod https://gordon.mydomain.com --token-file ./token
169 gordon remotes add staging https://staging.mydomain.com --token-env STAGING_TOKEN169 gordon remotes add staging https://staging.mydomain.com --token-env STAGING_TOKEN
170 gordon remotes add dev https://dev.internal --insecure`,170 gordon remotes add dev https://dev.internal --insecure`,
171 Args: cobra.ExactArgs(2),171 Args: cobra.ExactArgs(2),
@@ -173,8 +173,14 @@ Examples:
173 name := args[0]173 name := args[0]
174 url := normalizeURL(args[1])174 url := normalizeURL(args[1])
175 175
176- // If token-env is provided, use that instead of token176+ var finalToken string
177- finalToken := token177+ if tokenFile != "" {
178+ var err error
179+ finalToken, err = readProtectedSecretFile(tokenFile)
180+ if err != nil {
181+ return fmt.Errorf("read token: %w", err)
182+ }
183+ }
178 if tokenEnv != "" {184 if tokenEnv != "" {
179 // Store token_env reference instead of actual token185 // Store token_env reference instead of actual token
180 if err := addRemoteWithEnv(name, url, tokenEnv, insecureTLS); err != nil {186 if err := addRemoteWithEnv(name, url, tokenEnv, insecureTLS); err != nil {
@@ -188,15 +194,15 @@ Examples:
188 194
189 fmt.Println(styles.RenderSuccess(fmt.Sprintf("Remote added: %s -> %s", name, url)))195 fmt.Println(styles.RenderSuccess(fmt.Sprintf("Remote added: %s -> %s", name, url)))
190 196
191- if token == "" && tokenEnv == "" {197+ if tokenFile == "" && tokenEnv == "" {
192- fmt.Println(styles.Theme.Muted.Render("Tip: Add a token with --token or --token-env for authenticated access"))198+ fmt.Println(styles.Theme.Muted.Render("Tip: Add a token with --token-file or --token-env for authenticated access"))
193 }199 }
194 200
195 return nil201 return nil
196 },202 },
197 }203 }
198 204
199- cmd.Flags().StringVar(&token, "token", "", "Authentication token")205+ cmd.Flags().StringVar(&tokenFile, "token-file", "", "Read authentication token from a mode 0600 file")
200 cmd.Flags().StringVar(&tokenEnv, "token-env", "", "Environment variable containing token")206 cmd.Flags().StringVar(&tokenEnv, "token-env", "", "Environment variable containing token")
201 cmd.Flags().BoolVar(&insecureTLS, "insecure", false, "Skip TLS certificate verification")207 cmd.Flags().BoolVar(&insecureTLS, "insecure", false, "Skip TLS certificate verification")
202 208
@@ -313,8 +319,9 @@ Examples:
313 319
314 // newRemotesSetTokenCmd creates the remotes set-token command.320 // newRemotesSetTokenCmd creates the remotes set-token command.
315 func newRemotesSetTokenCmd() *cobra.Command {321 func newRemotesSetTokenCmd() *cobra.Command {
316- return &cobra.Command{322+ var tokenFile string
317- Use: "set-token <name> <token>",323+ cmd := &cobra.Command{
324+ Use: "set-token <name> --token-file <path>",
318 Short: "Set the token for a remote",325 Short: "Set the token for a remote",
319 Long: `Set or update the authentication token for a saved remote.326 Long: `Set or update the authentication token for a saved remote.
320 327
@@ -326,12 +333,14 @@ This is useful when:
326 For servers with password authentication, use 'gordon auth login' instead.333 For servers with password authentication, use 'gordon auth login' instead.
327 334
328 Examples:335 Examples:
329- gordon remotes set-token prod eyJhbGciOiJIUzI1NiIs...336+ gordon remotes set-token prod --token-file ./token`,
330- gordon remotes set-token staging $(cat token.txt)`,337+ Args: cobra.ExactArgs(1),
331- Args: cobra.ExactArgs(2),
332 RunE: func(cmd *cobra.Command, args []string) error {338 RunE: func(cmd *cobra.Command, args []string) error {
333 name := args[0]339 name := args[0]
334- token := args[1]340+ token, err := readProtectedSecretFile(tokenFile)
341+ if err != nil {
342+ return fmt.Errorf("read token: %w", err)
343+ }
335 344
336 // Verify remote exists345 // Verify remote exists
337 remotes, _, err := remote.ListRemotes()346 remotes, _, err := remote.ListRemotes()
@@ -352,6 +361,9 @@ Examples:
352 return nil361 return nil
353 },362 },
354 }363 }
364+ cmd.Flags().StringVar(&tokenFile, "token-file", "", "Read authentication token from a mode 0600 file")
365+ _ = cmd.MarkFlagRequired("token-file")
366+ return cmd
355 }367 }
356 368
357 // normalizeURL ensures the URL has a protocol scheme, defaulting to https.369 // normalizeURL ensures the URL has a protocol scheme, defaulting to https.
M internal/adapters/in/cli/root.go +13 -2
@@ -38,7 +38,18 @@ const (
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 +29 -0
@@ -0,0 +1,29 @@
1+package cli
2+
3+import (
4+ "fmt"
5+ "os"
6+ "strings"
7+)
8+
9+func readProtectedSecretFile(path string) (string, error) {
10+ info, err := os.Stat(path)
11+ if err != nil {
12+ return "", err
13+ }
14+ if !info.Mode().IsRegular() {
15+ return "", fmt.Errorf("%s is not a regular file", path)
16+ }
17+ if info.Mode().Perm()&0o077 != 0 {
18+ return "", fmt.Errorf("%s must not be accessible by group or others (use chmod 600)", path)
19+ }
20+ data, err := os.ReadFile(path)
21+ if err != nil {
22+ return "", err
23+ }
24+ value := strings.TrimSpace(string(data))
25+ if value == "" {
26+ return "", fmt.Errorf("%s is empty", path)
27+ }
28+ return value, nil
29+}
@@ -0,0 +1,29 @@
1+package cli
2+
3+import (
4+ "fmt"
5+ "os"
6+ "strings"
7+)
8+
9+func readProtectedSecretFile(path string) (string, error) {
10+ info, err := os.Stat(path)
11+ if err != nil {
12+ return "", err
13+ }
14+ if !info.Mode().IsRegular() {
15+ return "", fmt.Errorf("%s is not a regular file", path)
16+ }
17+ if info.Mode().Perm()&0o077 != 0 {
18+ return "", fmt.Errorf("%s must not be accessible by group or others (use chmod 600)", path)
19+ }
20+ data, err := os.ReadFile(path)
21+ if err != nil {
22+ return "", err
23+ }
24+ value := strings.TrimSpace(string(data))
25+ if value == "" {
26+ return "", fmt.Errorf("%s is empty", path)
27+ }
28+ return value, nil
29+}
M internal/adapters/in/cli/secrets.go +13 -11
@@ -222,32 +222,32 @@ func getKeyPrefix(isLastAttachment, isLastKey bool) string {
222222
223223 // newSecretsSetCmd creates the secrets set command.
224224 func newSecretsSetCmd() *cobra.Command {
225- var attachment string
225+ var attachment, fromFile string
226226
227227 cmd := &cobra.Command{
228- Use: "set <domain> <KEY=value>...",
228+ Use: "set <domain> --from-file <path>",
229229 Short: "Set secrets for a domain or attachment",
230230 Long: `Set one or more secrets for a domain or an attachment container.
231231
232-Secrets are specified as KEY=value pairs. Multiple secrets can be set at once.
232+Secrets are read from a mode 0600 file containing one KEY=value pair per line.
233233
234234 Use --attachment to target an attachment service (e.g., postgres, redis) instead
235235 of the main domain container.
236236
237237 Examples:
238- gordon secrets set app.mydomain.com DATABASE_URL=postgres://localhost/db
239- gordon secrets set app.mydomain.com API_KEY=secret123 DEBUG=false
240- gordon secrets set app.mydomain.com --attachment postgres POSTGRES_PASSWORD=secret
241- gordon secrets set app.mydomain.com --attachment redis REDIS_PASSWORD=secret`,
242- Args: cobra.MinimumNArgs(2),
238+ gordon secrets set app.mydomain.com --from-file ./app.env
239+ gordon secrets set app.mydomain.com --attachment postgres --from-file ./postgres.env`,
240+ Args: cobra.ExactArgs(1),
243241 RunE: func(cmd *cobra.Command, args []string) error {
244242 ctx := cmd.Context()
245243 secretDomain := args[0]
246- pairs := args[1:]
244+ content, err := readProtectedSecretFile(fromFile)
245+ if err != nil {
246+ return fmt.Errorf("read secrets: %w", err)
247+ }
247248
248- // Parse KEY=value pairs
249249 secrets := make(map[string]string)
250- for _, pair := range pairs {
250+ for _, pair := range strings.Split(content, "\n") {
251251 parts := strings.SplitN(pair, "=", 2)
252252 if len(parts) != 2 {
253253 return fmt.Errorf("invalid format: %s (expected KEY=value)", pair)
@@ -288,6 +288,8 @@ Examples:
288288 }
289289
290290 cmd.Flags().StringVarP(&attachment, "attachment", "a", "", "Target an attachment service (e.g., postgres, redis)")
291+ cmd.Flags().StringVar(&fromFile, "from-file", "", "Read KEY=value lines from a mode 0600 file")
292+ _ = cmd.MarkFlagRequired("from-file")
291293
292294 return cmd
293295 }
@@ -222,32 +222,32 @@ func getKeyPrefix(isLastAttachment, isLastKey bool) string {
222 222
223 // newSecretsSetCmd creates the secrets set command.223 // newSecretsSetCmd creates the secrets set command.
224 func newSecretsSetCmd() *cobra.Command {224 func newSecretsSetCmd() *cobra.Command {
225- var attachment string225+ var attachment, fromFile string
226 226
227 cmd := &cobra.Command{227 cmd := &cobra.Command{
228- Use: "set <domain> <KEY=value>...",228+ Use: "set <domain> --from-file <path>",
229 Short: "Set secrets for a domain or attachment",229 Short: "Set secrets for a domain or attachment",
230 Long: `Set one or more secrets for a domain or an attachment container.230 Long: `Set one or more secrets for a domain or an attachment container.
231 231
232-Secrets are specified as KEY=value pairs. Multiple secrets can be set at once.232+Secrets are read from a mode 0600 file containing one KEY=value pair per line.
233 233
234 Use --attachment to target an attachment service (e.g., postgres, redis) instead234 Use --attachment to target an attachment service (e.g., postgres, redis) instead
235 of the main domain container.235 of the main domain container.
236 236
237 Examples:237 Examples:
238- gordon secrets set app.mydomain.com DATABASE_URL=postgres://localhost/db238+ gordon secrets set app.mydomain.com --from-file ./app.env
239- gordon secrets set app.mydomain.com API_KEY=secret123 DEBUG=false239+ gordon secrets set app.mydomain.com --attachment postgres --from-file ./postgres.env`,
240- gordon secrets set app.mydomain.com --attachment postgres POSTGRES_PASSWORD=secret240+ Args: cobra.ExactArgs(1),
241- gordon secrets set app.mydomain.com --attachment redis REDIS_PASSWORD=secret`,
242- Args: cobra.MinimumNArgs(2),
243 RunE: func(cmd *cobra.Command, args []string) error {241 RunE: func(cmd *cobra.Command, args []string) error {
244 ctx := cmd.Context()242 ctx := cmd.Context()
245 secretDomain := args[0]243 secretDomain := args[0]
246- pairs := args[1:]244+ content, err := readProtectedSecretFile(fromFile)
245+ if err != nil {
246+ return fmt.Errorf("read secrets: %w", err)
247+ }
247 248
248- // Parse KEY=value pairs
249 secrets := make(map[string]string)249 secrets := make(map[string]string)
250- for _, pair := range pairs {250+ for _, pair := range strings.Split(content, "\n") {
251 parts := strings.SplitN(pair, "=", 2)251 parts := strings.SplitN(pair, "=", 2)
252 if len(parts) != 2 {252 if len(parts) != 2 {
253 return fmt.Errorf("invalid format: %s (expected KEY=value)", pair)253 return fmt.Errorf("invalid format: %s (expected KEY=value)", pair)
@@ -288,6 +288,8 @@ Examples:
288 }288 }
289 289
290 cmd.Flags().StringVarP(&attachment, "attachment", "a", "", "Target an attachment service (e.g., postgres, redis)")290 cmd.Flags().StringVarP(&attachment, "attachment", "a", "", "Target an attachment service (e.g., postgres, redis)")
291+ cmd.Flags().StringVar(&fromFile, "from-file", "", "Read KEY=value lines from a mode 0600 file")
292+ _ = cmd.MarkFlagRequired("from-file")
291 293
292 return cmd294 return cmd
293 }295 }
M internal/adapters/in/http/registry/handler.go +5 -1
@@ -370,6 +370,10 @@ func (h *Handler) handlePutManifest(w http.ResponseWriter, r *http.Request) {
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 +2 -2
@@ -214,7 +214,7 @@ func TestHandler_GetBlob_Success(t *testing.T) {
214214 _, err = tmpFile.Write(blobContent)
215215 assert.NoError(t, err)
216216
217- registrySvc.EXPECT().GetBlobPath(mock.Anything, "sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4").Return(tmpFile.Name(), nil)
217+ registrySvc.EXPECT().GetBlobPath(mock.Anything, "myapp", "sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4").Return(tmpFile.Name(), nil)
218218
219219 req := httptest.NewRequest("GET", "/v2/myapp/blobs/sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4", nil)
220220 rec := httptest.NewRecorder()
@@ -230,7 +230,7 @@ func TestHandler_GetBlob_NotFound(t *testing.T) {
230230
231231 handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize)
232232
233- registrySvc.EXPECT().GetBlobPath(mock.Anything, "sha256:0000000000000000000000000000000000000000000000000000000000000000").Return("", assert.AnError)
233+ registrySvc.EXPECT().GetBlobPath(mock.Anything, "myapp", "sha256:0000000000000000000000000000000000000000000000000000000000000000").Return("", assert.AnError)
234234
235235 req := httptest.NewRequest("GET", "/v2/myapp/blobs/sha256:0000000000000000000000000000000000000000000000000000000000000000", nil)
236236 rec := httptest.NewRecorder()
@@ -214,7 +214,7 @@ func TestHandler_GetBlob_Success(t *testing.T) {
214 _, err = tmpFile.Write(blobContent)214 _, err = tmpFile.Write(blobContent)
215 assert.NoError(t, err)215 assert.NoError(t, err)
216 216
217- registrySvc.EXPECT().GetBlobPath(mock.Anything, "sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4").Return(tmpFile.Name(), nil)217+ registrySvc.EXPECT().GetBlobPath(mock.Anything, "myapp", "sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4").Return(tmpFile.Name(), nil)
218 218
219 req := httptest.NewRequest("GET", "/v2/myapp/blobs/sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4", nil)219 req := httptest.NewRequest("GET", "/v2/myapp/blobs/sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4", nil)
220 rec := httptest.NewRecorder()220 rec := httptest.NewRecorder()
@@ -230,7 +230,7 @@ func TestHandler_GetBlob_NotFound(t *testing.T) {
230 230
231 handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize)231 handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize)
232 232
233- registrySvc.EXPECT().GetBlobPath(mock.Anything, "sha256:0000000000000000000000000000000000000000000000000000000000000000").Return("", assert.AnError)233+ registrySvc.EXPECT().GetBlobPath(mock.Anything, "myapp", "sha256:0000000000000000000000000000000000000000000000000000000000000000").Return("", assert.AnError)
234 234
235 req := httptest.NewRequest("GET", "/v2/myapp/blobs/sha256:0000000000000000000000000000000000000000000000000000000000000000", nil)235 req := httptest.NewRequest("GET", "/v2/myapp/blobs/sha256:0000000000000000000000000000000000000000000000000000000000000000", nil)
236 rec := httptest.NewRecorder()236 rec := httptest.NewRecorder()
M internal/adapters/out/docker/runtime.go +23 -2
@@ -525,6 +525,7 @@ func (r *Runtime) GetContainerLogs(ctx context.Context, containerID string, foll
525525 ShowStderr: true,
526526 Follow: follow,
527527 Timestamps: true,
528+ Tail: "10000",
528529 })
529530 if err != nil {
530531 return nil, log.WrapErr(err, "failed to get container logs")
@@ -747,6 +748,7 @@ func (r *Runtime) PruneImages(ctx context.Context, danglingOnly bool) (runtimepk
747748 log := zerowrap.FromCtx(ctx)
748749
749750 pruneFilters := filters.NewArgs()
751+ pruneFilters.Add("label", domain.LabelManaged+"=true")
750752 if danglingOnly {
751753 pruneFilters.Add("dangling", "true")
752754 }
@@ -1724,9 +1726,28 @@ func (r *Runtime) ExecInContainer(ctx context.Context, containerID string, cmd [
17241726 }, nil
17251727 }
17261728
1729+const maxExecOutputSize = 8 << 20 // 8 MiB per stream
1730+
1731+type boundedBuffer struct {
1732+ bytes.Buffer
1733+ limit int
1734+}
1735+
1736+func (w *boundedBuffer) Write(p []byte) (int, error) {
1737+ remaining := w.limit - w.Len()
1738+ if remaining <= 0 {
1739+ return 0, fmt.Errorf("container exec output exceeds %d bytes", w.limit)
1740+ }
1741+ if len(p) > remaining {
1742+ _, _ = w.Buffer.Write(p[:remaining])
1743+ return remaining, fmt.Errorf("container exec output exceeds %d bytes", w.limit)
1744+ }
1745+ return w.Buffer.Write(p)
1746+}
1747+
17271748 func parseExecOutput(reader io.Reader) ([]byte, []byte, error) {
1728- var stdout bytes.Buffer
1729- var stderr bytes.Buffer
1749+ stdout := boundedBuffer{limit: maxExecOutputSize}
1750+ stderr := boundedBuffer{limit: maxExecOutputSize}
17301751
17311752 if _, err := stdcopy.StdCopy(&stdout, &stderr, reader); err != nil {
17321753 return nil, nil, err
@@ -525,6 +525,7 @@ func (r *Runtime) GetContainerLogs(ctx context.Context, containerID string, foll
525 ShowStderr: true,525 ShowStderr: true,
526 Follow: follow,526 Follow: follow,
527 Timestamps: true,527 Timestamps: true,
528+ Tail: "10000",
528 })529 })
529 if err != nil {530 if err != nil {
530 return nil, log.WrapErr(err, "failed to get container logs")531 return nil, log.WrapErr(err, "failed to get container logs")
@@ -747,6 +748,7 @@ func (r *Runtime) PruneImages(ctx context.Context, danglingOnly bool) (runtimepk
747 log := zerowrap.FromCtx(ctx)748 log := zerowrap.FromCtx(ctx)
748 749
749 pruneFilters := filters.NewArgs()750 pruneFilters := filters.NewArgs()
751+ pruneFilters.Add("label", domain.LabelManaged+"=true")
750 if danglingOnly {752 if danglingOnly {
751 pruneFilters.Add("dangling", "true")753 pruneFilters.Add("dangling", "true")
752 }754 }
@@ -1724,9 +1726,28 @@ func (r *Runtime) ExecInContainer(ctx context.Context, containerID string, cmd [
1724 }, nil1726 }, nil
1725 }1727 }
1726 1728
1729+const maxExecOutputSize = 8 << 20 // 8 MiB per stream
1730+
1731+type boundedBuffer struct {
1732+ bytes.Buffer
1733+ limit int
1734+}
1735+
1736+func (w *boundedBuffer) Write(p []byte) (int, error) {
1737+ remaining := w.limit - w.Len()
1738+ if remaining <= 0 {
1739+ return 0, fmt.Errorf("container exec output exceeds %d bytes", w.limit)
1740+ }
1741+ if len(p) > remaining {
1742+ _, _ = w.Buffer.Write(p[:remaining])
1743+ return remaining, fmt.Errorf("container exec output exceeds %d bytes", w.limit)
1744+ }
1745+ return w.Buffer.Write(p)
1746+}
1747+
1727 func parseExecOutput(reader io.Reader) ([]byte, []byte, error) {1748 func parseExecOutput(reader io.Reader) ([]byte, []byte, error) {
1728- var stdout bytes.Buffer1749+ stdout := boundedBuffer{limit: maxExecOutputSize}
1729- var stderr bytes.Buffer1750+ stderr := boundedBuffer{limit: maxExecOutputSize}
1730 1751
1731 if _, err := stdcopy.StdCopy(&stdout, &stderr, reader); err != nil {1752 if _, err := stdcopy.StdCopy(&stdout, &stderr, reader); err != nil {
1732 return nil, nil, err1753 return nil, nil, err
M internal/adapters/out/ratelimit/memory.go +45 -15
@@ -17,8 +17,18 @@ var _ out.RateLimiter = (*MemoryStore)(nil)
1717
1818 // MemoryStore is an in-memory rate limiter implementation using golang.org/x/time/rate.
1919 // Each unique key gets its own independent rate limiter.
20+type limiterEntry struct {
21+ limiter *rate.Limiter
22+ lastSeen time.Time
23+}
24+
25+const (
26+ maxLimiterEntries = 10000
27+ limiterIdleTTL = 10 * time.Minute
28+)
29+
2030 type MemoryStore struct {
21- limiters map[string]*rate.Limiter
31+ limiters map[string]limiterEntry
2232 mu sync.RWMutex
2333 rps float64
2434 burst int
@@ -28,7 +38,7 @@ type MemoryStore struct {
2838 // NewMemoryStore creates a new in-memory rate limiter store.
2939 func NewMemoryStore(rps float64, burst int, log zerowrap.Logger) *MemoryStore {
3040 return &MemoryStore{
31- limiters: make(map[string]*rate.Limiter),
41+ limiters: make(map[string]limiterEntry),
3242 rps: rps,
3343 burst: burst,
3444 log: log,
@@ -48,23 +58,43 @@ func (s *MemoryStore) AllowN(_ context.Context, key string, n int) bool {
4858
4959 // getLimiter returns the rate limiter for the given key, creating one if it doesn't exist.
5060 func (s *MemoryStore) getLimiter(key string) *rate.Limiter {
51- s.mu.RLock()
52- limiter, exists := s.limiters[key]
53- s.mu.RUnlock()
54-
55- if exists {
56- return limiter
57- }
58-
61+ now := time.Now()
5962 s.mu.Lock()
6063 defer s.mu.Unlock()
6164
62- // Double-check after acquiring write lock
63- if limiter, exists = s.limiters[key]; exists {
64- return limiter
65+ if entry, exists := s.limiters[key]; exists {
66+ entry.lastSeen = now
67+ s.limiters[key] = entry
68+ return entry.limiter
6569 }
6670
67- limiter = rate.NewLimiter(rate.Limit(s.rps), s.burst)
68- s.limiters[key] = limiter
71+ s.evictExpiredOrOldest(now)
72+ limiter := rate.NewLimiter(rate.Limit(s.rps), s.burst)
73+ s.limiters[key] = limiterEntry{limiter: limiter, lastSeen: now}
6974 return limiter
7075 }
76+
77+func (s *MemoryStore) evictExpiredOrOldest(now time.Time) {
78+ for key, entry := range s.limiters {
79+ if now.Sub(entry.lastSeen) > limiterIdleTTL {
80+ delete(s.limiters, key)
81+ }
82+ }
83+ if len(s.limiters) < maxLimiterEntries {
84+ return
85+ }
86+
87+ var oldestKey string
88+ var oldest time.Time
89+ found := false
90+ for key, entry := range s.limiters {
91+ if !found || entry.lastSeen.Before(oldest) {
92+ oldestKey = key
93+ oldest = entry.lastSeen
94+ found = true
95+ }
96+ }
97+ if found {
98+ delete(s.limiters, oldestKey)
99+ }
100+}
@@ -17,8 +17,18 @@ var _ out.RateLimiter = (*MemoryStore)(nil)
17 17
18 // MemoryStore is an in-memory rate limiter implementation using golang.org/x/time/rate.18 // MemoryStore is an in-memory rate limiter implementation using golang.org/x/time/rate.
19 // Each unique key gets its own independent rate limiter.19 // Each unique key gets its own independent rate limiter.
20+type limiterEntry struct {
21+ limiter *rate.Limiter
22+ lastSeen time.Time
23+}
24+
25+const (
26+ maxLimiterEntries = 10000
27+ limiterIdleTTL = 10 * time.Minute
28+)
29+
20 type MemoryStore struct {30 type MemoryStore struct {
21- limiters map[string]*rate.Limiter31+ limiters map[string]limiterEntry
22 mu sync.RWMutex32 mu sync.RWMutex
23 rps float6433 rps float64
24 burst int34 burst int
@@ -28,7 +38,7 @@ type MemoryStore struct {
28 // NewMemoryStore creates a new in-memory rate limiter store.38 // NewMemoryStore creates a new in-memory rate limiter store.
29 func NewMemoryStore(rps float64, burst int, log zerowrap.Logger) *MemoryStore {39 func NewMemoryStore(rps float64, burst int, log zerowrap.Logger) *MemoryStore {
30 return &MemoryStore{40 return &MemoryStore{
31- limiters: make(map[string]*rate.Limiter),41+ limiters: make(map[string]limiterEntry),
32 rps: rps,42 rps: rps,
33 burst: burst,43 burst: burst,
34 log: log,44 log: log,
@@ -48,23 +58,43 @@ func (s *MemoryStore) AllowN(_ context.Context, key string, n int) bool {
48 58
49 // getLimiter returns the rate limiter for the given key, creating one if it doesn't exist.59 // getLimiter returns the rate limiter for the given key, creating one if it doesn't exist.
50 func (s *MemoryStore) getLimiter(key string) *rate.Limiter {60 func (s *MemoryStore) getLimiter(key string) *rate.Limiter {
51- s.mu.RLock()61+ now := time.Now()
52- limiter, exists := s.limiters[key]
53- s.mu.RUnlock()
54-
55- if exists {
56- return limiter
57- }
58-
59 s.mu.Lock()62 s.mu.Lock()
60 defer s.mu.Unlock()63 defer s.mu.Unlock()
61 64
62- // Double-check after acquiring write lock65+ if entry, exists := s.limiters[key]; exists {
63- if limiter, exists = s.limiters[key]; exists {66+ entry.lastSeen = now
64- return limiter67+ s.limiters[key] = entry
68+ return entry.limiter
65 }69 }
66 70
67- limiter = rate.NewLimiter(rate.Limit(s.rps), s.burst)71+ s.evictExpiredOrOldest(now)
68- s.limiters[key] = limiter72+ limiter := rate.NewLimiter(rate.Limit(s.rps), s.burst)
73+ s.limiters[key] = limiterEntry{limiter: limiter, lastSeen: now}
69 return limiter74 return limiter
70 }75 }
76+
77+func (s *MemoryStore) evictExpiredOrOldest(now time.Time) {
78+ for key, entry := range s.limiters {
79+ if now.Sub(entry.lastSeen) > limiterIdleTTL {
80+ delete(s.limiters, key)
81+ }
82+ }
83+ if len(s.limiters) < maxLimiterEntries {
84+ return
85+ }
86+
87+ var oldestKey string
88+ var oldest time.Time
89+ found := false
90+ for key, entry := range s.limiters {
91+ if !found || entry.lastSeen.Before(oldest) {
92+ oldestKey = key
93+ oldest = entry.lastSeen
94+ found = true
95+ }
96+ }
97+ if found {
98+ delete(s.limiters, oldestKey)
99+ }
100+}
M internal/app/run.go +3 -2
@@ -747,8 +747,9 @@ func (si *serviceInit) initRuntimeAndProxy() error {
747747 return err
748748 }
749749
750- si.svc.registrySvc = registrySvc.NewService(si.svc.blobStorage, si.svc.manifestStorage, si.svc.eventBus)
751- si.svc.imageSvc = images.NewService(si.svc.runtime, si.svc.manifestStorage, si.svc.blobStorage, si.log)
750+ registryMutationMu := &sync.RWMutex{}
751+ si.svc.registrySvc = registrySvc.NewService(si.svc.blobStorage, si.svc.manifestStorage, si.svc.eventBus, registryMutationMu)
752+ si.svc.imageSvc = images.NewService(si.svc.runtime, si.svc.manifestStorage, si.svc.blobStorage, si.log, registryMutationMu)
752753 si.svc.volumeSvc = volumesSvc.NewService(si.svc.runtime)
753754
754755 injectTelemetryMetrics(si.cfg, si.svc, si.log)
@@ -747,8 +747,9 @@ func (si *serviceInit) initRuntimeAndProxy() error {
747 return err747 return err
748 }748 }
749 749
750- si.svc.registrySvc = registrySvc.NewService(si.svc.blobStorage, si.svc.manifestStorage, si.svc.eventBus)750+ registryMutationMu := &sync.RWMutex{}
751- si.svc.imageSvc = images.NewService(si.svc.runtime, si.svc.manifestStorage, si.svc.blobStorage, si.log)751+ si.svc.registrySvc = registrySvc.NewService(si.svc.blobStorage, si.svc.manifestStorage, si.svc.eventBus, registryMutationMu)
752+ si.svc.imageSvc = images.NewService(si.svc.runtime, si.svc.manifestStorage, si.svc.blobStorage, si.log, registryMutationMu)
752 si.svc.volumeSvc = volumesSvc.NewService(si.svc.runtime)753 si.svc.volumeSvc = volumesSvc.NewService(si.svc.runtime)
753 754
754 injectTelemetryMetrics(si.cfg, si.svc, si.log)755 injectTelemetryMetrics(si.cfg, si.svc, si.log)
M internal/boundaries/in/mocks/mock_registry_service.go +18 -12
@@ -438,8 +438,8 @@ func (_c *MockRegistryService_GetBlob_Call) RunAndReturn(run func(ctx context.Co
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/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 +21 -13
@@ -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)
@@ -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)
M internal/usecase/auth/service_test.go +14 -0
@@ -939,6 +939,20 @@ func TestIsEphemeralAccessTokenRejectsFutureIat(t *testing.T) {
939939 }
940940 }
941941
942+func TestIsEphemeralAccessTokenRequiresExplicitTokenType(t *testing.T) {
943+ svc, _ := newTestAuthService(t)
944+ now := time.Now().UTC()
945+
946+ claims := &domain.TokenClaims{
947+ IssuedAt: now.Unix(),
948+ ExpiresAt: now.Add(5 * time.Minute).Unix(),
949+ }
950+
951+ assert.False(t, svc.isEphemeralAccessToken(claims))
952+ claims.TokenType = "access"
953+ assert.True(t, svc.isEphemeralAccessToken(claims))
954+}
955+
942956 func TestExtendTokenSkipsServiceToken(t *testing.T) {
943957 tokenStore := mocks.NewMockTokenStore(t)
944958
@@ -939,6 +939,20 @@ func TestIsEphemeralAccessTokenRejectsFutureIat(t *testing.T) {
939 }939 }
940 }940 }
941 941
942+func TestIsEphemeralAccessTokenRequiresExplicitTokenType(t *testing.T) {
943+ svc, _ := newTestAuthService(t)
944+ now := time.Now().UTC()
945+
946+ claims := &domain.TokenClaims{
947+ IssuedAt: now.Unix(),
948+ ExpiresAt: now.Add(5 * time.Minute).Unix(),
949+ }
950+
951+ assert.False(t, svc.isEphemeralAccessToken(claims))
952+ claims.TokenType = "access"
953+ assert.True(t, svc.isEphemeralAccessToken(claims))
954+}
955+
942 func TestExtendTokenSkipsServiceToken(t *testing.T) {956 func TestExtendTokenSkipsServiceToken(t *testing.T) {
943 tokenStore := mocks.NewMockTokenStore(t)957 tokenStore := mocks.NewMockTokenStore(t)
944 958
M internal/usecase/auto/preview/handler.go +13 -1
@@ -16,6 +16,7 @@ type AutoPreviewHandler struct {
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,18 @@ 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 <-h.serviceCtx.Done():
83+ return h.serviceCtx.Err()
84+ default:
85+ log.Warn().Str("preview", previewName).Str("base_route", baseRoute.Domain).Msg("preview worker queue is full, skipping event")
86+ continue
87+ }
7788 go func(baseRoute baseRouteInfo, previewDomain string) {
89+ defer func() { <-h.jobs }()
7890 if err := h.previewService.CreatePreview(h.serviceCtx, CreatePreviewRequest{
7991 Name: previewName,
8092 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,18 @@ 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 <-h.serviceCtx.Done():
83+ return h.serviceCtx.Err()
84+ default:
85+ log.Warn().Str("preview", previewName).Str("base_route", baseRoute.Domain).Msg("preview worker queue is full, skipping event")
86+ continue
87+ }
77 go func(baseRoute baseRouteInfo, previewDomain string) {88 go func(baseRoute baseRouteInfo, previewDomain string) {
89+ defer func() { <-h.jobs }()
78 if err := h.previewService.CreatePreview(h.serviceCtx, CreatePreviewRequest{90 if err := h.previewService.CreatePreview(h.serviceCtx, CreatePreviewRequest{
79 Name: previewName,91 Name: previewName,
80 Domain: previewDomain,92 Domain: previewDomain,
M internal/usecase/auto/preview/service.go +17 -7
@@ -92,7 +92,17 @@ func (s *Service) Load(ctx context.Context) error {
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,7 @@ 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) + "-"
332342
333343 vols, err := s.volumeCloner.ListVolumes(ctx)
334344 if err != nil {
@@ -370,11 +380,11 @@ type CreatePreviewRequest struct {
370380
371381 // CreatePreview orchestrates the full preview creation: lock, clone volumes, start containers, register.
372382 func (s *Service) CreatePreview(ctx context.Context, req CreatePreviewRequest) error {
373- lock := s.AcquireNameLock(req.Name)
383+ lock := s.AcquireNameLock(req.Domain)
374384 lock.Lock()
375385 defer func() {
376386 lock.Unlock()
377- s.ReleaseNameLock(req.Name)
387+ s.ReleaseNameLock(req.Domain)
378388 }()
379389
380390 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,7 @@ func (s *Service) cloneBaseRouteVolumes(ctx context.Context, baseRoute, previewD
328 }338 }
329 339
330 log := zerowrap.FromCtx(ctx)340 log := zerowrap.FromCtx(ctx)
331- basePrefix := prefix + "-" + strings.ReplaceAll(baseRoute, ".", "-") + "-"341+ basePrefix := prefix + "-" + domain.StableResourceName(baseRoute) + "-"
332 342
333 vols, err := s.volumeCloner.ListVolumes(ctx)343 vols, err := s.volumeCloner.ListVolumes(ctx)
334 if err != nil {344 if err != nil {
@@ -370,11 +380,11 @@ type CreatePreviewRequest struct {
370 380
371 // CreatePreview orchestrates the full preview creation: lock, clone volumes, start containers, register.381 // CreatePreview orchestrates the full preview creation: lock, clone volumes, start containers, register.
372 func (s *Service) CreatePreview(ctx context.Context, req CreatePreviewRequest) error {382 func (s *Service) CreatePreview(ctx context.Context, req CreatePreviewRequest) error {
373- lock := s.AcquireNameLock(req.Name)383+ lock := s.AcquireNameLock(req.Domain)
374 lock.Lock()384 lock.Lock()
375 defer func() {385 defer func() {
376 lock.Unlock()386 lock.Unlock()
377- s.ReleaseNameLock(req.Name)387+ s.ReleaseNameLock(req.Domain)
378 }()388 }()
379 389
380 log := zerowrap.FromCtx(ctx)390 log := zerowrap.FromCtx(ctx)
M internal/usecase/container/service.go +41 -15
@@ -211,7 +211,7 @@ func (s *Service) acquireDomainDeployLock(ctx context.Context, domain string) (f
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,16 @@ 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+ volumes[path] = legacyName
2544+ continue
2545+ }
25402546
25412547 exists, err := s.runtime.VolumeExists(ctx, name)
25422548 if err != nil {
@@ -2593,7 +2599,7 @@ func (s *Service) generateNetworkName(identifier string) string {
25932599 cfg := s.config
25942600 s.mu.RUnlock()
25952601
2596- return fmt.Sprintf("%s-%s", cfg.NetworkPrefix, strings.ReplaceAll(identifier, ".", "-"))
2602+ return fmt.Sprintf("%s-%s", cfg.NetworkPrefix, domain.StableResourceName(identifier))
25972603 }
25982604
25992605 func (s *Service) createNetworkIfNeeded(ctx context.Context, networkName string) error {
@@ -2613,7 +2619,11 @@ func (s *Service) createNetworkIfNeeded(ctx context.Context, networkName string)
26132619 s.mu.RLock()
26142620 internal := s.config.NetworkInternal
26152621 s.mu.RUnlock()
2616- if err := s.runtime.CreateNetwork(ctx, networkName, domain.NetworkConfig{Driver: "bridge", Internal: internal}); err != nil {
2622+ if err := s.runtime.CreateNetwork(ctx, networkName, domain.NetworkConfig{
2623+ Driver: "bridge",
2624+ Internal: internal,
2625+ Labels: map[string]string{domain.LabelManaged: "true"},
2626+ }); err != nil {
26172627 return log.WrapErr(err, "failed to create network")
26182628 }
26192629 log.Info().Msg("created network for app isolation")
@@ -2624,7 +2634,7 @@ func (s *Service) createNetworkIfNeeded(ctx context.Context, networkName string)
26242634
26252635 func (s *Service) cleanupOrphanedContainers(ctx context.Context, domainName string, skipContainerID string) error {
26262636 log := zerowrap.FromCtx(ctx)
2627- expectedName := fmt.Sprintf("gordon-%s", domainName)
2637+ expectedName := managedContainerName(domainName)
26282638 expectedNewName := expectedName + "-new"
26292639 expectedNextName := expectedName + "-next"
26302640
@@ -2681,7 +2691,7 @@ func (s *Service) cleanupNetworkIfEmpty(ctx context.Context, networkName string)
26812691 }
26822692
26832693 for _, n := range networks {
2684- if n.Name == networkName && len(n.Containers) == 0 {
2694+ if n.Name == networkName && len(n.Containers) == 0 && n.Labels[domain.LabelManaged] == "true" {
26852695 if err := s.runtime.RemoveNetwork(ctx, networkName); err != nil {
26862696 return err
26872697 }
@@ -3125,6 +3135,11 @@ func (s *Service) resolveExistingAttachment(ctx context.Context, ownerDomain, se
31253135 return nil, nil
31263136 }
31273137
3138+ if existingContainer.Labels[domain.LabelManaged] != "true" ||
3139+ existingContainer.Labels[domain.LabelAttachment] != "true" ||
3140+ existingContainer.Labels[domain.LabelAttachedTo] != ownerDomain {
3141+ return nil, fmt.Errorf("legacy attachment name %q is owned by another workload", containerNameLegacy)
3142+ }
31283143 log.Info().Str("container_name", containerNameLegacy).Msg("found attachment with legacy naming, will be replaced")
31293144 if err := s.runtime.StopContainer(ctx, existingContainer.ID); err != nil {
31303145 return nil, log.WrapErr(err, "failed to stop legacy attachment container")
@@ -3138,6 +3153,10 @@ func (s *Service) resolveExistingAttachment(ctx context.Context, ownerDomain, se
31383153
31393154 // Utility functions
31403155
3156+func managedContainerName(domainName string) string {
3157+ return "gordon-" + domainName
3158+}
3159+
31413160 func normalizeImageRef(image string) string {
31423161 if isDigestRef(image) {
31433162 return image
@@ -3179,6 +3198,13 @@ func normalizeImageRef(image string) string {
31793198 }
31803199
31813200 func generateVolumeName(prefix, domainName, volumePath string) string {
3201+ return fmt.Sprintf("%s-%s-%s",
3202+ prefix,
3203+ domain.StableResourceName(domainName),
3204+ domain.StableResourceName(strings.Trim(volumePath, "/")))
3205+}
3206+
3207+func legacyVolumeName(prefix, domainName, volumePath string) string {
31823208 return fmt.Sprintf("%s-%s-%s",
31833209 prefix,
31843210 strings.ReplaceAll(domainName, ".", "-"),
@@ -211,7 +211,7 @@ func (s *Service) acquireDomainDeployLock(ctx context.Context, domain string) (f
211 211
212 // buildContainerConfig constructs the container configuration for deployment.212 // buildContainerConfig constructs the container configuration for deployment.
213 func (s *Service) deploymentContainerName(containerDomain string, existing *domain.Container) string {213 func (s *Service) deploymentContainerName(containerDomain string, existing *domain.Container) string {
214- canonicalName := fmt.Sprintf("gordon-%s", containerDomain)214+ canonicalName := managedContainerName(containerDomain)
215 if existing == nil {215 if existing == nil {
216 return 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,16 @@ func (s *Service) setupVolumes(ctx context.Context, domainName, imageRef string)
2537 2533
2538 for _, path := range volumePaths {2534 for _, path := range volumePaths {
2539 name := generateVolumeName(cfg.VolumePrefix, domainName, path)2535 name := generateVolumeName(cfg.VolumePrefix, domainName, path)
2536+ legacyName := legacyVolumeName(cfg.VolumePrefix, domainName, path)
2537+ legacyExists, err := s.runtime.VolumeExists(ctx, legacyName)
2538+ if err != nil {
2539+ log.WrapErrWithFields(err, "failed to check legacy volume", map[string]any{"volume": legacyName})
2540+ continue
2541+ }
2542+ if legacyExists {
2543+ volumes[path] = legacyName
2544+ continue
2545+ }
2540 2546
2541 exists, err := s.runtime.VolumeExists(ctx, name)2547 exists, err := s.runtime.VolumeExists(ctx, name)
2542 if err != nil {2548 if err != nil {
@@ -2593,7 +2599,7 @@ func (s *Service) generateNetworkName(identifier string) string {
2593 cfg := s.config2599 cfg := s.config
2594 s.mu.RUnlock()2600 s.mu.RUnlock()
2595 2601
2596- return fmt.Sprintf("%s-%s", cfg.NetworkPrefix, strings.ReplaceAll(identifier, ".", "-"))2602+ return fmt.Sprintf("%s-%s", cfg.NetworkPrefix, domain.StableResourceName(identifier))
2597 }2603 }
2598 2604
2599 func (s *Service) createNetworkIfNeeded(ctx context.Context, networkName string) error {2605 func (s *Service) createNetworkIfNeeded(ctx context.Context, networkName string) error {
@@ -2613,7 +2619,11 @@ func (s *Service) createNetworkIfNeeded(ctx context.Context, networkName string)
2613 s.mu.RLock()2619 s.mu.RLock()
2614 internal := s.config.NetworkInternal2620 internal := s.config.NetworkInternal
2615 s.mu.RUnlock()2621 s.mu.RUnlock()
2616- if err := s.runtime.CreateNetwork(ctx, networkName, domain.NetworkConfig{Driver: "bridge", Internal: internal}); err != nil {2622+ if err := s.runtime.CreateNetwork(ctx, networkName, domain.NetworkConfig{
2623+ Driver: "bridge",
2624+ Internal: internal,
2625+ Labels: map[string]string{domain.LabelManaged: "true"},
2626+ }); err != nil {
2617 return log.WrapErr(err, "failed to create network")2627 return log.WrapErr(err, "failed to create network")
2618 }2628 }
2619 log.Info().Msg("created network for app isolation")2629 log.Info().Msg("created network for app isolation")
@@ -2624,7 +2634,7 @@ func (s *Service) createNetworkIfNeeded(ctx context.Context, networkName string)
2624 2634
2625 func (s *Service) cleanupOrphanedContainers(ctx context.Context, domainName string, skipContainerID string) error {2635 func (s *Service) cleanupOrphanedContainers(ctx context.Context, domainName string, skipContainerID string) error {
2626 log := zerowrap.FromCtx(ctx)2636 log := zerowrap.FromCtx(ctx)
2627- expectedName := fmt.Sprintf("gordon-%s", domainName)2637+ expectedName := managedContainerName(domainName)
2628 expectedNewName := expectedName + "-new"2638 expectedNewName := expectedName + "-new"
2629 expectedNextName := expectedName + "-next"2639 expectedNextName := expectedName + "-next"
2630 2640
@@ -2681,7 +2691,7 @@ func (s *Service) cleanupNetworkIfEmpty(ctx context.Context, networkName string)
2681 }2691 }
2682 2692
2683 for _, n := range networks {2693 for _, n := range networks {
2684- if n.Name == networkName && len(n.Containers) == 0 {2694+ if n.Name == networkName && len(n.Containers) == 0 && n.Labels[domain.LabelManaged] == "true" {
2685 if err := s.runtime.RemoveNetwork(ctx, networkName); err != nil {2695 if err := s.runtime.RemoveNetwork(ctx, networkName); err != nil {
2686 return err2696 return err
2687 }2697 }
@@ -3125,6 +3135,11 @@ func (s *Service) resolveExistingAttachment(ctx context.Context, ownerDomain, se
3125 return nil, nil3135 return nil, nil
3126 }3136 }
3127 3137
3138+ if existingContainer.Labels[domain.LabelManaged] != "true" ||
3139+ existingContainer.Labels[domain.LabelAttachment] != "true" ||
3140+ existingContainer.Labels[domain.LabelAttachedTo] != ownerDomain {
3141+ return nil, fmt.Errorf("legacy attachment name %q is owned by another workload", containerNameLegacy)
3142+ }
3128 log.Info().Str("container_name", containerNameLegacy).Msg("found attachment with legacy naming, will be replaced")3143 log.Info().Str("container_name", containerNameLegacy).Msg("found attachment with legacy naming, will be replaced")
3129 if err := s.runtime.StopContainer(ctx, existingContainer.ID); err != nil {3144 if err := s.runtime.StopContainer(ctx, existingContainer.ID); err != nil {
3130 return nil, log.WrapErr(err, "failed to stop legacy attachment container")3145 return nil, log.WrapErr(err, "failed to stop legacy attachment container")
@@ -3138,6 +3153,10 @@ func (s *Service) resolveExistingAttachment(ctx context.Context, ownerDomain, se
3138 3153
3139 // Utility functions3154 // Utility functions
3140 3155
3156+func managedContainerName(domainName string) string {
3157+ return "gordon-" + domainName
3158+}
3159+
3141 func normalizeImageRef(image string) string {3160 func normalizeImageRef(image string) string {
3142 if isDigestRef(image) {3161 if isDigestRef(image) {
3143 return image3162 return image
@@ -3179,6 +3198,13 @@ func normalizeImageRef(image string) string {
3179 }3198 }
3180 3199
3181 func generateVolumeName(prefix, domainName, volumePath string) string {3200 func generateVolumeName(prefix, domainName, volumePath string) string {
3201+ return fmt.Sprintf("%s-%s-%s",
3202+ prefix,
3203+ domain.StableResourceName(domainName),
3204+ domain.StableResourceName(strings.Trim(volumePath, "/")))
3205+}
3206+
3207+func legacyVolumeName(prefix, domainName, volumePath string) string {
3182 return fmt.Sprintf("%s-%s-%s",3208 return fmt.Sprintf("%s-%s-%s",
3183 prefix,3209 prefix,
3184 strings.ReplaceAll(domainName, ".", "-"),3210 strings.ReplaceAll(domainName, ".", "-"),
M internal/usecase/container/service_test.go +45 -36
@@ -200,16 +200,15 @@ func TestService_GetNetworkForApp_UsesLiveConfigProvider(t *testing.T) {
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,20 @@ 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+
1798+ volumes, err := svc.setupVolumes(testContext(), "app.example.com", "myapp:latest")
1799+
1800+ require.NoError(t, err)
1801+ assert.Equal(t, map[string]string{"/data": legacyName}, volumes)
1802+}
1803+
17991804 func TestMergeEnvironmentVariables(t *testing.T) {
18001805 dockerEnv := []string{"FOO=docker", "BAR=docker"}
18011806 userEnv := []string{"FOO=user", "BAZ=user"}
@@ -4480,7 +4485,11 @@ func TestService_CreateNetworkIfNeeded_UsesInternalOptionWhenConfigured(t *testi
44804485 svc := NewService(runtime, nil, nil, nil, Config{NetworkInternal: true}, nil)
44814486
44824487 runtime.EXPECT().NetworkExists(mock.Anything, "gordon-app").Return(false, nil)
4483- runtime.EXPECT().CreateNetwork(mock.Anything, "gordon-app", domain.NetworkConfig{Driver: "bridge", Internal: true}).Return(nil)
4488+ runtime.EXPECT().CreateNetwork(mock.Anything, "gordon-app", domain.NetworkConfig{
4489+ Driver: "bridge",
4490+ Internal: true,
4491+ Labels: map[string]string{domain.LabelManaged: "true"},
4492+ }).Return(nil)
44844493
44854494 require.NoError(t, svc.createNetworkIfNeeded(testContext(), "gordon-app"))
44864495 }
@@ -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,20 @@ func TestGenerateVolumeName(t *testing.T) {
1796 }1787 }
1797 }1788 }
1798 1789
1790+func TestService_SetupVolumes_ReusesLegacyVolume(t *testing.T) {
1791+ runtime := mocks.NewMockContainerRuntime(t)
1792+ svc := NewService(runtime, nil, nil, nil, Config{VolumeAutoCreate: true, VolumePrefix: "gordon"}, nil)
1793+ legacyName := legacyVolumeName("gordon", "app.example.com", "/data")
1794+
1795+ runtime.EXPECT().InspectImageVolumes(mock.Anything, "myapp:latest").Return([]string{"/data"}, nil)
1796+ runtime.EXPECT().VolumeExists(mock.Anything, legacyName).Return(true, nil)
1797+
1798+ volumes, err := svc.setupVolumes(testContext(), "app.example.com", "myapp:latest")
1799+
1800+ require.NoError(t, err)
1801+ assert.Equal(t, map[string]string{"/data": legacyName}, volumes)
1802+}
1803+
1799 func TestMergeEnvironmentVariables(t *testing.T) {1804 func TestMergeEnvironmentVariables(t *testing.T) {
1800 dockerEnv := []string{"FOO=docker", "BAR=docker"}1805 dockerEnv := []string{"FOO=docker", "BAR=docker"}
1801 userEnv := []string{"FOO=user", "BAZ=user"}1806 userEnv := []string{"FOO=user", "BAZ=user"}
@@ -4480,7 +4485,11 @@ func TestService_CreateNetworkIfNeeded_UsesInternalOptionWhenConfigured(t *testi
4480 svc := NewService(runtime, nil, nil, nil, Config{NetworkInternal: true}, nil)4485 svc := NewService(runtime, nil, nil, nil, Config{NetworkInternal: true}, nil)
4481 4486
4482 runtime.EXPECT().NetworkExists(mock.Anything, "gordon-app").Return(false, nil)4487 runtime.EXPECT().NetworkExists(mock.Anything, "gordon-app").Return(false, nil)
4483- runtime.EXPECT().CreateNetwork(mock.Anything, "gordon-app", domain.NetworkConfig{Driver: "bridge", Internal: true}).Return(nil)4488+ runtime.EXPECT().CreateNetwork(mock.Anything, "gordon-app", domain.NetworkConfig{
4489+ Driver: "bridge",
4490+ Internal: true,
4491+ Labels: map[string]string{domain.LabelManaged: "true"},
4492+ }).Return(nil)
4484 4493
4485 require.NoError(t, svc.createNetworkIfNeeded(testContext(), "gordon-app"))4494 require.NoError(t, svc.createNetworkIfNeeded(testContext(), "gordon-app"))
4486 }4495 }
M internal/usecase/images/service.go +11 -0
@@ -7,6 +7,7 @@ import (
77 "fmt"
88 "sort"
99 "strings"
10+ "sync"
1011 "time"
1112
1213 "github.com/bnema/zerowrap"
@@ -23,6 +24,7 @@ type Service struct {
2324 manifestStorage out.ManifestStorage
2425 blobStorage out.BlobStorage
2526 log zerowrap.Logger
27+ mutationMu *sync.RWMutex
2628 }
2729
2830 type imageRuntime interface {
@@ -36,12 +38,18 @@ func NewService(
3638 manifestStorage out.ManifestStorage,
3739 blobStorage out.BlobStorage,
3840 log zerowrap.Logger,
41+ mutationLocks ...*sync.RWMutex,
3942 ) *Service {
43+ mutationMu := &sync.RWMutex{}
44+ if len(mutationLocks) > 0 && mutationLocks[0] != nil {
45+ mutationMu = mutationLocks[0]
46+ }
4047 return &Service{
4148 runtime: rt,
4249 manifestStorage: manifestStorage,
4350 blobStorage: blobStorage,
4451 log: log,
52+ mutationMu: mutationMu,
4553 }
4654 }
4755
@@ -194,6 +202,9 @@ func (s *Service) PruneRuntime(ctx context.Context) (domain.ImagePruneReport, er
194202 // It keeps the "latest" tag when present and keeps keepLast most-recent
195203 // non-latest tags from tagInfos.
196204 func (s *Service) PruneRegistry(ctx context.Context, keepLast int) (domain.ImagePruneReport, error) {
205+ s.mutationMu.Lock()
206+ defer s.mutationMu.Unlock()
207+
197208 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
198209 zerowrap.FieldLayer: "usecase",
199210 zerowrap.FieldUseCase: "PruneRegistry",
@@ -7,6 +7,7 @@ import (
7 "fmt"7 "fmt"
8 "sort"8 "sort"
9 "strings"9 "strings"
10+ "sync"
10 "time"11 "time"
11 12
12 "github.com/bnema/zerowrap"13 "github.com/bnema/zerowrap"
@@ -23,6 +24,7 @@ type Service struct {
23 manifestStorage out.ManifestStorage24 manifestStorage out.ManifestStorage
24 blobStorage out.BlobStorage25 blobStorage out.BlobStorage
25 log zerowrap.Logger26 log zerowrap.Logger
27+ mutationMu *sync.RWMutex
26 }28 }
27 29
28 type imageRuntime interface {30 type imageRuntime interface {
@@ -36,12 +38,18 @@ func NewService(
36 manifestStorage out.ManifestStorage,38 manifestStorage out.ManifestStorage,
37 blobStorage out.BlobStorage,39 blobStorage out.BlobStorage,
38 log zerowrap.Logger,40 log zerowrap.Logger,
41+ mutationLocks ...*sync.RWMutex,
39 ) *Service {42 ) *Service {
43+ mutationMu := &sync.RWMutex{}
44+ if len(mutationLocks) > 0 && mutationLocks[0] != nil {
45+ mutationMu = mutationLocks[0]
46+ }
40 return &Service{47 return &Service{
41 runtime: rt,48 runtime: rt,
42 manifestStorage: manifestStorage,49 manifestStorage: manifestStorage,
43 blobStorage: blobStorage,50 blobStorage: blobStorage,
44 log: log,51 log: log,
52+ mutationMu: mutationMu,
45 }53 }
46 }54 }
47 55
@@ -194,6 +202,9 @@ func (s *Service) PruneRuntime(ctx context.Context) (domain.ImagePruneReport, er
194 // It keeps the "latest" tag when present and keeps keepLast most-recent202 // It keeps the "latest" tag when present and keeps keepLast most-recent
195 // non-latest tags from tagInfos.203 // non-latest tags from tagInfos.
196 func (s *Service) PruneRegistry(ctx context.Context, keepLast int) (domain.ImagePruneReport, error) {204 func (s *Service) PruneRegistry(ctx context.Context, keepLast int) (domain.ImagePruneReport, error) {
205+ s.mutationMu.Lock()
206+ defer s.mutationMu.Unlock()
207+
197 ctx = zerowrap.CtxWithFields(ctx, map[string]any{208 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
198 zerowrap.FieldLayer: "usecase",209 zerowrap.FieldLayer: "usecase",
199 zerowrap.FieldUseCase: "PruneRegistry",210 zerowrap.FieldUseCase: "PruneRegistry",
M internal/usecase/registry/service.go +122 -4
@@ -4,6 +4,9 @@ package registry
44 import (
55 "context"
66 "crypto/sha256"
7+ "crypto/sha512"
8+ "encoding/json"
9+ "errors"
710 "fmt"
811 "io"
912 "strings"
@@ -31,6 +34,7 @@ type Service struct {
3134 eventBus out.EventPublisher
3235 metrics *telemetry.Metrics
3336 suppressedImages sync.Map // imageName -> *time.Timer
37+ mutationMu *sync.RWMutex
3438 }
3539
3640 // SetMetrics sets the telemetry metrics for the registry service.
@@ -43,11 +47,17 @@ func NewService(
4347 blobStorage out.BlobStorage,
4448 manifestStorage out.ManifestStorage,
4549 eventBus out.EventPublisher,
50+ mutationLocks ...*sync.RWMutex,
4651 ) *Service {
52+ mutationMu := &sync.RWMutex{}
53+ if len(mutationLocks) > 0 && mutationLocks[0] != nil {
54+ mutationMu = mutationLocks[0]
55+ }
4756 return &Service{
4857 blobStorage: blobStorage,
4958 manifestStorage: manifestStorage,
5059 eventBus: eventBus,
60+ mutationMu: mutationMu,
5161 }
5262 }
5363
@@ -155,6 +165,9 @@ func (s *Service) GetManifest(ctx context.Context, name, reference string) (*dom
155165
156166 // PutManifest stores a manifest and returns the calculated digest.
157167 func (s *Service) PutManifest(ctx context.Context, manifest *domain.Manifest) (string, error) {
168+ s.mutationMu.RLock()
169+ defer s.mutationMu.RUnlock()
170+
158171 ctx, span := registryTracer.Start(ctx, "registry.put_manifest",
159172 trace.WithAttributes(
160173 attribute.String("name", manifest.Name),
@@ -171,8 +184,20 @@ func (s *Service) PutManifest(ctx context.Context, manifest *domain.Manifest) (s
171184 })
172185 log := zerowrap.FromCtx(ctx)
173186
174- // Calculate digest
187+ // Calculate and verify the digest before mutating storage. A digest-addressed
188+ // manifest must match its URL reference or clients could later retrieve
189+ // attacker-controlled bytes under a trusted content address.
175190 digest := fmt.Sprintf("sha256:%x", sha256.Sum256(manifest.Data))
191+ if validation.IsDigest(manifest.Reference) {
192+ matches, err := manifestDigestMatches(manifest.Reference, manifest.Data)
193+ if err != nil {
194+ return "", err
195+ }
196+ if !matches {
197+ return "", fmt.Errorf("%w: manifest content does not match %s", domain.ErrDigestMismatch, manifest.Reference)
198+ }
199+ digest = manifest.Reference
200+ }
176201
177202 if err := s.manifestStorage.PutManifest(manifest.Name, manifest.Reference, manifest.ContentType, manifest.Data); err != nil {
178203 return "", log.WrapErr(err, "failed to store manifest")
@@ -191,7 +216,7 @@ func (s *Service) PutManifest(ctx context.Context, manifest *domain.Manifest) (s
191216 // Publish image pushed event only for tag references (not digests).
192217 // A docker push sends manifests by both digest and tag; firing only on
193218 // tag prevents duplicate deploy triggers for the same push.
194- if s.eventBus != nil && !strings.HasPrefix(manifest.Reference, "sha256:") {
219+ if s.eventBus != nil && !validation.IsDigest(manifest.Reference) {
195220 if s.IsDeployEventSuppressed(manifest.Name) {
196221 log.Info().Str("image", manifest.Name).Msg("skipping image.pushed event: CLI deploy intent active")
197222 } else {
@@ -245,15 +270,25 @@ func (s *Service) GetBlob(ctx context.Context, digest string) (io.ReadCloser, er
245270 return reader, nil
246271 }
247272
248-// GetBlobPath returns the filesystem path to a blob for direct serving.
249-func (s *Service) GetBlobPath(ctx context.Context, digest string) (string, error) {
273+// GetBlobPath returns the filesystem path to a blob only when a manifest in
274+// the requested repository references it.
275+func (s *Service) GetBlobPath(ctx context.Context, name, digest string) (string, error) {
250276 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
251277 zerowrap.FieldLayer: "usecase",
252278 zerowrap.FieldUseCase: "GetBlobPath",
279+ "name": name,
253280 "digest": digest,
254281 })
255282 log := zerowrap.FromCtx(ctx)
256283
284+ referenced, err := s.repositoryReferencesDigest(name, digest)
285+ if err != nil {
286+ return "", log.WrapErr(err, "failed to verify blob ownership")
287+ }
288+ if !referenced {
289+ return "", domain.ErrBlobNotFound
290+ }
291+
257292 path, err := s.blobStorage.GetBlobPath(digest)
258293 if err != nil {
259294 return "", log.WrapErr(err, "failed to get blob path")
@@ -262,8 +297,91 @@ func (s *Service) GetBlobPath(ctx context.Context, digest string) (string, error
262297 return path, nil
263298 }
264299
300+type manifestDescriptor struct {
301+ Digest string `json:"digest"`
302+}
303+
304+type manifestReferences struct {
305+ Config manifestDescriptor `json:"config"`
306+ Layers []manifestDescriptor `json:"layers"`
307+ Manifests []manifestDescriptor `json:"manifests"`
308+ Blobs []manifestDescriptor `json:"blobs"`
309+ Subject *manifestDescriptor `json:"subject"`
310+}
311+
312+func (s *Service) repositoryReferencesDigest(name, target string) (bool, error) {
313+ tags, err := s.manifestStorage.ListTags(name)
314+ if err != nil {
315+ if errors.Is(err, domain.ErrManifestNotFound) {
316+ return false, nil
317+ }
318+ return false, err
319+ }
320+
321+ queue := append([]string(nil), tags...)
322+ seen := make(map[string]struct{}, len(queue))
323+ for len(queue) > 0 {
324+ reference := queue[0]
325+ queue = queue[1:]
326+ if _, ok := seen[reference]; ok {
327+ continue
328+ }
329+ seen[reference] = struct{}{}
330+
331+ data, _, err := s.manifestStorage.GetManifest(name, reference)
332+ if err != nil {
333+ return false, err
334+ }
335+ var refs manifestReferences
336+ if err := json.Unmarshal(data, &refs); err != nil {
337+ return false, fmt.Errorf("decode manifest %s: %w", reference, err)
338+ }
339+
340+ if refs.Config.Digest == target || descriptorListContains(refs.Layers, target) ||
341+ descriptorListContains(refs.Blobs, target) || (refs.Subject != nil && refs.Subject.Digest == target) {
342+ return true, nil
343+ }
344+ for _, child := range refs.Manifests {
345+ if child.Digest == target {
346+ return true, nil
347+ }
348+ if child.Digest != "" {
349+ queue = append(queue, child.Digest)
350+ }
351+ }
352+ }
353+ return false, nil
354+}
355+
356+func descriptorListContains(descriptors []manifestDescriptor, digest string) bool {
357+ for _, descriptor := range descriptors {
358+ if descriptor.Digest == digest {
359+ return true
360+ }
361+ }
362+ return false
363+}
364+
365+func manifestDigestMatches(reference string, data []byte) (bool, error) {
366+ algorithm, _, ok := strings.Cut(reference, ":")
367+ if !ok {
368+ return false, domain.ErrInvalidDigest
369+ }
370+ switch algorithm {
371+ case "sha256":
372+ return reference == fmt.Sprintf("sha256:%x", sha256.Sum256(data)), nil
373+ case "sha512":
374+ return reference == fmt.Sprintf("sha512:%x", sha512.Sum512(data)), nil
375+ default:
376+ return false, domain.ErrInvalidDigest
377+ }
378+}
379+
265380 // PutBlob stores a blob with the given digest.
266381 func (s *Service) PutBlob(ctx context.Context, digest string, data io.Reader, size int64) error {
382+ s.mutationMu.RLock()
383+ defer s.mutationMu.RUnlock()
384+
267385 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
268386 zerowrap.FieldLayer: "usecase",
269387 zerowrap.FieldUseCase: "PutBlob",
@@ -4,6 +4,9 @@ package registry
4 import (4 import (
5 "context"5 "context"
6 "crypto/sha256"6 "crypto/sha256"
7+ "crypto/sha512"
8+ "encoding/json"
9+ "errors"
7 "fmt"10 "fmt"
8 "io"11 "io"
9 "strings"12 "strings"
@@ -31,6 +34,7 @@ type Service struct {
31 eventBus out.EventPublisher34 eventBus out.EventPublisher
32 metrics *telemetry.Metrics35 metrics *telemetry.Metrics
33 suppressedImages sync.Map // imageName -> *time.Timer36 suppressedImages sync.Map // imageName -> *time.Timer
37+ mutationMu *sync.RWMutex
34 }38 }
35 39
36 // SetMetrics sets the telemetry metrics for the registry service.40 // SetMetrics sets the telemetry metrics for the registry service.
@@ -43,11 +47,17 @@ func NewService(
43 blobStorage out.BlobStorage,47 blobStorage out.BlobStorage,
44 manifestStorage out.ManifestStorage,48 manifestStorage out.ManifestStorage,
45 eventBus out.EventPublisher,49 eventBus out.EventPublisher,
50+ mutationLocks ...*sync.RWMutex,
46 ) *Service {51 ) *Service {
52+ mutationMu := &sync.RWMutex{}
53+ if len(mutationLocks) > 0 && mutationLocks[0] != nil {
54+ mutationMu = mutationLocks[0]
55+ }
47 return &Service{56 return &Service{
48 blobStorage: blobStorage,57 blobStorage: blobStorage,
49 manifestStorage: manifestStorage,58 manifestStorage: manifestStorage,
50 eventBus: eventBus,59 eventBus: eventBus,
60+ mutationMu: mutationMu,
51 }61 }
52 }62 }
53 63
@@ -155,6 +165,9 @@ func (s *Service) GetManifest(ctx context.Context, name, reference string) (*dom
155 165
156 // PutManifest stores a manifest and returns the calculated digest.166 // PutManifest stores a manifest and returns the calculated digest.
157 func (s *Service) PutManifest(ctx context.Context, manifest *domain.Manifest) (string, error) {167 func (s *Service) PutManifest(ctx context.Context, manifest *domain.Manifest) (string, error) {
168+ s.mutationMu.RLock()
169+ defer s.mutationMu.RUnlock()
170+
158 ctx, span := registryTracer.Start(ctx, "registry.put_manifest",171 ctx, span := registryTracer.Start(ctx, "registry.put_manifest",
159 trace.WithAttributes(172 trace.WithAttributes(
160 attribute.String("name", manifest.Name),173 attribute.String("name", manifest.Name),
@@ -171,8 +184,20 @@ func (s *Service) PutManifest(ctx context.Context, manifest *domain.Manifest) (s
171 })184 })
172 log := zerowrap.FromCtx(ctx)185 log := zerowrap.FromCtx(ctx)
173 186
174- // Calculate digest187+ // Calculate and verify the digest before mutating storage. A digest-addressed
188+ // manifest must match its URL reference or clients could later retrieve
189+ // attacker-controlled bytes under a trusted content address.
175 digest := fmt.Sprintf("sha256:%x", sha256.Sum256(manifest.Data))190 digest := fmt.Sprintf("sha256:%x", sha256.Sum256(manifest.Data))
191+ if validation.IsDigest(manifest.Reference) {
192+ matches, err := manifestDigestMatches(manifest.Reference, manifest.Data)
193+ if err != nil {
194+ return "", err
195+ }
196+ if !matches {
197+ return "", fmt.Errorf("%w: manifest content does not match %s", domain.ErrDigestMismatch, manifest.Reference)
198+ }
199+ digest = manifest.Reference
200+ }
176 201
177 if err := s.manifestStorage.PutManifest(manifest.Name, manifest.Reference, manifest.ContentType, manifest.Data); err != nil {202 if err := s.manifestStorage.PutManifest(manifest.Name, manifest.Reference, manifest.ContentType, manifest.Data); err != nil {
178 return "", log.WrapErr(err, "failed to store manifest")203 return "", log.WrapErr(err, "failed to store manifest")
@@ -191,7 +216,7 @@ func (s *Service) PutManifest(ctx context.Context, manifest *domain.Manifest) (s
191 // Publish image pushed event only for tag references (not digests).216 // Publish image pushed event only for tag references (not digests).
192 // A docker push sends manifests by both digest and tag; firing only on217 // A docker push sends manifests by both digest and tag; firing only on
193 // tag prevents duplicate deploy triggers for the same push.218 // tag prevents duplicate deploy triggers for the same push.
194- if s.eventBus != nil && !strings.HasPrefix(manifest.Reference, "sha256:") {219+ if s.eventBus != nil && !validation.IsDigest(manifest.Reference) {
195 if s.IsDeployEventSuppressed(manifest.Name) {220 if s.IsDeployEventSuppressed(manifest.Name) {
196 log.Info().Str("image", manifest.Name).Msg("skipping image.pushed event: CLI deploy intent active")221 log.Info().Str("image", manifest.Name).Msg("skipping image.pushed event: CLI deploy intent active")
197 } else {222 } else {
@@ -245,15 +270,25 @@ func (s *Service) GetBlob(ctx context.Context, digest string) (io.ReadCloser, er
245 return reader, nil270 return reader, nil
246 }271 }
247 272
248-// GetBlobPath returns the filesystem path to a blob for direct serving.273+// GetBlobPath returns the filesystem path to a blob only when a manifest in
249-func (s *Service) GetBlobPath(ctx context.Context, digest string) (string, error) {274+// the requested repository references it.
275+func (s *Service) GetBlobPath(ctx context.Context, name, digest string) (string, error) {
250 ctx = zerowrap.CtxWithFields(ctx, map[string]any{276 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
251 zerowrap.FieldLayer: "usecase",277 zerowrap.FieldLayer: "usecase",
252 zerowrap.FieldUseCase: "GetBlobPath",278 zerowrap.FieldUseCase: "GetBlobPath",
279+ "name": name,
253 "digest": digest,280 "digest": digest,
254 })281 })
255 log := zerowrap.FromCtx(ctx)282 log := zerowrap.FromCtx(ctx)
256 283
284+ referenced, err := s.repositoryReferencesDigest(name, digest)
285+ if err != nil {
286+ return "", log.WrapErr(err, "failed to verify blob ownership")
287+ }
288+ if !referenced {
289+ return "", domain.ErrBlobNotFound
290+ }
291+
257 path, err := s.blobStorage.GetBlobPath(digest)292 path, err := s.blobStorage.GetBlobPath(digest)
258 if err != nil {293 if err != nil {
259 return "", log.WrapErr(err, "failed to get blob path")294 return "", log.WrapErr(err, "failed to get blob path")
@@ -262,8 +297,91 @@ func (s *Service) GetBlobPath(ctx context.Context, digest string) (string, error
262 return path, nil297 return path, nil
263 }298 }
264 299
300+type manifestDescriptor struct {
301+ Digest string `json:"digest"`
302+}
303+
304+type manifestReferences struct {
305+ Config manifestDescriptor `json:"config"`
306+ Layers []manifestDescriptor `json:"layers"`
307+ Manifests []manifestDescriptor `json:"manifests"`
308+ Blobs []manifestDescriptor `json:"blobs"`
309+ Subject *manifestDescriptor `json:"subject"`
310+}
311+
312+func (s *Service) repositoryReferencesDigest(name, target string) (bool, error) {
313+ tags, err := s.manifestStorage.ListTags(name)
314+ if err != nil {
315+ if errors.Is(err, domain.ErrManifestNotFound) {
316+ return false, nil
317+ }
318+ return false, err
319+ }
320+
321+ queue := append([]string(nil), tags...)
322+ seen := make(map[string]struct{}, len(queue))
323+ for len(queue) > 0 {
324+ reference := queue[0]
325+ queue = queue[1:]
326+ if _, ok := seen[reference]; ok {
327+ continue
328+ }
329+ seen[reference] = struct{}{}
330+
331+ data, _, err := s.manifestStorage.GetManifest(name, reference)
332+ if err != nil {
333+ return false, err
334+ }
335+ var refs manifestReferences
336+ if err := json.Unmarshal(data, &refs); err != nil {
337+ return false, fmt.Errorf("decode manifest %s: %w", reference, err)
338+ }
339+
340+ if refs.Config.Digest == target || descriptorListContains(refs.Layers, target) ||
341+ descriptorListContains(refs.Blobs, target) || (refs.Subject != nil && refs.Subject.Digest == target) {
342+ return true, nil
343+ }
344+ for _, child := range refs.Manifests {
345+ if child.Digest == target {
346+ return true, nil
347+ }
348+ if child.Digest != "" {
349+ queue = append(queue, child.Digest)
350+ }
351+ }
352+ }
353+ return false, nil
354+}
355+
356+func descriptorListContains(descriptors []manifestDescriptor, digest string) bool {
357+ for _, descriptor := range descriptors {
358+ if descriptor.Digest == digest {
359+ return true
360+ }
361+ }
362+ return false
363+}
364+
365+func manifestDigestMatches(reference string, data []byte) (bool, error) {
366+ algorithm, _, ok := strings.Cut(reference, ":")
367+ if !ok {
368+ return false, domain.ErrInvalidDigest
369+ }
370+ switch algorithm {
371+ case "sha256":
372+ return reference == fmt.Sprintf("sha256:%x", sha256.Sum256(data)), nil
373+ case "sha512":
374+ return reference == fmt.Sprintf("sha512:%x", sha512.Sum512(data)), nil
375+ default:
376+ return false, domain.ErrInvalidDigest
377+ }
378+}
379+
265 // PutBlob stores a blob with the given digest.380 // PutBlob stores a blob with the given digest.
266 func (s *Service) PutBlob(ctx context.Context, digest string, data io.Reader, size int64) error {381 func (s *Service) PutBlob(ctx context.Context, digest string, data io.Reader, size int64) error {
382+ s.mutationMu.RLock()
383+ defer s.mutationMu.RUnlock()
384+
267 ctx = zerowrap.CtxWithFields(ctx, map[string]any{385 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
268 zerowrap.FieldLayer: "usecase",386 zerowrap.FieldLayer: "usecase",
269 zerowrap.FieldUseCase: "PutBlob",387 zerowrap.FieldUseCase: "PutBlob",
M internal/usecase/registry/service_test.go +65 -0
@@ -3,7 +3,9 @@ package registry
33 import (
44 "bytes"
55 "context"
6+ "crypto/sha512"
67 "errors"
8+ "fmt"
79 "io"
810 "strings"
911 "testing"
@@ -84,6 +86,69 @@ func TestService_PutManifest_Success(t *testing.T) {
8486 assert.True(t, strings.HasPrefix(digest, "sha256:"))
8587 }
8688
89+func TestService_PutManifest_SHA512DigestDoesNotPublishEvent(t *testing.T) {
90+ blobStorage := mocks.NewMockBlobStorage(t)
91+ manifestStorage := mocks.NewMockManifestStorage(t)
92+ eventBus := mocks.NewMockEventPublisher(t)
93+ svc := NewService(blobStorage, manifestStorage, eventBus)
94+ data := []byte(`{"schemaVersion":2}`)
95+ reference := fmt.Sprintf("sha512:%x", sha512.Sum512(data))
96+ manifest := &domain.Manifest{Name: "myapp", Reference: reference, ContentType: "application/vnd.oci.image.manifest.v1+json", Data: data}
97+
98+ manifestStorage.EXPECT().PutManifest("myapp", reference, manifest.ContentType, data).Return(nil)
99+
100+ digest, err := svc.PutManifest(testContext(), manifest)
101+
102+ require.NoError(t, err)
103+ assert.Equal(t, reference, digest)
104+}
105+
106+func TestService_PutManifest_RejectsDigestMismatch(t *testing.T) {
107+ blobStorage := mocks.NewMockBlobStorage(t)
108+ manifestStorage := mocks.NewMockManifestStorage(t)
109+ eventBus := mocks.NewMockEventPublisher(t)
110+ svc := NewService(blobStorage, manifestStorage, eventBus)
111+
112+ manifest := &domain.Manifest{
113+ Name: "myapp",
114+ Reference: "sha256:0000000000000000000000000000000000000000000000000000000000000000",
115+ ContentType: "application/vnd.oci.image.manifest.v1+json",
116+ Data: []byte(`{"schemaVersion":2}`),
117+ }
118+
119+ _, err := svc.PutManifest(testContext(), manifest)
120+
121+ require.Error(t, err)
122+ assert.ErrorIs(t, err, domain.ErrDigestMismatch)
123+}
124+
125+func TestService_GetBlobPath_RequiresRepositoryReference(t *testing.T) {
126+ const digest = "sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4"
127+ blobStorage := mocks.NewMockBlobStorage(t)
128+ manifestStorage := mocks.NewMockManifestStorage(t)
129+ svc := NewService(blobStorage, manifestStorage, nil)
130+
131+ manifestStorage.EXPECT().ListTags("allowed").Return([]string{"latest"}, nil)
132+ manifestStorage.EXPECT().GetManifest("allowed", "latest").Return(
133+ []byte(`{"schemaVersion":2,"layers":[{"digest":"`+digest+`"}]}`),
134+ "application/vnd.oci.image.manifest.v1+json", nil,
135+ )
136+ blobStorage.EXPECT().GetBlobPath(digest).Return("/registry/blob", nil)
137+
138+ path, err := svc.GetBlobPath(testContext(), "allowed", digest)
139+ require.NoError(t, err)
140+ assert.Equal(t, "/registry/blob", path)
141+
142+ manifestStorage.EXPECT().ListTags("denied").Return([]string{"latest"}, nil)
143+ manifestStorage.EXPECT().GetManifest("denied", "latest").Return(
144+ []byte(`{"schemaVersion":2,"layers":[]}`),
145+ "application/vnd.oci.image.manifest.v1+json", nil,
146+ )
147+
148+ _, err = svc.GetBlobPath(testContext(), "denied", digest)
149+ assert.ErrorIs(t, err, domain.ErrBlobNotFound)
150+}
151+
87152 func TestService_PutManifest_StorageError(t *testing.T) {
88153 blobStorage := mocks.NewMockBlobStorage(t)
89154 manifestStorage := mocks.NewMockManifestStorage(t)
@@ -3,7 +3,9 @@ package registry
3 import (3 import (
4 "bytes"4 "bytes"
5 "context"5 "context"
6+ "crypto/sha512"
6 "errors"7 "errors"
8+ "fmt"
7 "io"9 "io"
8 "strings"10 "strings"
9 "testing"11 "testing"
@@ -84,6 +86,69 @@ func TestService_PutManifest_Success(t *testing.T) {
84 assert.True(t, strings.HasPrefix(digest, "sha256:"))86 assert.True(t, strings.HasPrefix(digest, "sha256:"))
85 }87 }
86 88
89+func TestService_PutManifest_SHA512DigestDoesNotPublishEvent(t *testing.T) {
90+ blobStorage := mocks.NewMockBlobStorage(t)
91+ manifestStorage := mocks.NewMockManifestStorage(t)
92+ eventBus := mocks.NewMockEventPublisher(t)
93+ svc := NewService(blobStorage, manifestStorage, eventBus)
94+ data := []byte(`{"schemaVersion":2}`)
95+ reference := fmt.Sprintf("sha512:%x", sha512.Sum512(data))
96+ manifest := &domain.Manifest{Name: "myapp", Reference: reference, ContentType: "application/vnd.oci.image.manifest.v1+json", Data: data}
97+
98+ manifestStorage.EXPECT().PutManifest("myapp", reference, manifest.ContentType, data).Return(nil)
99+
100+ digest, err := svc.PutManifest(testContext(), manifest)
101+
102+ require.NoError(t, err)
103+ assert.Equal(t, reference, digest)
104+}
105+
106+func TestService_PutManifest_RejectsDigestMismatch(t *testing.T) {
107+ blobStorage := mocks.NewMockBlobStorage(t)
108+ manifestStorage := mocks.NewMockManifestStorage(t)
109+ eventBus := mocks.NewMockEventPublisher(t)
110+ svc := NewService(blobStorage, manifestStorage, eventBus)
111+
112+ manifest := &domain.Manifest{
113+ Name: "myapp",
114+ Reference: "sha256:0000000000000000000000000000000000000000000000000000000000000000",
115+ ContentType: "application/vnd.oci.image.manifest.v1+json",
116+ Data: []byte(`{"schemaVersion":2}`),
117+ }
118+
119+ _, err := svc.PutManifest(testContext(), manifest)
120+
121+ require.Error(t, err)
122+ assert.ErrorIs(t, err, domain.ErrDigestMismatch)
123+}
124+
125+func TestService_GetBlobPath_RequiresRepositoryReference(t *testing.T) {
126+ const digest = "sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4"
127+ blobStorage := mocks.NewMockBlobStorage(t)
128+ manifestStorage := mocks.NewMockManifestStorage(t)
129+ svc := NewService(blobStorage, manifestStorage, nil)
130+
131+ manifestStorage.EXPECT().ListTags("allowed").Return([]string{"latest"}, nil)
132+ manifestStorage.EXPECT().GetManifest("allowed", "latest").Return(
133+ []byte(`{"schemaVersion":2,"layers":[{"digest":"`+digest+`"}]}`),
134+ "application/vnd.oci.image.manifest.v1+json", nil,
135+ )
136+ blobStorage.EXPECT().GetBlobPath(digest).Return("/registry/blob", nil)
137+
138+ path, err := svc.GetBlobPath(testContext(), "allowed", digest)
139+ require.NoError(t, err)
140+ assert.Equal(t, "/registry/blob", path)
141+
142+ manifestStorage.EXPECT().ListTags("denied").Return([]string{"latest"}, nil)
143+ manifestStorage.EXPECT().GetManifest("denied", "latest").Return(
144+ []byte(`{"schemaVersion":2,"layers":[]}`),
145+ "application/vnd.oci.image.manifest.v1+json", nil,
146+ )
147+
148+ _, err = svc.GetBlobPath(testContext(), "denied", digest)
149+ assert.ErrorIs(t, err, domain.ErrBlobNotFound)
150+}
151+
87 func TestService_PutManifest_StorageError(t *testing.T) {152 func TestService_PutManifest_StorageError(t *testing.T) {
88 blobStorage := mocks.NewMockBlobStorage(t)153 blobStorage := mocks.NewMockBlobStorage(t)
89 manifestStorage := mocks.NewMockManifestStorage(t)154 manifestStorage := mocks.NewMockManifestStorage(t)
M internal/usecase/services/service.go +7 -2
@@ -521,7 +521,7 @@ func tcpReadinessAddress(svc domain.StandaloneService) (string, error) {
521521 }
522522
523523 func (s *Service) waitLogReadiness(ctx context.Context, containerID string, svc domain.StandaloneService) error {
524- ticker := time.NewTicker(10 * time.Millisecond)
524+ ticker := time.NewTicker(250 * time.Millisecond)
525525 defer ticker.Stop()
526526 var lastErr error
527527 for {
@@ -551,16 +551,21 @@ func logReadinessTimeoutError(serviceName string, timeoutErr, lastErr error) err
551551 return fmt.Errorf("standalone service %q log readiness timed out: %w", serviceName, timeoutErr)
552552 }
553553
554+const maxReadinessLogSize = 1 << 20 // 1 MiB
555+
554556 func (s *Service) logContains(ctx context.Context, containerID, path, contains string) (bool, error) {
555557 reader, err := s.runtime.CopyFromContainer(ctx, containerID, path)
556558 if err != nil {
557559 return false, err
558560 }
559561 defer reader.Close()
560- content, err := io.ReadAll(reader)
562+ content, err := io.ReadAll(io.LimitReader(reader, maxReadinessLogSize+1))
561563 if err != nil {
562564 return false, err
563565 }
566+ if len(content) > maxReadinessLogSize {
567+ return false, fmt.Errorf("readiness log exceeds %d bytes", maxReadinessLogSize)
568+ }
564569 return strings.Contains(string(content), contains), nil
565570 }
566571
@@ -521,7 +521,7 @@ func tcpReadinessAddress(svc domain.StandaloneService) (string, error) {
521 }521 }
522 522
523 func (s *Service) waitLogReadiness(ctx context.Context, containerID string, svc domain.StandaloneService) error {523 func (s *Service) waitLogReadiness(ctx context.Context, containerID string, svc domain.StandaloneService) error {
524- ticker := time.NewTicker(10 * time.Millisecond)524+ ticker := time.NewTicker(250 * time.Millisecond)
525 defer ticker.Stop()525 defer ticker.Stop()
526 var lastErr error526 var lastErr error
527 for {527 for {
@@ -551,16 +551,21 @@ func logReadinessTimeoutError(serviceName string, timeoutErr, lastErr error) err
551 return fmt.Errorf("standalone service %q log readiness timed out: %w", serviceName, timeoutErr)551 return fmt.Errorf("standalone service %q log readiness timed out: %w", serviceName, timeoutErr)
552 }552 }
553 553
554+const maxReadinessLogSize = 1 << 20 // 1 MiB
555+
554 func (s *Service) logContains(ctx context.Context, containerID, path, contains string) (bool, error) {556 func (s *Service) logContains(ctx context.Context, containerID, path, contains string) (bool, error) {
555 reader, err := s.runtime.CopyFromContainer(ctx, containerID, path)557 reader, err := s.runtime.CopyFromContainer(ctx, containerID, path)
556 if err != nil {558 if err != nil {
557 return false, err559 return false, err
558 }560 }
559 defer reader.Close()561 defer reader.Close()
560- content, err := io.ReadAll(reader)562+ content, err := io.ReadAll(io.LimitReader(reader, maxReadinessLogSize+1))
561 if err != nil {563 if err != nil {
562 return false, err564 return false, err
563 }565 }
566+ if len(content) > maxReadinessLogSize {
567+ return false, fmt.Errorf("readiness log exceeds %d bytes", maxReadinessLogSize)
568+ }
564 return strings.Contains(string(content), contains), nil569 return strings.Contains(string(content), contains), nil
565 }570 }
566 571

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