detached 378d7cf

fix(security): address verified review findings

commit
378d7cf4e7e5ff398f4d9b1c0450b09b069766a6
author
brice · · 2026-08-10 22:44 UTC
parent
6e78011
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
27 files · +562 -110
M .github/actions/deploy/README.md +9 -0
@@ -28,6 +28,8 @@ jobs:
2828 runs-on: ubuntu-latest
2929 steps:
3030 - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
31+ with:
32+ persist-credentials: false
3133
3234 - name: Deploy to Gordon
3335 uses: bnema/gordon/.github/actions/deploy@271c36a4cc32fabd91b20f93cd006bdd2a01b181
@@ -53,6 +55,8 @@ jobs:
5355 runs-on: ubuntu-latest
5456 steps:
5557 - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
58+ with:
59+ persist-credentials: false
5660
5761 - name: Deploy to Gordon
5862 id: deploy
@@ -151,6 +155,7 @@ jobs:
151155 - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
152156 with:
153157 ref: ${{ github.event.inputs.tag || github.ref }}
158+ persist-credentials: false
154159
155160 - uses: bnema/gordon/.github/actions/deploy@271c36a4cc32fabd91b20f93cd006bdd2a01b181
156161 with:
@@ -175,6 +180,8 @@ jobs:
175180 runs-on: ubuntu-latest
176181 steps:
177182 - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
183+ with:
184+ persist-credentials: false
178185 - uses: bnema/gordon/.github/actions/deploy@271c36a4cc32fabd91b20f93cd006bdd2a01b181
179186 with:
180187 registry: registry.mydomain.com
@@ -188,6 +195,8 @@ jobs:
188195 runs-on: ubuntu-latest
189196 steps:
190197 - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
198+ with:
199+ persist-credentials: false
191200 - uses: bnema/gordon/.github/actions/deploy@271c36a4cc32fabd91b20f93cd006bdd2a01b181
192201 with:
193202 registry: registry.mydomain.com
@@ -28,6 +28,8 @@ jobs:
28 runs-on: ubuntu-latest28 runs-on: ubuntu-latest
29 steps:29 steps:
30 - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v630 - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
31+ with:
32+ persist-credentials: false
31 33
32 - name: Deploy to Gordon34 - name: Deploy to Gordon
33 uses: bnema/gordon/.github/actions/deploy@271c36a4cc32fabd91b20f93cd006bdd2a01b18135 uses: bnema/gordon/.github/actions/deploy@271c36a4cc32fabd91b20f93cd006bdd2a01b181
@@ -53,6 +55,8 @@ jobs:
53 runs-on: ubuntu-latest55 runs-on: ubuntu-latest
54 steps:56 steps:
55 - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v657 - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
58+ with:
59+ persist-credentials: false
56 60
57 - name: Deploy to Gordon61 - name: Deploy to Gordon
58 id: deploy62 id: deploy
@@ -151,6 +155,7 @@ jobs:
151 - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6155 - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
152 with:156 with:
153 ref: ${{ github.event.inputs.tag || github.ref }}157 ref: ${{ github.event.inputs.tag || github.ref }}
158+ persist-credentials: false
154 159
155 - uses: bnema/gordon/.github/actions/deploy@271c36a4cc32fabd91b20f93cd006bdd2a01b181160 - uses: bnema/gordon/.github/actions/deploy@271c36a4cc32fabd91b20f93cd006bdd2a01b181
156 with:161 with:
@@ -175,6 +180,8 @@ jobs:
175 runs-on: ubuntu-latest180 runs-on: ubuntu-latest
176 steps:181 steps:
177 - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6182 - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
183+ with:
184+ persist-credentials: false
178 - uses: bnema/gordon/.github/actions/deploy@271c36a4cc32fabd91b20f93cd006bdd2a01b181185 - uses: bnema/gordon/.github/actions/deploy@271c36a4cc32fabd91b20f93cd006bdd2a01b181
179 with:186 with:
180 registry: registry.mydomain.com187 registry: registry.mydomain.com
@@ -188,6 +195,8 @@ jobs:
188 runs-on: ubuntu-latest195 runs-on: ubuntu-latest
189 steps:196 steps:
190 - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6197 - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
198+ with:
199+ persist-credentials: false
191 - uses: bnema/gordon/.github/actions/deploy@271c36a4cc32fabd91b20f93cd006bdd2a01b181200 - uses: bnema/gordon/.github/actions/deploy@271c36a4cc32fabd91b20f93cd006bdd2a01b181
192 with:201 with:
193 registry: registry.mydomain.com202 registry: registry.mydomain.com
M .github/actions/deploy/action.yml +36 -9
@@ -81,22 +81,45 @@ runs:
8181 INPUT_TAG: ${{ inputs.tag }}
8282 INPUT_REGISTRY: ${{ inputs.registry }}
8383 run: |
84- # Determine image name
85- IMAGE_NAME="${INPUT_IMAGE}"
86- if [[ -z "$IMAGE_NAME" ]]; then
87- IMAGE_NAME="${GITHUB_REPOSITORY#*/}"
88- fi
84+ reject_line_breaks() {
85+ [[ "$2" != *$'\n'* && "$2" != *$'\r'* ]] || {
86+ echo "::error::$1 must not contain line breaks"
87+ exit 1
88+ }
89+ }
90+ reject_line_breaks "registry" "$INPUT_REGISTRY"
91+ reject_line_breaks "image" "$INPUT_IMAGE"
92+ reject_line_breaks "tag" "$INPUT_TAG"
8993
90- # Extract tag from ref (or override)
94+ IMAGE_NAME="${INPUT_IMAGE:-${GITHUB_REPOSITORY#*/}}"
9195 if [[ -n "${INPUT_TAG}" ]]; then
9296 TAG="${INPUT_TAG}"
9397 elif [[ "$GITHUB_REF" == refs/tags/* ]]; then
9498 TAG="${GITHUB_REF#refs/tags/}"
9599 else
96- TAG="$(echo "$GITHUB_SHA" | head -c 7)"
100+ TAG="${GITHUB_SHA:0:7}"
101+ fi
102+
103+ [[ "$INPUT_REGISTRY" =~ ^[a-zA-Z0-9.-]+(:[0-9]{1,5})?$ ]] || {
104+ echo "::error::registry must be a hostname with an optional port"
105+ exit 1
106+ }
107+ if [[ "$INPUT_REGISTRY" =~ :([0-9]+)$ ]]; then
108+ PORT="${BASH_REMATCH[1]}"
109+ [[ "$PORT" != 0* && $((10#$PORT)) -ge 1 && $((10#$PORT)) -le 65535 ]] || {
110+ echo "::error::registry port must be between 1 and 65535 without leading zeros"
111+ exit 1
112+ }
97113 fi
114+ [[ "$IMAGE_NAME" =~ ^[a-z0-9]+([._-][a-z0-9]+)*(/[a-z0-9]+([._-][a-z0-9]+)*)*$ ]] || {
115+ echo "::error::image contains invalid Docker repository components"
116+ exit 1
117+ }
118+ [[ "$TAG" =~ ^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$ ]] || {
119+ echo "::error::tag is not a valid Docker tag"
120+ exit 1
121+ }
98122
99- # Full image reference
100123 FULL_IMAGE="${INPUT_REGISTRY}/${IMAGE_NAME}:${TAG}"
101124
102125 printf 'image=%s\nimage_name=%s\ntag=%s\nregistry=%s\n' \
@@ -130,7 +153,11 @@ runs:
130153 if [[ "${INPUT_PUSH_LATEST}" == "true" ]]; then
131154 TAGS="${TAGS}"$'\n'"${INPUT_REGISTRY}/${META_IMAGE_NAME}:latest"
132155 fi
133- printf "tags<<EOF\n%s\nEOF\n" "$TAGS" >> "$GITHUB_OUTPUT"
156+ DELIMITER="gordon_$(openssl rand -hex 16)"
157+ while grep -Fqx "$DELIMITER" <<< "$TAGS"; do
158+ DELIMITER="gordon_$(openssl rand -hex 16)"
159+ done
160+ printf 'tags<<%s\n%s\n%s\n' "$DELIMITER" "$TAGS" "$DELIMITER" >> "$GITHUB_OUTPUT"
134161
135162 - name: Build and push
136163 id: push
@@ -81,22 +81,45 @@ runs:
81 INPUT_TAG: ${{ inputs.tag }}81 INPUT_TAG: ${{ inputs.tag }}
82 INPUT_REGISTRY: ${{ inputs.registry }}82 INPUT_REGISTRY: ${{ inputs.registry }}
83 run: |83 run: |
84- # Determine image name84+ reject_line_breaks() {
85- IMAGE_NAME="${INPUT_IMAGE}"85+ [[ "$2" != *$'\n'* && "$2" != *$'\r'* ]] || {
86- if [[ -z "$IMAGE_NAME" ]]; then86+ echo "::error::$1 must not contain line breaks"
87- IMAGE_NAME="${GITHUB_REPOSITORY#*/}"87+ exit 1
88- fi88+ }
89+ }
90+ reject_line_breaks "registry" "$INPUT_REGISTRY"
91+ reject_line_breaks "image" "$INPUT_IMAGE"
92+ reject_line_breaks "tag" "$INPUT_TAG"
89 93
90- # Extract tag from ref (or override)94+ IMAGE_NAME="${INPUT_IMAGE:-${GITHUB_REPOSITORY#*/}}"
91 if [[ -n "${INPUT_TAG}" ]]; then95 if [[ -n "${INPUT_TAG}" ]]; then
92 TAG="${INPUT_TAG}"96 TAG="${INPUT_TAG}"
93 elif [[ "$GITHUB_REF" == refs/tags/* ]]; then97 elif [[ "$GITHUB_REF" == refs/tags/* ]]; then
94 TAG="${GITHUB_REF#refs/tags/}"98 TAG="${GITHUB_REF#refs/tags/}"
95 else99 else
96- TAG="$(echo "$GITHUB_SHA" | head -c 7)"100+ TAG="${GITHUB_SHA:0:7}"
101+ fi
102+
103+ [[ "$INPUT_REGISTRY" =~ ^[a-zA-Z0-9.-]+(:[0-9]{1,5})?$ ]] || {
104+ echo "::error::registry must be a hostname with an optional port"
105+ exit 1
106+ }
107+ if [[ "$INPUT_REGISTRY" =~ :([0-9]+)$ ]]; then
108+ PORT="${BASH_REMATCH[1]}"
109+ [[ "$PORT" != 0* && $((10#$PORT)) -ge 1 && $((10#$PORT)) -le 65535 ]] || {
110+ echo "::error::registry port must be between 1 and 65535 without leading zeros"
111+ exit 1
112+ }
97 fi113 fi
114+ [[ "$IMAGE_NAME" =~ ^[a-z0-9]+([._-][a-z0-9]+)*(/[a-z0-9]+([._-][a-z0-9]+)*)*$ ]] || {
115+ echo "::error::image contains invalid Docker repository components"
116+ exit 1
117+ }
118+ [[ "$TAG" =~ ^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$ ]] || {
119+ echo "::error::tag is not a valid Docker tag"
120+ exit 1
121+ }
98 122
99- # Full image reference
100 FULL_IMAGE="${INPUT_REGISTRY}/${IMAGE_NAME}:${TAG}"123 FULL_IMAGE="${INPUT_REGISTRY}/${IMAGE_NAME}:${TAG}"
101 124
102 printf 'image=%s\nimage_name=%s\ntag=%s\nregistry=%s\n' \125 printf 'image=%s\nimage_name=%s\ntag=%s\nregistry=%s\n' \
@@ -130,7 +153,11 @@ runs:
130 if [[ "${INPUT_PUSH_LATEST}" == "true" ]]; then153 if [[ "${INPUT_PUSH_LATEST}" == "true" ]]; then
131 TAGS="${TAGS}"$'\n'"${INPUT_REGISTRY}/${META_IMAGE_NAME}:latest"154 TAGS="${TAGS}"$'\n'"${INPUT_REGISTRY}/${META_IMAGE_NAME}:latest"
132 fi155 fi
133- printf "tags<<EOF\n%s\nEOF\n" "$TAGS" >> "$GITHUB_OUTPUT"156+ DELIMITER="gordon_$(openssl rand -hex 16)"
157+ while grep -Fqx "$DELIMITER" <<< "$TAGS"; do
158+ DELIMITER="gordon_$(openssl rand -hex 16)"
159+ done
160+ printf 'tags<<%s\n%s\n%s\n' "$DELIMITER" "$TAGS" "$DELIMITER" >> "$GITHUB_OUTPUT"
134 161
135 - name: Build and push162 - name: Build and push
136 id: push163 id: push
M .github/workflows/release.yml +1 -0
@@ -16,6 +16,7 @@ jobs:
1616 - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
1717 with:
1818 fetch-depth: 0
19+ persist-credentials: false
1920
2021 - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
2122 with:
@@ -16,6 +16,7 @@ jobs:
16 - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v616 - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
17 with:17 with:
18 fetch-depth: 018 fetch-depth: 0
19+ persist-credentials: false
19 20
20 - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v521 - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
21 with:22 with:
M docs/deployment/rollback.md +11 -3
@@ -159,9 +159,17 @@ jobs:
159159 VERSION: ${{ inputs.version }}
160160 run: |
161161 [[ "$VERSION" =~ ^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$ ]] || exit 1
162- docker pull "$GORDON_REGISTRY/myapp:$VERSION"
163- docker tag "$GORDON_REGISTRY/myapp:$VERSION" "$GORDON_REGISTRY/myapp:latest"
164- docker push "$GORDON_REGISTRY/myapp:latest"
162+ REPOSITORY="$GORDON_REGISTRY/myapp"
163+ SOURCE_IMAGE="$REPOSITORY:$VERSION"
164+ LATEST_IMAGE="$REPOSITORY:latest"
165+ SOURCE_DIGEST="$(docker buildx imagetools inspect "$SOURCE_IMAGE" --format '{{json .Manifest}}' | jq -er '.digest')"
166+ IMMUTABLE_IMAGE="$REPOSITORY@$SOURCE_DIGEST"
167+ docker buildx imagetools create --tag "$LATEST_IMAGE" "$IMMUTABLE_IMAGE"
168+ LATEST_DIGEST="$(docker buildx imagetools inspect "$LATEST_IMAGE" --format '{{json .Manifest}}' | jq -er '.digest')"
169+ [[ "$LATEST_DIGEST" == "$SOURCE_DIGEST" ]] || {
170+ echo "rollback verification failed: latest resolved to $LATEST_DIGEST, expected $SOURCE_DIGEST" >&2
171+ exit 1
172+ }
165173
166174 - name: Summary
167175 env:
@@ -159,9 +159,17 @@ jobs:
159 VERSION: ${{ inputs.version }}159 VERSION: ${{ inputs.version }}
160 run: |160 run: |
161 [[ "$VERSION" =~ ^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$ ]] || exit 1161 [[ "$VERSION" =~ ^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$ ]] || exit 1
162- docker pull "$GORDON_REGISTRY/myapp:$VERSION"162+ REPOSITORY="$GORDON_REGISTRY/myapp"
163- docker tag "$GORDON_REGISTRY/myapp:$VERSION" "$GORDON_REGISTRY/myapp:latest"163+ SOURCE_IMAGE="$REPOSITORY:$VERSION"
164- docker push "$GORDON_REGISTRY/myapp:latest"164+ LATEST_IMAGE="$REPOSITORY:latest"
165+ SOURCE_DIGEST="$(docker buildx imagetools inspect "$SOURCE_IMAGE" --format '{{json .Manifest}}' | jq -er '.digest')"
166+ IMMUTABLE_IMAGE="$REPOSITORY@$SOURCE_DIGEST"
167+ docker buildx imagetools create --tag "$LATEST_IMAGE" "$IMMUTABLE_IMAGE"
168+ LATEST_DIGEST="$(docker buildx imagetools inspect "$LATEST_IMAGE" --format '{{json .Manifest}}' | jq -er '.digest')"
169+ [[ "$LATEST_DIGEST" == "$SOURCE_DIGEST" ]] || {
170+ echo "rollback verification failed: latest resolved to $LATEST_DIGEST, expected $SOURCE_DIGEST" >&2
171+ exit 1
172+ }
165 173
166 - name: Summary174 - name: Summary
167 env:175 env:
M docs/installation.md +14 -5
@@ -20,7 +20,11 @@ Detailed installation guide for production environments.
2020 installer="$(mktemp)"
2121 trap 'rm -f "$installer"' EXIT
2222 curl -fsSL --output "$installer" https://gordon.bnema.dev/install
23- less "$installer"
23+ if command -v less >/dev/null 2>&1; then
24+ less "$installer"
25+ else
26+ cat "$installer"
27+ fi
2428 bash "$installer"
2529 )
2630 ```
@@ -32,42 +36,47 @@ Download and inspect the installer before executing it. The installer verifies t
3236 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.
3337
3438 **Linux (x86_64)**
39+
3540 ```bash
3641 wget https://github.com/bnema/gordon/releases/latest/download/{gordon_linux_amd64.tar.gz,checksums.txt}
37-grep ' gordon_linux_amd64.tar.gz$' checksums.txt | sha256sum --check --strict
42+grep -E '^[[:xdigit:]]{64} ([* ]?)gordon_linux_amd64\.tar\.gz$' checksums.txt | sha256sum --check --strict
3843 tar -xzf gordon_linux_amd64.tar.gz
3944 chmod +x gordon
4045 sudo mv gordon /usr/local/bin/
4146 ```
4247
4348 **Linux (ARM64)** - for Raspberry Pi 4, AWS Graviton, Oracle Ampere, etc.
49+
4450 ```bash
4551 wget https://github.com/bnema/gordon/releases/latest/download/{gordon_linux_arm64.tar.gz,checksums.txt}
46-grep ' gordon_linux_arm64.tar.gz$' checksums.txt | sha256sum --check --strict
52+grep -E '^[[:xdigit:]]{64} ([* ]?)gordon_linux_arm64\.tar\.gz$' checksums.txt | sha256sum --check --strict
4753 tar -xzf gordon_linux_arm64.tar.gz
4854 chmod +x gordon
4955 sudo mv gordon /usr/local/bin/
5056 ```
5157
5258 **macOS (Apple Silicon)**
59+
5360 ```bash
5461 curl -LO https://github.com/bnema/gordon/releases/latest/download/{gordon_darwin_arm64.tar.gz,checksums.txt}
55-grep ' gordon_darwin_arm64.tar.gz$' checksums.txt | shasum -a 256 --check
62+grep -E '^[[:xdigit:]]{64} ([* ]?)gordon_darwin_arm64\.tar\.gz$' checksums.txt | shasum -a 256 --check
5663 tar -xzf gordon_darwin_arm64.tar.gz
5764 chmod +x gordon
5865 sudo mv gordon /usr/local/bin/
5966 ```
6067
6168 **macOS (Intel)**
69+
6270 ```bash
6371 curl -LO https://github.com/bnema/gordon/releases/latest/download/{gordon_darwin_amd64.tar.gz,checksums.txt}
64-grep ' gordon_darwin_amd64.tar.gz$' checksums.txt | shasum -a 256 --check
72+grep -E '^[[:xdigit:]]{64} ([* ]?)gordon_darwin_amd64\.tar\.gz$' checksums.txt | shasum -a 256 --check
6573 tar -xzf gordon_darwin_amd64.tar.gz
6674 chmod +x gordon
6775 sudo mv gordon /usr/local/bin/
6876 ```
6977
7078 Verify installation:
79+
7180 ```bash
7281 gordon version
7382 ```
@@ -20,7 +20,11 @@ Detailed installation guide for production environments.
20 installer="$(mktemp)"20 installer="$(mktemp)"
21 trap 'rm -f "$installer"' EXIT21 trap 'rm -f "$installer"' EXIT
22 curl -fsSL --output "$installer" https://gordon.bnema.dev/install22 curl -fsSL --output "$installer" https://gordon.bnema.dev/install
23- less "$installer"23+ if command -v less >/dev/null 2>&1; then
24+ less "$installer"
25+ else
26+ cat "$installer"
27+ fi
24 bash "$installer"28 bash "$installer"
25 )29 )
26 ```30 ```
@@ -32,42 +36,47 @@ Download and inspect the installer before executing it. The installer verifies t
32 Manual archive installation requires downloading the matching `checksums.txt` release asset and verifying the archive's SHA-256 checksum before extraction. The checksum-verifying installer above is recommended.36 Manual archive installation requires downloading the matching `checksums.txt` release asset and verifying the archive's SHA-256 checksum before extraction. The checksum-verifying installer above is recommended.
33 37
34 **Linux (x86_64)**38 **Linux (x86_64)**
39+
35 ```bash40 ```bash
36 wget https://github.com/bnema/gordon/releases/latest/download/{gordon_linux_amd64.tar.gz,checksums.txt}41 wget https://github.com/bnema/gordon/releases/latest/download/{gordon_linux_amd64.tar.gz,checksums.txt}
37-grep ' gordon_linux_amd64.tar.gz$' checksums.txt | sha256sum --check --strict42+grep -E '^[[:xdigit:]]{64} ([* ]?)gordon_linux_amd64\.tar\.gz$' checksums.txt | sha256sum --check --strict
38 tar -xzf gordon_linux_amd64.tar.gz43 tar -xzf gordon_linux_amd64.tar.gz
39 chmod +x gordon44 chmod +x gordon
40 sudo mv gordon /usr/local/bin/45 sudo mv gordon /usr/local/bin/
41 ```46 ```
42 47
43 **Linux (ARM64)** - for Raspberry Pi 4, AWS Graviton, Oracle Ampere, etc.48 **Linux (ARM64)** - for Raspberry Pi 4, AWS Graviton, Oracle Ampere, etc.
49+
44 ```bash50 ```bash
45 wget https://github.com/bnema/gordon/releases/latest/download/{gordon_linux_arm64.tar.gz,checksums.txt}51 wget https://github.com/bnema/gordon/releases/latest/download/{gordon_linux_arm64.tar.gz,checksums.txt}
46-grep ' gordon_linux_arm64.tar.gz$' checksums.txt | sha256sum --check --strict52+grep -E '^[[:xdigit:]]{64} ([* ]?)gordon_linux_arm64\.tar\.gz$' checksums.txt | sha256sum --check --strict
47 tar -xzf gordon_linux_arm64.tar.gz53 tar -xzf gordon_linux_arm64.tar.gz
48 chmod +x gordon54 chmod +x gordon
49 sudo mv gordon /usr/local/bin/55 sudo mv gordon /usr/local/bin/
50 ```56 ```
51 57
52 **macOS (Apple Silicon)**58 **macOS (Apple Silicon)**
59+
53 ```bash60 ```bash
54 curl -LO https://github.com/bnema/gordon/releases/latest/download/{gordon_darwin_arm64.tar.gz,checksums.txt}61 curl -LO https://github.com/bnema/gordon/releases/latest/download/{gordon_darwin_arm64.tar.gz,checksums.txt}
55-grep ' gordon_darwin_arm64.tar.gz$' checksums.txt | shasum -a 256 --check62+grep -E '^[[:xdigit:]]{64} ([* ]?)gordon_darwin_arm64\.tar\.gz$' checksums.txt | shasum -a 256 --check
56 tar -xzf gordon_darwin_arm64.tar.gz63 tar -xzf gordon_darwin_arm64.tar.gz
57 chmod +x gordon64 chmod +x gordon
58 sudo mv gordon /usr/local/bin/65 sudo mv gordon /usr/local/bin/
59 ```66 ```
60 67
61 **macOS (Intel)**68 **macOS (Intel)**
69+
62 ```bash70 ```bash
63 curl -LO https://github.com/bnema/gordon/releases/latest/download/{gordon_darwin_amd64.tar.gz,checksums.txt}71 curl -LO https://github.com/bnema/gordon/releases/latest/download/{gordon_darwin_amd64.tar.gz,checksums.txt}
64-grep ' gordon_darwin_amd64.tar.gz$' checksums.txt | shasum -a 256 --check72+grep -E '^[[:xdigit:]]{64} ([* ]?)gordon_darwin_amd64\.tar\.gz$' checksums.txt | shasum -a 256 --check
65 tar -xzf gordon_darwin_amd64.tar.gz73 tar -xzf gordon_darwin_amd64.tar.gz
66 chmod +x gordon74 chmod +x gordon
67 sudo mv gordon /usr/local/bin/75 sudo mv gordon /usr/local/bin/
68 ```76 ```
69 77
70 Verify installation:78 Verify installation:
79+
71 ```bash80 ```bash
72 gordon version81 gordon version
73 ```82 ```
M internal/adapters/in/cli/auth.go +20 -9
@@ -207,6 +207,7 @@ available while Gordon is running.`,
207207 // newAuthLoginCmd creates the login command for remote authentication.
208208 func newAuthLoginCmd() *cobra.Command {
209209 var tokenFile string
210+ var jsonOut bool
210211
211212 cmd := &cobra.Command{
212213 Use: "login",
@@ -228,13 +229,14 @@ Examples:
228229 if err != nil {
229230 return fmt.Errorf("read token: %w", err)
230231 }
231- return runAuthLoginWithToken(cmd.Context(), token, cmd.OutOrStdout())
232+ return runAuthLoginWithToken(cmd.Context(), token, cmd.OutOrStdout(), jsonOut)
232233 }
233- return runAuthLoginVerify(cmd.Context(), cmd.OutOrStdout())
234+ return runAuthLoginVerify(cmd.Context(), cmd.OutOrStdout(), jsonOut)
234235 },
235236 }
236237
237238 cmd.Flags().StringVar(&tokenFile, "token-file", "", "Read authentication token from a mode 0600 file")
239+ cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
238240
239241 return cmd
240242 }
@@ -251,7 +253,7 @@ func resolveRequiredRemote(flagToken string) (*remote.ResolvedRemote, error) {
251253 return resolved, nil
252254 }
253255
254-func runAuthLoginWithToken(ctx context.Context, token string, out io.Writer) error {
256+func runAuthLoginWithToken(ctx context.Context, token string, out io.Writer, jsonOut bool) error {
255257 token = strings.TrimSpace(token)
256258 if token == "" {
257259 return fmt.Errorf("token cannot be empty")
@@ -268,19 +270,26 @@ func runAuthLoginWithToken(ctx context.Context, token string, out io.Writer) err
268270 defer cancel()
269271 verified := true
270272 if _, err := client.GetStatus(verifyCtx); err != nil {
271- _ = cliWriteLine(out, styles.RenderWarning(fmt.Sprintf("Token verification failed: %v", err)))
273+ if !jsonOut {
274+ _ = cliWriteLine(out, styles.RenderWarning(fmt.Sprintf("Token verification failed: %v", err)))
275+ }
272276 verified = false
273277 }
274278
275279 // Store token — requires a named remote.
276280 if resolved.Name == "" {
277- _ = cliWriteLine(out, styles.RenderWarning("Ad-hoc URL: token verified but cannot be stored. Use 'gordon remotes add' to save this remote."))
278- return nil
281+ if jsonOut {
282+ return writeJSON(out, map[string]any{"remote": resolved.DisplayName(), "stored": false, "verified": verified})
283+ }
284+ return cliWriteLine(out, styles.RenderWarning("Ad-hoc URL: token verified but cannot be stored. Use 'gordon remotes add' to save this remote."))
279285 }
280286 if err := remote.UpdateRemoteToken(resolved.Name, token); err != nil {
281287 return fmt.Errorf("failed to save token: %w", err)
282288 }
283289
290+ if jsonOut {
291+ return writeJSON(out, map[string]any{"remote": resolved.Name, "stored": true, "verified": verified})
292+ }
284293 _ = cliWriteLine(out, "")
285294 _ = cliWriteLine(out, styles.RenderSuccess(fmt.Sprintf("Token stored for remote '%s'", resolved.Name)))
286295 if verified {
@@ -289,7 +298,7 @@ func runAuthLoginWithToken(ctx context.Context, token string, out io.Writer) err
289298 return nil
290299 }
291300
292-func runAuthLoginVerify(ctx context.Context, out io.Writer) error {
301+func runAuthLoginVerify(ctx context.Context, out io.Writer, jsonOut bool) error {
293302 resolved, err := resolveRequiredRemote(tokenFlag)
294303 if err != nil {
295304 return err
@@ -307,8 +316,10 @@ func runAuthLoginVerify(ctx context.Context, out io.Writer) error {
307316 return fmt.Errorf("authentication failed")
308317 }
309318
310- _ = cliWriteLine(out, styles.RenderSuccess(fmt.Sprintf("Authenticated to '%s'", resolved.DisplayName())))
311- return nil
319+ if jsonOut {
320+ return writeJSON(out, map[string]any{"remote": resolved.DisplayName(), "authenticated": true})
321+ }
322+ return cliWriteLine(out, styles.RenderSuccess(fmt.Sprintf("Authenticated to '%s'", resolved.DisplayName())))
312323 }
313324
314325 // newAuthShowTokenCmd creates the show-token command.
@@ -207,6 +207,7 @@ available while Gordon is running.`,
207 // newAuthLoginCmd creates the login command for remote authentication.207 // newAuthLoginCmd creates the login command for remote authentication.
208 func newAuthLoginCmd() *cobra.Command {208 func newAuthLoginCmd() *cobra.Command {
209 var tokenFile string209 var tokenFile string
210+ var jsonOut bool
210 211
211 cmd := &cobra.Command{212 cmd := &cobra.Command{
212 Use: "login",213 Use: "login",
@@ -228,13 +229,14 @@ Examples:
228 if err != nil {229 if err != nil {
229 return fmt.Errorf("read token: %w", err)230 return fmt.Errorf("read token: %w", err)
230 }231 }
231- return runAuthLoginWithToken(cmd.Context(), token, cmd.OutOrStdout())232+ return runAuthLoginWithToken(cmd.Context(), token, cmd.OutOrStdout(), jsonOut)
232 }233 }
233- return runAuthLoginVerify(cmd.Context(), cmd.OutOrStdout())234+ return runAuthLoginVerify(cmd.Context(), cmd.OutOrStdout(), jsonOut)
234 },235 },
235 }236 }
236 237
237 cmd.Flags().StringVar(&tokenFile, "token-file", "", "Read authentication token from a mode 0600 file")238 cmd.Flags().StringVar(&tokenFile, "token-file", "", "Read authentication token from a mode 0600 file")
239+ cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
238 240
239 return cmd241 return cmd
240 }242 }
@@ -251,7 +253,7 @@ func resolveRequiredRemote(flagToken string) (*remote.ResolvedRemote, error) {
251 return resolved, nil253 return resolved, nil
252 }254 }
253 255
254-func runAuthLoginWithToken(ctx context.Context, token string, out io.Writer) error {256+func runAuthLoginWithToken(ctx context.Context, token string, out io.Writer, jsonOut bool) error {
255 token = strings.TrimSpace(token)257 token = strings.TrimSpace(token)
256 if token == "" {258 if token == "" {
257 return fmt.Errorf("token cannot be empty")259 return fmt.Errorf("token cannot be empty")
@@ -268,19 +270,26 @@ func runAuthLoginWithToken(ctx context.Context, token string, out io.Writer) err
268 defer cancel()270 defer cancel()
269 verified := true271 verified := true
270 if _, err := client.GetStatus(verifyCtx); err != nil {272 if _, err := client.GetStatus(verifyCtx); err != nil {
271- _ = cliWriteLine(out, styles.RenderWarning(fmt.Sprintf("Token verification failed: %v", err)))273+ if !jsonOut {
274+ _ = cliWriteLine(out, styles.RenderWarning(fmt.Sprintf("Token verification failed: %v", err)))
275+ }
272 verified = false276 verified = false
273 }277 }
274 278
275 // Store token — requires a named remote.279 // Store token — requires a named remote.
276 if resolved.Name == "" {280 if resolved.Name == "" {
277- _ = cliWriteLine(out, styles.RenderWarning("Ad-hoc URL: token verified but cannot be stored. Use 'gordon remotes add' to save this remote."))281+ if jsonOut {
278- return nil282+ return writeJSON(out, map[string]any{"remote": resolved.DisplayName(), "stored": false, "verified": verified})
283+ }
284+ return cliWriteLine(out, styles.RenderWarning("Ad-hoc URL: token verified but cannot be stored. Use 'gordon remotes add' to save this remote."))
279 }285 }
280 if err := remote.UpdateRemoteToken(resolved.Name, token); err != nil {286 if err := remote.UpdateRemoteToken(resolved.Name, token); err != nil {
281 return fmt.Errorf("failed to save token: %w", err)287 return fmt.Errorf("failed to save token: %w", err)
282 }288 }
283 289
290+ if jsonOut {
291+ return writeJSON(out, map[string]any{"remote": resolved.Name, "stored": true, "verified": verified})
292+ }
284 _ = cliWriteLine(out, "")293 _ = cliWriteLine(out, "")
285 _ = cliWriteLine(out, styles.RenderSuccess(fmt.Sprintf("Token stored for remote '%s'", resolved.Name)))294 _ = cliWriteLine(out, styles.RenderSuccess(fmt.Sprintf("Token stored for remote '%s'", resolved.Name)))
286 if verified {295 if verified {
@@ -289,7 +298,7 @@ func runAuthLoginWithToken(ctx context.Context, token string, out io.Writer) err
289 return nil298 return nil
290 }299 }
291 300
292-func runAuthLoginVerify(ctx context.Context, out io.Writer) error {301+func runAuthLoginVerify(ctx context.Context, out io.Writer, jsonOut bool) error {
293 resolved, err := resolveRequiredRemote(tokenFlag)302 resolved, err := resolveRequiredRemote(tokenFlag)
294 if err != nil {303 if err != nil {
295 return err304 return err
@@ -307,8 +316,10 @@ func runAuthLoginVerify(ctx context.Context, out io.Writer) error {
307 return fmt.Errorf("authentication failed")316 return fmt.Errorf("authentication failed")
308 }317 }
309 318
310- _ = cliWriteLine(out, styles.RenderSuccess(fmt.Sprintf("Authenticated to '%s'", resolved.DisplayName())))319+ if jsonOut {
311- return nil320+ return writeJSON(out, map[string]any{"remote": resolved.DisplayName(), "authenticated": true})
321+ }
322+ return cliWriteLine(out, styles.RenderSuccess(fmt.Sprintf("Authenticated to '%s'", resolved.DisplayName())))
312 }323 }
313 324
314 // newAuthShowTokenCmd creates the show-token command.325 // newAuthShowTokenCmd creates the show-token command.
M internal/adapters/in/cli/auth_test.go +2 -1
@@ -39,8 +39,9 @@ func TestRunAuthLoginWithToken_StoresToken(t *testing.T) {
3939 t.Cleanup(func() { remoteFlag = origRemote })
4040
4141 var out bytes.Buffer
42- err := runAuthLoginWithToken(context.Background(), "token123", &out)
42+ err := runAuthLoginWithToken(context.Background(), "token123", &out, true)
4343 assert.NoError(t, err)
44+ assert.JSONEq(t, `{"remote":"prod","stored":true,"verified":false}`, out.String())
4445
4546 loaded, err := remote.LoadRemotes("")
4647 assert.NoError(t, err)
@@ -39,8 +39,9 @@ func TestRunAuthLoginWithToken_StoresToken(t *testing.T) {
39 t.Cleanup(func() { remoteFlag = origRemote })39 t.Cleanup(func() { remoteFlag = origRemote })
40 40
41 var out bytes.Buffer41 var out bytes.Buffer
42- err := runAuthLoginWithToken(context.Background(), "token123", &out)42+ err := runAuthLoginWithToken(context.Background(), "token123", &out, true)
43 assert.NoError(t, err)43 assert.NoError(t, err)
44+ assert.JSONEq(t, `{"remote":"prod","stored":true,"verified":false}`, out.String())
44 45
45 loaded, err := remote.LoadRemotes("")46 loaded, err := remote.LoadRemotes("")
46 assert.NoError(t, err)47 assert.NoError(t, err)
M internal/adapters/in/cli/remotes.go +19 -8
@@ -157,6 +157,7 @@ func newRemotesAddCmd() *cobra.Command {
157157 var tokenFile string
158158 var tokenEnv string
159159 var insecureTLS bool
160+ var jsonOut bool
160161
161162 cmd := &cobra.Command{
162163 Use: "add <name> <url>",
@@ -192,12 +193,15 @@ Examples:
192193 }
193194 }
194195
195- fmt.Println(styles.RenderSuccess(fmt.Sprintf("Remote added: %s -> %s", name, url)))
196-
196+ if jsonOut {
197+ return writeJSON(cmd.OutOrStdout(), map[string]any{"name": name, "url": url, "added": true})
198+ }
199+ if err := cliWriteLine(cmd.OutOrStdout(), styles.RenderSuccess(fmt.Sprintf("Remote added: %s -> %s", name, url))); err != nil {
200+ return err
201+ }
197202 if tokenFile == "" && tokenEnv == "" {
198- fmt.Println(styles.Theme.Muted.Render("Tip: Add a token with --token-file or --token-env for authenticated access"))
203+ return cliWriteLine(cmd.OutOrStdout(), styles.Theme.Muted.Render("Tip: Add a token with --token-file or --token-env for authenticated access"))
199204 }
200-
201205 return nil
202206 },
203207 }
@@ -205,6 +209,7 @@ Examples:
205209 cmd.Flags().StringVar(&tokenFile, "token-file", "", "Read authentication token from a mode 0600 file")
206210 cmd.Flags().StringVar(&tokenEnv, "token-env", "", "Environment variable containing token")
207211 cmd.Flags().BoolVar(&insecureTLS, "insecure", false, "Skip TLS certificate verification")
212+ cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
208213
209214 return cmd
210215 }
@@ -320,6 +325,7 @@ Examples:
320325 // newRemotesSetTokenCmd creates the remotes set-token command.
321326 func newRemotesSetTokenCmd() *cobra.Command {
322327 var tokenFile string
328+ var jsonOut bool
323329 cmd := &cobra.Command{
324330 Use: "set-token <name> --token-file <path>",
325331 Short: "Set the token for a remote",
@@ -349,19 +355,24 @@ Examples:
349355 }
350356
351357 if _, exists := remotes[name]; !exists {
352- fmt.Println(styles.RenderError(fmt.Sprintf("Remote '%s' not found", name)))
353- return nil
358+ if jsonOut {
359+ return writeJSON(cmd.OutOrStdout(), map[string]any{"name": name, "updated": false, "error": "remote not found"})
360+ }
361+ return cliWriteLine(cmd.OutOrStdout(), styles.RenderError(fmt.Sprintf("Remote '%s' not found", name)))
354362 }
355363
356364 if err := remote.UpdateRemoteToken(name, token); err != nil {
357365 return fmt.Errorf("failed to update token: %w", err)
358366 }
359367
360- fmt.Println(styles.RenderSuccess(fmt.Sprintf("Token updated for remote '%s'", name)))
361- return nil
368+ if jsonOut {
369+ return writeJSON(cmd.OutOrStdout(), map[string]any{"name": name, "updated": true})
370+ }
371+ return cliWriteLine(cmd.OutOrStdout(), styles.RenderSuccess(fmt.Sprintf("Token updated for remote '%s'", name)))
362372 },
363373 }
364374 cmd.Flags().StringVar(&tokenFile, "token-file", "", "Read authentication token from a mode 0600 file")
375+ cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
365376 _ = cmd.MarkFlagRequired("token-file")
366377 return cmd
367378 }
@@ -157,6 +157,7 @@ func newRemotesAddCmd() *cobra.Command {
157 var tokenFile string157 var tokenFile string
158 var tokenEnv string158 var tokenEnv string
159 var insecureTLS bool159 var insecureTLS bool
160+ var jsonOut bool
160 161
161 cmd := &cobra.Command{162 cmd := &cobra.Command{
162 Use: "add <name> <url>",163 Use: "add <name> <url>",
@@ -192,12 +193,15 @@ Examples:
192 }193 }
193 }194 }
194 195
195- fmt.Println(styles.RenderSuccess(fmt.Sprintf("Remote added: %s -> %s", name, url)))196+ if jsonOut {
196-197+ return writeJSON(cmd.OutOrStdout(), map[string]any{"name": name, "url": url, "added": true})
198+ }
199+ if err := cliWriteLine(cmd.OutOrStdout(), styles.RenderSuccess(fmt.Sprintf("Remote added: %s -> %s", name, url))); err != nil {
200+ return err
201+ }
197 if tokenFile == "" && tokenEnv == "" {202 if tokenFile == "" && tokenEnv == "" {
198- fmt.Println(styles.Theme.Muted.Render("Tip: Add a token with --token-file or --token-env for authenticated access"))203+ return cliWriteLine(cmd.OutOrStdout(), styles.Theme.Muted.Render("Tip: Add a token with --token-file or --token-env for authenticated access"))
199 }204 }
200-
201 return nil205 return nil
202 },206 },
203 }207 }
@@ -205,6 +209,7 @@ Examples:
205 cmd.Flags().StringVar(&tokenFile, "token-file", "", "Read authentication token from a mode 0600 file")209 cmd.Flags().StringVar(&tokenFile, "token-file", "", "Read authentication token from a mode 0600 file")
206 cmd.Flags().StringVar(&tokenEnv, "token-env", "", "Environment variable containing token")210 cmd.Flags().StringVar(&tokenEnv, "token-env", "", "Environment variable containing token")
207 cmd.Flags().BoolVar(&insecureTLS, "insecure", false, "Skip TLS certificate verification")211 cmd.Flags().BoolVar(&insecureTLS, "insecure", false, "Skip TLS certificate verification")
212+ cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
208 213
209 return cmd214 return cmd
210 }215 }
@@ -320,6 +325,7 @@ Examples:
320 // newRemotesSetTokenCmd creates the remotes set-token command.325 // newRemotesSetTokenCmd creates the remotes set-token command.
321 func newRemotesSetTokenCmd() *cobra.Command {326 func newRemotesSetTokenCmd() *cobra.Command {
322 var tokenFile string327 var tokenFile string
328+ var jsonOut bool
323 cmd := &cobra.Command{329 cmd := &cobra.Command{
324 Use: "set-token <name> --token-file <path>",330 Use: "set-token <name> --token-file <path>",
325 Short: "Set the token for a remote",331 Short: "Set the token for a remote",
@@ -349,19 +355,24 @@ Examples:
349 }355 }
350 356
351 if _, exists := remotes[name]; !exists {357 if _, exists := remotes[name]; !exists {
352- fmt.Println(styles.RenderError(fmt.Sprintf("Remote '%s' not found", name)))358+ if jsonOut {
353- return nil359+ return writeJSON(cmd.OutOrStdout(), map[string]any{"name": name, "updated": false, "error": "remote not found"})
360+ }
361+ return cliWriteLine(cmd.OutOrStdout(), styles.RenderError(fmt.Sprintf("Remote '%s' not found", name)))
354 }362 }
355 363
356 if err := remote.UpdateRemoteToken(name, token); err != nil {364 if err := remote.UpdateRemoteToken(name, token); err != nil {
357 return fmt.Errorf("failed to update token: %w", err)365 return fmt.Errorf("failed to update token: %w", err)
358 }366 }
359 367
360- fmt.Println(styles.RenderSuccess(fmt.Sprintf("Token updated for remote '%s'", name)))368+ if jsonOut {
361- return nil369+ return writeJSON(cmd.OutOrStdout(), map[string]any{"name": name, "updated": true})
370+ }
371+ return cliWriteLine(cmd.OutOrStdout(), styles.RenderSuccess(fmt.Sprintf("Token updated for remote '%s'", name)))
362 },372 },
363 }373 }
364 cmd.Flags().StringVar(&tokenFile, "token-file", "", "Read authentication token from a mode 0600 file")374 cmd.Flags().StringVar(&tokenFile, "token-file", "", "Read authentication token from a mode 0600 file")
375+ cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
365 _ = cmd.MarkFlagRequired("token-file")376 _ = cmd.MarkFlagRequired("token-file")
366 return cmd377 return cmd
367 }378 }
M internal/adapters/in/cli/secret_input.go +4 -0
@@ -14,6 +14,10 @@ func readProtectedSecretFile(path string) (string, error) {
1414 if err != nil {
1515 return "", fmt.Errorf("open secret file %s: %w", path, err)
1616 }
17+ if fd < 0 {
18+ return "", fmt.Errorf("open secret file %s: invalid file descriptor %d", path, fd)
19+ }
20+ //nolint:gosec // G115: fd is validated non-negative and originates from unix.Open.
1721 file := os.NewFile(uintptr(fd), path)
1822 if file == nil {
1923 _ = unix.Close(fd)
@@ -14,6 +14,10 @@ func readProtectedSecretFile(path string) (string, error) {
14 if err != nil {14 if err != nil {
15 return "", fmt.Errorf("open secret file %s: %w", path, err)15 return "", fmt.Errorf("open secret file %s: %w", path, err)
16 }16 }
17+ if fd < 0 {
18+ return "", fmt.Errorf("open secret file %s: invalid file descriptor %d", path, fd)
19+ }
20+ //nolint:gosec // G115: fd is validated non-negative and originates from unix.Open.
17 file := os.NewFile(uintptr(fd), path)21 file := os.NewFile(uintptr(fd), path)
18 if file == nil {22 if file == nil {
19 _ = unix.Close(fd)23 _ = unix.Close(fd)
M internal/adapters/in/cli/secrets.go +9 -5
@@ -223,6 +223,7 @@ func getKeyPrefix(isLastAttachment, isLastKey bool) string {
223223 // newSecretsSetCmd creates the secrets set command.
224224 func newSecretsSetCmd() *cobra.Command {
225225 var attachment, fromFile string
226+ var jsonOut bool
226227
227228 cmd := &cobra.Command{
228229 Use: "set <domain> --from-file <path>",
@@ -275,20 +276,23 @@ Examples:
275276 target = fmt.Sprintf("%s [%s]", secretDomain, attachment)
276277 }
277278
279+ if jsonOut {
280+ return writeJSON(cmd.OutOrStdout(), map[string]any{
281+ "domain": secretDomain, "attachment": attachment, "count": len(secrets), "updated": true,
282+ })
283+ }
278284 if len(secrets) == 1 {
279285 for key := range secrets {
280- fmt.Println(styles.RenderSuccess(fmt.Sprintf("Secret set: %s on %s", key, target)))
286+ return cliWriteLine(cmd.OutOrStdout(), styles.RenderSuccess(fmt.Sprintf("Secret set: %s on %s", key, target)))
281287 }
282- } else {
283- fmt.Println(styles.RenderSuccess(fmt.Sprintf("Set %d secrets for %s", len(secrets), target)))
284288 }
285-
286- return nil
289+ return cliWriteLine(cmd.OutOrStdout(), styles.RenderSuccess(fmt.Sprintf("Set %d secrets for %s", len(secrets), target)))
287290 },
288291 }
289292
290293 cmd.Flags().StringVarP(&attachment, "attachment", "a", "", "Target an attachment service (e.g., postgres, redis)")
291294 cmd.Flags().StringVar(&fromFile, "from-file", "", "Read KEY=value lines from a mode 0600 file")
295+ cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
292296 _ = cmd.MarkFlagRequired("from-file")
293297
294298 return cmd
@@ -223,6 +223,7 @@ func getKeyPrefix(isLastAttachment, isLastKey bool) string {
223 // newSecretsSetCmd creates the secrets set command.223 // newSecretsSetCmd creates the secrets set command.
224 func newSecretsSetCmd() *cobra.Command {224 func newSecretsSetCmd() *cobra.Command {
225 var attachment, fromFile string225 var attachment, fromFile string
226+ var jsonOut bool
226 227
227 cmd := &cobra.Command{228 cmd := &cobra.Command{
228 Use: "set <domain> --from-file <path>",229 Use: "set <domain> --from-file <path>",
@@ -275,20 +276,23 @@ Examples:
275 target = fmt.Sprintf("%s [%s]", secretDomain, attachment)276 target = fmt.Sprintf("%s [%s]", secretDomain, attachment)
276 }277 }
277 278
279+ if jsonOut {
280+ return writeJSON(cmd.OutOrStdout(), map[string]any{
281+ "domain": secretDomain, "attachment": attachment, "count": len(secrets), "updated": true,
282+ })
283+ }
278 if len(secrets) == 1 {284 if len(secrets) == 1 {
279 for key := range secrets {285 for key := range secrets {
280- fmt.Println(styles.RenderSuccess(fmt.Sprintf("Secret set: %s on %s", key, target)))286+ return cliWriteLine(cmd.OutOrStdout(), styles.RenderSuccess(fmt.Sprintf("Secret set: %s on %s", key, target)))
281 }287 }
282- } else {
283- fmt.Println(styles.RenderSuccess(fmt.Sprintf("Set %d secrets for %s", len(secrets), target)))
284 }288 }
285-289+ return cliWriteLine(cmd.OutOrStdout(), styles.RenderSuccess(fmt.Sprintf("Set %d secrets for %s", len(secrets), target)))
286- return nil
287 },290 },
288 }291 }
289 292
290 cmd.Flags().StringVarP(&attachment, "attachment", "a", "", "Target an attachment service (e.g., postgres, redis)")293 cmd.Flags().StringVarP(&attachment, "attachment", "a", "", "Target an attachment service (e.g., postgres, redis)")
291 cmd.Flags().StringVar(&fromFile, "from-file", "", "Read KEY=value lines from a mode 0600 file")294 cmd.Flags().StringVar(&fromFile, "from-file", "", "Read KEY=value lines from a mode 0600 file")
295+ cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
292 _ = cmd.MarkFlagRequired("from-file")296 _ = cmd.MarkFlagRequired("from-file")
293 297
294 return cmd298 return cmd
M internal/adapters/in/http/registry/handler_test.go +16 -11
@@ -13,6 +13,7 @@ import (
1313 "github.com/bnema/zerowrap"
1414 "github.com/stretchr/testify/assert"
1515 "github.com/stretchr/testify/mock"
16+ "github.com/stretchr/testify/require"
1617
1718 inmocks "github.com/bnema/gordon/internal/boundaries/in/mocks"
1819 "github.com/bnema/gordon/internal/domain"
@@ -204,6 +205,8 @@ func TestHandler_GetBlob_Success(t *testing.T) {
204205 registrySvc := inmocks.NewMockRegistryService(t)
205206
206207 handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize)
208+ server := httptest.NewServer(handler)
209+ defer server.Close()
207210
208211 // Create a temp file for http.ServeFile
209212 tmpFile, err := os.CreateTemp("", "blob-*")
@@ -216,28 +219,30 @@ func TestHandler_GetBlob_Success(t *testing.T) {
216219
217220 registrySvc.EXPECT().GetBlobPath(mock.Anything, "myapp", "sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4").Return(tmpFile.Name(), nil)
218221
219- req := httptest.NewRequest("GET", "/v2/myapp/blobs/sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4", nil)
220- rec := httptest.NewRecorder()
221-
222- handler.ServeHTTP(rec, req)
222+ resp, err := server.Client().Get(server.URL + "/v2/myapp/blobs/sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4")
223+ require.NoError(t, err)
224+ defer resp.Body.Close()
225+ body, err := io.ReadAll(resp.Body)
226+ require.NoError(t, err)
223227
224- assert.Equal(t, http.StatusOK, rec.Code)
225- assert.Equal(t, blobContent, rec.Body.Bytes())
228+ assert.Equal(t, http.StatusOK, resp.StatusCode)
229+ assert.Equal(t, blobContent, body)
226230 }
227231
228232 func TestHandler_GetBlob_NotFound(t *testing.T) {
229233 registrySvc := inmocks.NewMockRegistryService(t)
230234
231235 handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize)
236+ server := httptest.NewServer(handler)
237+ defer server.Close()
232238
233239 registrySvc.EXPECT().GetBlobPath(mock.Anything, "myapp", "sha256:0000000000000000000000000000000000000000000000000000000000000000").Return("", assert.AnError)
234240
235- req := httptest.NewRequest("GET", "/v2/myapp/blobs/sha256:0000000000000000000000000000000000000000000000000000000000000000", nil)
236- rec := httptest.NewRecorder()
237-
238- handler.ServeHTTP(rec, req)
241+ resp, err := server.Client().Get(server.URL + "/v2/myapp/blobs/sha256:0000000000000000000000000000000000000000000000000000000000000000")
242+ require.NoError(t, err)
243+ defer resp.Body.Close()
239244
240- assert.Equal(t, http.StatusNotFound, rec.Code)
245+ assert.Equal(t, http.StatusNotFound, resp.StatusCode)
241246 }
242247
243248 func TestHandler_BlobRoutes_MethodNotAllowed(t *testing.T) {
@@ -13,6 +13,7 @@ import (
13 "github.com/bnema/zerowrap"13 "github.com/bnema/zerowrap"
14 "github.com/stretchr/testify/assert"14 "github.com/stretchr/testify/assert"
15 "github.com/stretchr/testify/mock"15 "github.com/stretchr/testify/mock"
16+ "github.com/stretchr/testify/require"
16 17
17 inmocks "github.com/bnema/gordon/internal/boundaries/in/mocks"18 inmocks "github.com/bnema/gordon/internal/boundaries/in/mocks"
18 "github.com/bnema/gordon/internal/domain"19 "github.com/bnema/gordon/internal/domain"
@@ -204,6 +205,8 @@ func TestHandler_GetBlob_Success(t *testing.T) {
204 registrySvc := inmocks.NewMockRegistryService(t)205 registrySvc := inmocks.NewMockRegistryService(t)
205 206
206 handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize)207 handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize)
208+ server := httptest.NewServer(handler)
209+ defer server.Close()
207 210
208 // Create a temp file for http.ServeFile211 // Create a temp file for http.ServeFile
209 tmpFile, err := os.CreateTemp("", "blob-*")212 tmpFile, err := os.CreateTemp("", "blob-*")
@@ -216,28 +219,30 @@ func TestHandler_GetBlob_Success(t *testing.T) {
216 219
217 registrySvc.EXPECT().GetBlobPath(mock.Anything, "myapp", "sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4").Return(tmpFile.Name(), nil)220 registrySvc.EXPECT().GetBlobPath(mock.Anything, "myapp", "sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4").Return(tmpFile.Name(), nil)
218 221
219- req := httptest.NewRequest("GET", "/v2/myapp/blobs/sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4", nil)222+ resp, err := server.Client().Get(server.URL + "/v2/myapp/blobs/sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4")
220- rec := httptest.NewRecorder()223+ require.NoError(t, err)
221-224+ defer resp.Body.Close()
222- handler.ServeHTTP(rec, req)225+ body, err := io.ReadAll(resp.Body)
226+ require.NoError(t, err)
223 227
224- assert.Equal(t, http.StatusOK, rec.Code)228+ assert.Equal(t, http.StatusOK, resp.StatusCode)
225- assert.Equal(t, blobContent, rec.Body.Bytes())229+ assert.Equal(t, blobContent, body)
226 }230 }
227 231
228 func TestHandler_GetBlob_NotFound(t *testing.T) {232 func TestHandler_GetBlob_NotFound(t *testing.T) {
229 registrySvc := inmocks.NewMockRegistryService(t)233 registrySvc := inmocks.NewMockRegistryService(t)
230 234
231 handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize)235 handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize)
236+ server := httptest.NewServer(handler)
237+ defer server.Close()
232 238
233 registrySvc.EXPECT().GetBlobPath(mock.Anything, "myapp", "sha256:0000000000000000000000000000000000000000000000000000000000000000").Return("", assert.AnError)239 registrySvc.EXPECT().GetBlobPath(mock.Anything, "myapp", "sha256:0000000000000000000000000000000000000000000000000000000000000000").Return("", assert.AnError)
234 240
235- req := httptest.NewRequest("GET", "/v2/myapp/blobs/sha256:0000000000000000000000000000000000000000000000000000000000000000", nil)241+ resp, err := server.Client().Get(server.URL + "/v2/myapp/blobs/sha256:0000000000000000000000000000000000000000000000000000000000000000")
236- rec := httptest.NewRecorder()242+ require.NoError(t, err)
237-243+ defer resp.Body.Close()
238- handler.ServeHTTP(rec, req)
239 244
240- assert.Equal(t, http.StatusNotFound, rec.Code)245+ assert.Equal(t, http.StatusNotFound, resp.StatusCode)
241 }246 }
242 247
243 func TestHandler_BlobRoutes_MethodNotAllowed(t *testing.T) {248 func TestHandler_BlobRoutes_MethodNotAllowed(t *testing.T) {
M internal/adapters/out/docker/runtime.go +24 -4
@@ -6,6 +6,7 @@ import (
66 "bytes"
77 "context"
88 "encoding/base64"
9+ "encoding/binary"
910 "encoding/json"
1011 "errors"
1112 "fmt"
@@ -1736,11 +1737,11 @@ type boundedBuffer struct {
17361737 func (w *boundedBuffer) Write(p []byte) (int, error) {
17371738 remaining := w.limit - w.Len()
17381739 if remaining <= 0 {
1739- return 0, fmt.Errorf("container exec output exceeds %d bytes", w.limit)
1740+ return 0, fmt.Errorf("%w: limit is %d bytes", domain.ErrExecOutputExceeded, w.limit)
17401741 }
17411742 if len(p) > remaining {
17421743 _, _ = w.Buffer.Write(p[:remaining])
1743- return remaining, fmt.Errorf("container exec output exceeds %d bytes", w.limit)
1744+ return remaining, fmt.Errorf("%w: limit is %d bytes", domain.ErrExecOutputExceeded, w.limit)
17441745 }
17451746 return w.Buffer.Write(p)
17461747 }
@@ -1748,9 +1749,28 @@ func (w *boundedBuffer) Write(p []byte) (int, error) {
17481749 func parseExecOutput(reader io.Reader) ([]byte, []byte, error) {
17491750 stdout := boundedBuffer{limit: maxExecOutputSize}
17501751 stderr := boundedBuffer{limit: maxExecOutputSize}
1752+ buffers := map[byte]*boundedBuffer{1: &stdout, 2: &stderr}
1753+ var header [8]byte
17511754
1752- if _, err := stdcopy.StdCopy(&stdout, &stderr, reader); err != nil {
1753- return nil, nil, err
1755+ for {
1756+ if _, err := io.ReadFull(reader, header[:]); err != nil {
1757+ if errors.Is(err, io.EOF) {
1758+ break
1759+ }
1760+ return nil, nil, fmt.Errorf("read Docker exec frame header: %w", err)
1761+ }
1762+ output, ok := buffers[header[0]]
1763+ if !ok {
1764+ return nil, nil, fmt.Errorf("invalid Docker exec stream %d", header[0])
1765+ }
1766+ payloadSize := int64(binary.BigEndian.Uint32(header[4:]))
1767+ remaining := int64(output.limit - output.Len())
1768+ if payloadSize > remaining {
1769+ return nil, nil, fmt.Errorf("%w: frame is %d bytes with %d bytes remaining", domain.ErrExecOutputExceeded, payloadSize, remaining)
1770+ }
1771+ if _, err := io.CopyN(output, reader, payloadSize); err != nil {
1772+ return nil, nil, fmt.Errorf("read Docker exec frame payload: %w", err)
1773+ }
17541774 }
17551775
17561776 return stdout.Bytes(), stderr.Bytes(), nil
@@ -6,6 +6,7 @@ import (
6 "bytes"6 "bytes"
7 "context"7 "context"
8 "encoding/base64"8 "encoding/base64"
9+ "encoding/binary"
9 "encoding/json"10 "encoding/json"
10 "errors"11 "errors"
11 "fmt"12 "fmt"
@@ -1736,11 +1737,11 @@ type boundedBuffer struct {
1736 func (w *boundedBuffer) Write(p []byte) (int, error) {1737 func (w *boundedBuffer) Write(p []byte) (int, error) {
1737 remaining := w.limit - w.Len()1738 remaining := w.limit - w.Len()
1738 if remaining <= 0 {1739 if remaining <= 0 {
1739- return 0, fmt.Errorf("container exec output exceeds %d bytes", w.limit)1740+ return 0, fmt.Errorf("%w: limit is %d bytes", domain.ErrExecOutputExceeded, w.limit)
1740 }1741 }
1741 if len(p) > remaining {1742 if len(p) > remaining {
1742 _, _ = w.Buffer.Write(p[:remaining])1743 _, _ = w.Buffer.Write(p[:remaining])
1743- return remaining, fmt.Errorf("container exec output exceeds %d bytes", w.limit)1744+ return remaining, fmt.Errorf("%w: limit is %d bytes", domain.ErrExecOutputExceeded, w.limit)
1744 }1745 }
1745 return w.Buffer.Write(p)1746 return w.Buffer.Write(p)
1746 }1747 }
@@ -1748,9 +1749,28 @@ func (w *boundedBuffer) Write(p []byte) (int, error) {
1748 func parseExecOutput(reader io.Reader) ([]byte, []byte, error) {1749 func parseExecOutput(reader io.Reader) ([]byte, []byte, error) {
1749 stdout := boundedBuffer{limit: maxExecOutputSize}1750 stdout := boundedBuffer{limit: maxExecOutputSize}
1750 stderr := boundedBuffer{limit: maxExecOutputSize}1751 stderr := boundedBuffer{limit: maxExecOutputSize}
1752+ buffers := map[byte]*boundedBuffer{1: &stdout, 2: &stderr}
1753+ var header [8]byte
1751 1754
1752- if _, err := stdcopy.StdCopy(&stdout, &stderr, reader); err != nil {1755+ for {
1753- return nil, nil, err1756+ if _, err := io.ReadFull(reader, header[:]); err != nil {
1757+ if errors.Is(err, io.EOF) {
1758+ break
1759+ }
1760+ return nil, nil, fmt.Errorf("read Docker exec frame header: %w", err)
1761+ }
1762+ output, ok := buffers[header[0]]
1763+ if !ok {
1764+ return nil, nil, fmt.Errorf("invalid Docker exec stream %d", header[0])
1765+ }
1766+ payloadSize := int64(binary.BigEndian.Uint32(header[4:]))
1767+ remaining := int64(output.limit - output.Len())
1768+ if payloadSize > remaining {
1769+ return nil, nil, fmt.Errorf("%w: frame is %d bytes with %d bytes remaining", domain.ErrExecOutputExceeded, payloadSize, remaining)
1770+ }
1771+ if _, err := io.CopyN(output, reader, payloadSize); err != nil {
1772+ return nil, nil, fmt.Errorf("read Docker exec frame payload: %w", err)
1773+ }
1754 }1774 }
1755 1775
1756 return stdout.Bytes(), stderr.Bytes(), nil1776 return stdout.Bytes(), stderr.Bytes(), nil
M internal/adapters/out/docker/runtime_exec_test.go +14 -0
@@ -11,6 +11,8 @@ import (
1111
1212 "github.com/stretchr/testify/assert"
1313 "github.com/stretchr/testify/require"
14+
15+ "github.com/bnema/gordon/internal/domain"
1416 )
1517
1618 func TestParseExecOutput_SplitsStdoutAndStderr(t *testing.T) {
@@ -23,6 +25,18 @@ func TestParseExecOutput_SplitsStdoutAndStderr(t *testing.T) {
2325 assert.Equal(t, []byte("warn\n"), stderr)
2426 }
2527
28+func TestParseExecOutput_RejectsOversizedFrameBeforeReadingPayload(t *testing.T) {
29+ var header [8]byte
30+ header[0] = 1
31+ binary.BigEndian.PutUint32(header[4:], maxExecOutputSize+1)
32+
33+ stdout, stderr, err := parseExecOutput(bytes.NewReader(header[:]))
34+
35+ assert.ErrorIs(t, err, domain.ErrExecOutputExceeded)
36+ assert.Nil(t, stdout)
37+ assert.Nil(t, stderr)
38+}
39+
2640 func TestRuntime_ExecInContainer_RejectsEmptyCommand(t *testing.T) {
2741 r := &Runtime{}
2842
@@ -11,6 +11,8 @@ import (
11 11
12 "github.com/stretchr/testify/assert"12 "github.com/stretchr/testify/assert"
13 "github.com/stretchr/testify/require"13 "github.com/stretchr/testify/require"
14+
15+ "github.com/bnema/gordon/internal/domain"
14 )16 )
15 17
16 func TestParseExecOutput_SplitsStdoutAndStderr(t *testing.T) {18 func TestParseExecOutput_SplitsStdoutAndStderr(t *testing.T) {
@@ -23,6 +25,18 @@ func TestParseExecOutput_SplitsStdoutAndStderr(t *testing.T) {
23 assert.Equal(t, []byte("warn\n"), stderr)25 assert.Equal(t, []byte("warn\n"), stderr)
24 }26 }
25 27
28+func TestParseExecOutput_RejectsOversizedFrameBeforeReadingPayload(t *testing.T) {
29+ var header [8]byte
30+ header[0] = 1
31+ binary.BigEndian.PutUint32(header[4:], maxExecOutputSize+1)
32+
33+ stdout, stderr, err := parseExecOutput(bytes.NewReader(header[:]))
34+
35+ assert.ErrorIs(t, err, domain.ErrExecOutputExceeded)
36+ assert.Nil(t, stdout)
37+ assert.Nil(t, stderr)
38+}
39+
26 func TestRuntime_ExecInContainer_RejectsEmptyCommand(t *testing.T) {40 func TestRuntime_ExecInContainer_RejectsEmptyCommand(t *testing.T) {
27 r := &Runtime{}41 r := &Runtime{}
28 42
M internal/app/run.go +4 -3
@@ -84,6 +84,7 @@ import (
8484 "github.com/bnema/gordon/internal/usecase/proxy"
8585 "github.com/bnema/gordon/internal/usecase/publictls"
8686 registrySvc "github.com/bnema/gordon/internal/usecase/registry"
87+ "github.com/bnema/gordon/internal/usecase/registrystate"
8788 secretsSvc "github.com/bnema/gordon/internal/usecase/secrets"
8889 servicecfg "github.com/bnema/gordon/internal/usecase/services"
8990 "github.com/bnema/gordon/internal/usecase/traffic"
@@ -747,9 +748,9 @@ func (si *serviceInit) initRuntimeAndProxy() error {
747748 return err
748749 }
749750
750- registryMutationMu := &sync.RWMutex{}
751- si.svc.registrySvc = registrySvc.NewService(si.svc.blobStorage, si.svc.manifestStorage, si.svc.eventBus, registryMutationMu)
752- si.svc.imageSvc = images.NewService(si.svc.runtime, si.svc.manifestStorage, si.svc.blobStorage, si.log, registryMutationMu)
751+ registryState := registrystate.New()
752+ si.svc.registrySvc = registrySvc.NewService(si.svc.blobStorage, si.svc.manifestStorage, si.svc.eventBus, registryState)
753+ si.svc.imageSvc = images.NewService(si.svc.runtime, si.svc.manifestStorage, si.svc.blobStorage, si.log, registryState)
753754 si.svc.volumeSvc = volumesSvc.NewService(si.svc.runtime)
754755
755756 injectTelemetryMetrics(si.cfg, si.svc, si.log)
@@ -84,6 +84,7 @@ import (
84 "github.com/bnema/gordon/internal/usecase/proxy"84 "github.com/bnema/gordon/internal/usecase/proxy"
85 "github.com/bnema/gordon/internal/usecase/publictls"85 "github.com/bnema/gordon/internal/usecase/publictls"
86 registrySvc "github.com/bnema/gordon/internal/usecase/registry"86 registrySvc "github.com/bnema/gordon/internal/usecase/registry"
87+ "github.com/bnema/gordon/internal/usecase/registrystate"
87 secretsSvc "github.com/bnema/gordon/internal/usecase/secrets"88 secretsSvc "github.com/bnema/gordon/internal/usecase/secrets"
88 servicecfg "github.com/bnema/gordon/internal/usecase/services"89 servicecfg "github.com/bnema/gordon/internal/usecase/services"
89 "github.com/bnema/gordon/internal/usecase/traffic"90 "github.com/bnema/gordon/internal/usecase/traffic"
@@ -747,9 +748,9 @@ func (si *serviceInit) initRuntimeAndProxy() error {
747 return err748 return err
748 }749 }
749 750
750- registryMutationMu := &sync.RWMutex{}751+ registryState := registrystate.New()
751- si.svc.registrySvc = registrySvc.NewService(si.svc.blobStorage, si.svc.manifestStorage, si.svc.eventBus, registryMutationMu)752+ si.svc.registrySvc = registrySvc.NewService(si.svc.blobStorage, si.svc.manifestStorage, si.svc.eventBus, registryState)
752- si.svc.imageSvc = images.NewService(si.svc.runtime, si.svc.manifestStorage, si.svc.blobStorage, si.log, registryMutationMu)753+ si.svc.imageSvc = images.NewService(si.svc.runtime, si.svc.manifestStorage, si.svc.blobStorage, si.log, registryState)
753 si.svc.volumeSvc = volumesSvc.NewService(si.svc.runtime)754 si.svc.volumeSvc = volumesSvc.NewService(si.svc.runtime)
754 755
755 injectTelemetryMetrics(si.cfg, si.svc, si.log)756 injectTelemetryMetrics(si.cfg, si.svc, si.log)
M internal/domain/errors.go +15 -12
@@ -28,13 +28,14 @@ var (
2828 ErrNoTargetAvailable = errors.New("no target available for route")
2929
3030 // Registry errors
31- ErrManifestNotFound = errors.New("manifest not found")
32- ErrBlobNotFound = errors.New("blob not found")
33- ErrUploadNotFound = errors.New("upload not found")
34- ErrInvalidDigest = errors.New("invalid digest")
35- ErrDigestMismatch = errors.New("digest mismatch")
36- ErrUnauthorized = errors.New("unauthorized")
37- ErrBlobSizeExceeded = errors.New("blob size exceeds maximum")
31+ ErrManifestNotFound = errors.New("manifest not found")
32+ ErrBlobNotFound = errors.New("blob not found")
33+ ErrUploadNotFound = errors.New("upload not found")
34+ ErrInvalidDigest = errors.New("invalid digest")
35+ ErrDigestMismatch = errors.New("digest mismatch")
36+ ErrUnauthorized = errors.New("unauthorized")
37+ ErrBlobSizeExceeded = errors.New("blob size exceeds maximum")
38+ ErrExecOutputExceeded = errors.New("container exec output exceeds maximum")
3839
3940 // Network errors
4041 ErrNetworkNotFound = errors.New("network not found")
@@ -58,11 +59,13 @@ var (
5859 ErrRouteConflict = errors.New("route conflicts with existing configuration")
5960
6061 // Environment errors
61- ErrEnvFileNotFound = errors.New("environment file not found")
62- ErrSecretNotFound = errors.New("secret not found")
63- ErrSecretsAlreadyExist = errors.New("secrets already exist")
64- ErrProviderNotFound = errors.New("secret provider not found")
65- ErrInvalidContainerName = errors.New("invalid container name")
62+ ErrEnvFileNotFound = errors.New("environment file not found")
63+ ErrSecretNotFound = errors.New("secret not found")
64+ ErrSecretsAlreadyExist = errors.New("secrets already exist")
65+ ErrProviderNotFound = errors.New("secret provider not found")
66+ ErrInvalidContainerName = errors.New("invalid container name")
67+ ErrAttachmentOwnershipMismatch = errors.New("attachment ownership mismatch")
68+ ErrReadinessLogSizeExceeded = errors.New("readiness log exceeds maximum")
6669
6770 // Authentication errors
6871 ErrInvalidToken = errors.New("invalid token")
@@ -28,13 +28,14 @@ var (
28 ErrNoTargetAvailable = errors.New("no target available for route")28 ErrNoTargetAvailable = errors.New("no target available for route")
29 29
30 // Registry errors30 // Registry errors
31- ErrManifestNotFound = errors.New("manifest not found")31+ ErrManifestNotFound = errors.New("manifest not found")
32- ErrBlobNotFound = errors.New("blob not found")32+ ErrBlobNotFound = errors.New("blob not found")
33- ErrUploadNotFound = errors.New("upload not found")33+ ErrUploadNotFound = errors.New("upload not found")
34- ErrInvalidDigest = errors.New("invalid digest")34+ ErrInvalidDigest = errors.New("invalid digest")
35- ErrDigestMismatch = errors.New("digest mismatch")35+ ErrDigestMismatch = errors.New("digest mismatch")
36- ErrUnauthorized = errors.New("unauthorized")36+ ErrUnauthorized = errors.New("unauthorized")
37- ErrBlobSizeExceeded = errors.New("blob size exceeds maximum")37+ ErrBlobSizeExceeded = errors.New("blob size exceeds maximum")
38+ ErrExecOutputExceeded = errors.New("container exec output exceeds maximum")
38 39
39 // Network errors40 // Network errors
40 ErrNetworkNotFound = errors.New("network not found")41 ErrNetworkNotFound = errors.New("network not found")
@@ -58,11 +59,13 @@ var (
58 ErrRouteConflict = errors.New("route conflicts with existing configuration")59 ErrRouteConflict = errors.New("route conflicts with existing configuration")
59 60
60 // Environment errors61 // Environment errors
61- ErrEnvFileNotFound = errors.New("environment file not found")62+ ErrEnvFileNotFound = errors.New("environment file not found")
62- ErrSecretNotFound = errors.New("secret not found")63+ ErrSecretNotFound = errors.New("secret not found")
63- ErrSecretsAlreadyExist = errors.New("secrets already exist")64+ ErrSecretsAlreadyExist = errors.New("secrets already exist")
64- ErrProviderNotFound = errors.New("secret provider not found")65+ ErrProviderNotFound = errors.New("secret provider not found")
65- ErrInvalidContainerName = errors.New("invalid container name")66+ ErrInvalidContainerName = errors.New("invalid container name")
67+ ErrAttachmentOwnershipMismatch = errors.New("attachment ownership mismatch")
68+ ErrReadinessLogSizeExceeded = errors.New("readiness log exceeds maximum")
66 69
67 // Authentication errors70 // Authentication errors
68 ErrInvalidToken = errors.New("invalid token")71 ErrInvalidToken = errors.New("invalid token")
M internal/usecase/auth/service.go +8 -7
@@ -515,13 +515,14 @@ func (s *Service) ExtendToken(ctx context.Context, tokenString string) (string,
515515 newExpiresAt := now.Add(tokenExtensionTTL)
516516
517517 newClaims := jwt.MapClaims{
518- "jti": tokenClaims.ID, // Reuse existing JTI to avoid invalidating concurrent requests
519- "sub": tokenClaims.Subject,
520- "iss": TokenIssuer,
521- "iat": now.Unix(), // Update to current time (token is being re-issued)
522- "nbf": now.Unix(), // SECURITY: not-before matches issuance time
523- "scopes": tokenClaims.Scopes,
524- "exp": newExpiresAt.Unix(),
518+ "jti": tokenClaims.ID, // Reuse existing JTI to avoid invalidating concurrent requests
519+ "sub": tokenClaims.Subject,
520+ "iss": TokenIssuer,
521+ "iat": now.Unix(), // Update to current time (token is being re-issued)
522+ "nbf": now.Unix(), // SECURITY: not-before matches issuance time
523+ "scopes": tokenClaims.Scopes,
524+ "exp": newExpiresAt.Unix(),
525+ "token_type": "stored",
525526 }
526527
527528 jwtToken := jwt.NewWithClaims(jwt.SigningMethodHS256, newClaims)
@@ -515,13 +515,14 @@ func (s *Service) ExtendToken(ctx context.Context, tokenString string) (string,
515 newExpiresAt := now.Add(tokenExtensionTTL)515 newExpiresAt := now.Add(tokenExtensionTTL)
516 516
517 newClaims := jwt.MapClaims{517 newClaims := jwt.MapClaims{
518- "jti": tokenClaims.ID, // Reuse existing JTI to avoid invalidating concurrent requests518+ "jti": tokenClaims.ID, // Reuse existing JTI to avoid invalidating concurrent requests
519- "sub": tokenClaims.Subject,519+ "sub": tokenClaims.Subject,
520- "iss": TokenIssuer,520+ "iss": TokenIssuer,
521- "iat": now.Unix(), // Update to current time (token is being re-issued)521+ "iat": now.Unix(), // Update to current time (token is being re-issued)
522- "nbf": now.Unix(), // SECURITY: not-before matches issuance time522+ "nbf": now.Unix(), // SECURITY: not-before matches issuance time
523- "scopes": tokenClaims.Scopes,523+ "scopes": tokenClaims.Scopes,
524- "exp": newExpiresAt.Unix(),524+ "exp": newExpiresAt.Unix(),
525+ "token_type": "stored",
525 }526 }
526 527
527 jwtToken := jwt.NewWithClaims(jwt.SigningMethodHS256, newClaims)528 jwtToken := jwt.NewWithClaims(jwt.SigningMethodHS256, newClaims)
M internal/usecase/auth/service_test.go +1 -0
@@ -802,6 +802,7 @@ func TestExtendTokenSlidesExpiry(t *testing.T) {
802802 expectedExpiry := time.Now().Add(24 * time.Hour)
803803 actualExpiry := time.Unix(claims.ExpiresAt, 0)
804804 assert.WithinDuration(t, expectedExpiry, actualExpiry, 5*time.Minute, "expiry should be ~24h from now")
805+ assert.Equal(t, "stored", claims.TokenType)
805806 }
806807
807808 func TestExtendTokenDebounce(t *testing.T) {
@@ -802,6 +802,7 @@ func TestExtendTokenSlidesExpiry(t *testing.T) {
802 expectedExpiry := time.Now().Add(24 * time.Hour)802 expectedExpiry := time.Now().Add(24 * time.Hour)
803 actualExpiry := time.Unix(claims.ExpiresAt, 0)803 actualExpiry := time.Unix(claims.ExpiresAt, 0)
804 assert.WithinDuration(t, expectedExpiry, actualExpiry, 5*time.Minute, "expiry should be ~24h from now")804 assert.WithinDuration(t, expectedExpiry, actualExpiry, 5*time.Minute, "expiry should be ~24h from now")
805+ assert.Equal(t, "stored", claims.TokenType)
805 }806 }
806 807
807 func TestExtendTokenDebounce(t *testing.T) {808 func TestExtendTokenDebounce(t *testing.T) {
M internal/usecase/container/service.go +40 -6
@@ -2540,8 +2540,13 @@ func (s *Service) setupVolumes(ctx context.Context, domainName, imageRef string)
25402540 continue
25412541 }
25422542 if legacyExists {
2543- volumes[path] = legacyName
2544- continue
2543+ owned, ownershipErr := s.legacyVolumeOwnedByDomain(ctx, legacyName, domainName)
2544+ if ownershipErr != nil {
2545+ log.Warn().Err(ownershipErr).Str("volume", legacyName).Msg("could not verify legacy volume ownership; using stable volume name")
2546+ } else if owned {
2547+ volumes[path] = legacyName
2548+ continue
2549+ }
25452550 }
25462551
25472552 exists, err := s.runtime.VolumeExists(ctx, name)
@@ -3126,6 +3131,9 @@ func (s *Service) resolveExistingAttachment(ctx context.Context, ownerDomain, se
31263131 containerName := fmt.Sprintf("gordon-%s-%s", sanitizeName(ownerDomain), serviceName)
31273132 existingContainer := s.findContainerByName(ctx, containerName)
31283133 if existingContainer != nil {
3134+ if err := validateAttachmentOwnership(existingContainer, ownerDomain, containerName); err != nil {
3135+ return nil, err
3136+ }
31293137 return existingContainer, nil
31303138 }
31313139
@@ -3135,10 +3143,8 @@ func (s *Service) resolveExistingAttachment(ctx context.Context, ownerDomain, se
31353143 return nil, nil
31363144 }
31373145
3138- if existingContainer.Labels[domain.LabelManaged] != "true" ||
3139- existingContainer.Labels[domain.LabelAttachment] != "true" ||
3140- existingContainer.Labels[domain.LabelAttachedTo] != ownerDomain {
3141- return nil, fmt.Errorf("legacy attachment name %q is owned by another workload", containerNameLegacy)
3146+ if err := validateAttachmentOwnership(existingContainer, ownerDomain, containerNameLegacy); err != nil {
3147+ return nil, err
31423148 }
31433149 log.Info().Str("container_name", containerNameLegacy).Msg("found attachment with legacy naming, will be replaced")
31443150 if err := s.runtime.StopContainer(ctx, existingContainer.ID); err != nil {
@@ -3151,6 +3157,15 @@ func (s *Service) resolveExistingAttachment(ctx context.Context, ownerDomain, se
31513157 return nil, nil
31523158 }
31533159
3160+func validateAttachmentOwnership(container *domain.Container, ownerDomain, containerName string) error {
3161+ if container.Labels[domain.LabelManaged] != "true" ||
3162+ container.Labels[domain.LabelAttachment] != "true" ||
3163+ container.Labels[domain.LabelAttachedTo] != ownerDomain {
3164+ return fmt.Errorf("%w: attachment name %q is owned by another workload", domain.ErrAttachmentOwnershipMismatch, containerName)
3165+ }
3166+ return nil
3167+}
3168+
31543169 // Utility functions
31553170
31563171 func managedContainerName(domainName string) string {
@@ -3211,6 +3226,25 @@ func legacyVolumeName(prefix, domainName, volumePath string) string {
32113226 strings.ReplaceAll(strings.Trim(volumePath, "/"), "/", "-"))
32123227 }
32133228
3229+func (s *Service) legacyVolumeOwnedByDomain(ctx context.Context, volumeName, domainName string) (bool, error) {
3230+ containers, err := s.runtime.ListContainers(ctx, true)
3231+ if err != nil {
3232+ return false, fmt.Errorf("list containers for legacy volume ownership: %w", err)
3233+ }
3234+ for _, container := range containers {
3235+ if container.Labels[domain.LabelManaged] != "true" ||
3236+ (container.Labels[domain.LabelRoute] != domainName && container.Labels[domain.LabelDomain] != domainName) {
3237+ continue
3238+ }
3239+ for _, mount := range container.VolumeMounts {
3240+ if mount.Name == volumeName || mount.Source == volumeName {
3241+ return true, nil
3242+ }
3243+ }
3244+ }
3245+ return false, nil
3246+}
3247+
32143248 func mergeEnvironmentVariables(dockerfileEnv, userEnv []string) []string {
32153249 envMap := make(map[string]string)
32163250
@@ -2540,8 +2540,13 @@ func (s *Service) setupVolumes(ctx context.Context, domainName, imageRef string)
2540 continue2540 continue
2541 }2541 }
2542 if legacyExists {2542 if legacyExists {
2543- volumes[path] = legacyName2543+ owned, ownershipErr := s.legacyVolumeOwnedByDomain(ctx, legacyName, domainName)
2544- continue2544+ if ownershipErr != nil {
2545+ log.Warn().Err(ownershipErr).Str("volume", legacyName).Msg("could not verify legacy volume ownership; using stable volume name")
2546+ } else if owned {
2547+ volumes[path] = legacyName
2548+ continue
2549+ }
2545 }2550 }
2546 2551
2547 exists, err := s.runtime.VolumeExists(ctx, name)2552 exists, err := s.runtime.VolumeExists(ctx, name)
@@ -3126,6 +3131,9 @@ func (s *Service) resolveExistingAttachment(ctx context.Context, ownerDomain, se
3126 containerName := fmt.Sprintf("gordon-%s-%s", sanitizeName(ownerDomain), serviceName)3131 containerName := fmt.Sprintf("gordon-%s-%s", sanitizeName(ownerDomain), serviceName)
3127 existingContainer := s.findContainerByName(ctx, containerName)3132 existingContainer := s.findContainerByName(ctx, containerName)
3128 if existingContainer != nil {3133 if existingContainer != nil {
3134+ if err := validateAttachmentOwnership(existingContainer, ownerDomain, containerName); err != nil {
3135+ return nil, err
3136+ }
3129 return existingContainer, nil3137 return existingContainer, nil
3130 }3138 }
3131 3139
@@ -3135,10 +3143,8 @@ func (s *Service) resolveExistingAttachment(ctx context.Context, ownerDomain, se
3135 return nil, nil3143 return nil, nil
3136 }3144 }
3137 3145
3138- if existingContainer.Labels[domain.LabelManaged] != "true" ||3146+ if err := validateAttachmentOwnership(existingContainer, ownerDomain, containerNameLegacy); err != nil {
3139- existingContainer.Labels[domain.LabelAttachment] != "true" ||3147+ return nil, err
3140- existingContainer.Labels[domain.LabelAttachedTo] != ownerDomain {
3141- return nil, fmt.Errorf("legacy attachment name %q is owned by another workload", containerNameLegacy)
3142 }3148 }
3143 log.Info().Str("container_name", containerNameLegacy).Msg("found attachment with legacy naming, will be replaced")3149 log.Info().Str("container_name", containerNameLegacy).Msg("found attachment with legacy naming, will be replaced")
3144 if err := s.runtime.StopContainer(ctx, existingContainer.ID); err != nil {3150 if err := s.runtime.StopContainer(ctx, existingContainer.ID); err != nil {
@@ -3151,6 +3157,15 @@ func (s *Service) resolveExistingAttachment(ctx context.Context, ownerDomain, se
3151 return nil, nil3157 return nil, nil
3152 }3158 }
3153 3159
3160+func validateAttachmentOwnership(container *domain.Container, ownerDomain, containerName string) error {
3161+ if container.Labels[domain.LabelManaged] != "true" ||
3162+ container.Labels[domain.LabelAttachment] != "true" ||
3163+ container.Labels[domain.LabelAttachedTo] != ownerDomain {
3164+ return fmt.Errorf("%w: attachment name %q is owned by another workload", domain.ErrAttachmentOwnershipMismatch, containerName)
3165+ }
3166+ return nil
3167+}
3168+
3154 // Utility functions3169 // Utility functions
3155 3170
3156 func managedContainerName(domainName string) string {3171 func managedContainerName(domainName string) string {
@@ -3211,6 +3226,25 @@ func legacyVolumeName(prefix, domainName, volumePath string) string {
3211 strings.ReplaceAll(strings.Trim(volumePath, "/"), "/", "-"))3226 strings.ReplaceAll(strings.Trim(volumePath, "/"), "/", "-"))
3212 }3227 }
3213 3228
3229+func (s *Service) legacyVolumeOwnedByDomain(ctx context.Context, volumeName, domainName string) (bool, error) {
3230+ containers, err := s.runtime.ListContainers(ctx, true)
3231+ if err != nil {
3232+ return false, fmt.Errorf("list containers for legacy volume ownership: %w", err)
3233+ }
3234+ for _, container := range containers {
3235+ if container.Labels[domain.LabelManaged] != "true" ||
3236+ (container.Labels[domain.LabelRoute] != domainName && container.Labels[domain.LabelDomain] != domainName) {
3237+ continue
3238+ }
3239+ for _, mount := range container.VolumeMounts {
3240+ if mount.Name == volumeName || mount.Source == volumeName {
3241+ return true, nil
3242+ }
3243+ }
3244+ }
3245+ return false, nil
3246+}
3247+
3214 func mergeEnvironmentVariables(dockerfileEnv, userEnv []string) []string {3248 func mergeEnvironmentVariables(dockerfileEnv, userEnv []string) []string {
3215 envMap := make(map[string]string)3249 envMap := make(map[string]string)
3216 3250
M internal/usecase/container/service_test.go +37 -0
@@ -1794,6 +1794,10 @@ func TestService_SetupVolumes_ReusesLegacyVolume(t *testing.T) {
17941794
17951795 runtime.EXPECT().InspectImageVolumes(mock.Anything, "myapp:latest").Return([]string{"/data"}, nil)
17961796 runtime.EXPECT().VolumeExists(mock.Anything, legacyName).Return(true, nil)
1797+ runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{{
1798+ Labels: map[string]string{domain.LabelManaged: "true", domain.LabelRoute: "app.example.com"},
1799+ VolumeMounts: []domain.ContainerVolumeMount{{Name: legacyName}},
1800+ }}, nil)
17971801
17981802 volumes, err := svc.setupVolumes(testContext(), "app.example.com", "myapp:latest")
17991803
@@ -1801,6 +1805,39 @@ func TestService_SetupVolumes_ReusesLegacyVolume(t *testing.T) {
18011805 assert.Equal(t, map[string]string{"/data": legacyName}, volumes)
18021806 }
18031807
1808+func TestService_SetupVolumes_DoesNotReuseCollidingLegacyVolume(t *testing.T) {
1809+ runtime := mocks.NewMockContainerRuntime(t)
1810+ svc := NewService(runtime, nil, nil, nil, Config{VolumeAutoCreate: true, VolumePrefix: "gordon"}, nil)
1811+ legacyName := legacyVolumeName("gordon", "app.example.com", "/data")
1812+ stableName := generateVolumeName("gordon", "app.example.com", "/data")
1813+ require.Equal(t, legacyName, legacyVolumeName("gordon", "app-example.com", "/data"))
1814+
1815+ runtime.EXPECT().InspectImageVolumes(mock.Anything, "myapp:latest").Return([]string{"/data"}, nil)
1816+ runtime.EXPECT().VolumeExists(mock.Anything, legacyName).Return(true, nil)
1817+ runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{{
1818+ Labels: map[string]string{domain.LabelManaged: "true", domain.LabelRoute: "app-example.com"},
1819+ VolumeMounts: []domain.ContainerVolumeMount{{Name: legacyName}},
1820+ }}, nil)
1821+ runtime.EXPECT().VolumeExists(mock.Anything, stableName).Return(true, nil)
1822+
1823+ volumes, err := svc.setupVolumes(testContext(), "app.example.com", "myapp:latest")
1824+
1825+ require.NoError(t, err)
1826+ assert.Equal(t, map[string]string{"/data": stableName}, volumes)
1827+}
1828+
1829+func TestValidateAttachmentOwnership(t *testing.T) {
1830+ owned := &domain.Container{Labels: map[string]string{
1831+ domain.LabelManaged: "true", domain.LabelAttachment: "true", domain.LabelAttachedTo: "app.example.com",
1832+ }}
1833+ require.NoError(t, validateAttachmentOwnership(owned, "app.example.com", "gordon-app-postgres"))
1834+
1835+ foreign := &domain.Container{Labels: map[string]string{
1836+ domain.LabelManaged: "true", domain.LabelAttachment: "true", domain.LabelAttachedTo: "other.example.com",
1837+ }}
1838+ assert.ErrorIs(t, validateAttachmentOwnership(foreign, "app.example.com", "gordon-app-postgres"), domain.ErrAttachmentOwnershipMismatch)
1839+}
1840+
18041841 func TestMergeEnvironmentVariables(t *testing.T) {
18051842 dockerEnv := []string{"FOO=docker", "BAR=docker"}
18061843 userEnv := []string{"FOO=user", "BAZ=user"}
@@ -1794,6 +1794,10 @@ func TestService_SetupVolumes_ReusesLegacyVolume(t *testing.T) {
1794 1794
1795 runtime.EXPECT().InspectImageVolumes(mock.Anything, "myapp:latest").Return([]string{"/data"}, nil)1795 runtime.EXPECT().InspectImageVolumes(mock.Anything, "myapp:latest").Return([]string{"/data"}, nil)
1796 runtime.EXPECT().VolumeExists(mock.Anything, legacyName).Return(true, nil)1796 runtime.EXPECT().VolumeExists(mock.Anything, legacyName).Return(true, nil)
1797+ runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{{
1798+ Labels: map[string]string{domain.LabelManaged: "true", domain.LabelRoute: "app.example.com"},
1799+ VolumeMounts: []domain.ContainerVolumeMount{{Name: legacyName}},
1800+ }}, nil)
1797 1801
1798 volumes, err := svc.setupVolumes(testContext(), "app.example.com", "myapp:latest")1802 volumes, err := svc.setupVolumes(testContext(), "app.example.com", "myapp:latest")
1799 1803
@@ -1801,6 +1805,39 @@ func TestService_SetupVolumes_ReusesLegacyVolume(t *testing.T) {
1801 assert.Equal(t, map[string]string{"/data": legacyName}, volumes)1805 assert.Equal(t, map[string]string{"/data": legacyName}, volumes)
1802 }1806 }
1803 1807
1808+func TestService_SetupVolumes_DoesNotReuseCollidingLegacyVolume(t *testing.T) {
1809+ runtime := mocks.NewMockContainerRuntime(t)
1810+ svc := NewService(runtime, nil, nil, nil, Config{VolumeAutoCreate: true, VolumePrefix: "gordon"}, nil)
1811+ legacyName := legacyVolumeName("gordon", "app.example.com", "/data")
1812+ stableName := generateVolumeName("gordon", "app.example.com", "/data")
1813+ require.Equal(t, legacyName, legacyVolumeName("gordon", "app-example.com", "/data"))
1814+
1815+ runtime.EXPECT().InspectImageVolumes(mock.Anything, "myapp:latest").Return([]string{"/data"}, nil)
1816+ runtime.EXPECT().VolumeExists(mock.Anything, legacyName).Return(true, nil)
1817+ runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{{
1818+ Labels: map[string]string{domain.LabelManaged: "true", domain.LabelRoute: "app-example.com"},
1819+ VolumeMounts: []domain.ContainerVolumeMount{{Name: legacyName}},
1820+ }}, nil)
1821+ runtime.EXPECT().VolumeExists(mock.Anything, stableName).Return(true, nil)
1822+
1823+ volumes, err := svc.setupVolumes(testContext(), "app.example.com", "myapp:latest")
1824+
1825+ require.NoError(t, err)
1826+ assert.Equal(t, map[string]string{"/data": stableName}, volumes)
1827+}
1828+
1829+func TestValidateAttachmentOwnership(t *testing.T) {
1830+ owned := &domain.Container{Labels: map[string]string{
1831+ domain.LabelManaged: "true", domain.LabelAttachment: "true", domain.LabelAttachedTo: "app.example.com",
1832+ }}
1833+ require.NoError(t, validateAttachmentOwnership(owned, "app.example.com", "gordon-app-postgres"))
1834+
1835+ foreign := &domain.Container{Labels: map[string]string{
1836+ domain.LabelManaged: "true", domain.LabelAttachment: "true", domain.LabelAttachedTo: "other.example.com",
1837+ }}
1838+ assert.ErrorIs(t, validateAttachmentOwnership(foreign, "app.example.com", "gordon-app-postgres"), domain.ErrAttachmentOwnershipMismatch)
1839+}
1840+
1804 func TestMergeEnvironmentVariables(t *testing.T) {1841 func TestMergeEnvironmentVariables(t *testing.T) {
1805 dockerEnv := []string{"FOO=docker", "BAR=docker"}1842 dockerEnv := []string{"FOO=docker", "BAR=docker"}
1806 userEnv := []string{"FOO=user", "BAZ=user"}1843 userEnv := []string{"FOO=user", "BAZ=user"}
M internal/usecase/images/service.go +12 -5
@@ -14,6 +14,7 @@ import (
1414
1515 "github.com/bnema/gordon/internal/boundaries/out"
1616 "github.com/bnema/gordon/internal/domain"
17+ "github.com/bnema/gordon/internal/usecase/registrystate"
1718 "github.com/bnema/gordon/pkg/runtime"
1819 "github.com/bnema/gordon/pkg/validation"
1920 )
@@ -25,6 +26,7 @@ type Service struct {
2526 blobStorage out.BlobStorage
2627 log zerowrap.Logger
2728 mutationMu *sync.RWMutex
29+ registryState *registrystate.State
2830 }
2931
3032 type imageRuntime interface {
@@ -38,18 +40,19 @@ func NewService(
3840 manifestStorage out.ManifestStorage,
3941 blobStorage out.BlobStorage,
4042 log zerowrap.Logger,
41- mutationLocks ...*sync.RWMutex,
43+ states ...*registrystate.State,
4244 ) *Service {
43- mutationMu := &sync.RWMutex{}
44- if len(mutationLocks) > 0 && mutationLocks[0] != nil {
45- mutationMu = mutationLocks[0]
45+ registryState := registrystate.New()
46+ if len(states) > 0 && states[0] != nil {
47+ registryState = states[0]
4648 }
4749 return &Service{
4850 runtime: rt,
4951 manifestStorage: manifestStorage,
5052 blobStorage: blobStorage,
5153 log: log,
52- mutationMu: mutationMu,
54+ mutationMu: &registryState.MutationMu,
55+ registryState: registryState,
5356 }
5457 }
5558
@@ -245,6 +248,10 @@ func (s *Service) PruneRegistry(ctx context.Context, keepLast int) (domain.Image
245248 }
246249 }
247250
251+ for digest := range s.registryState.PendingDigests(time.Now().UTC()) {
252+ referencedDigests[digest] = struct{}{}
253+ }
254+
248255 blobs, err := s.blobStorage.ListBlobs()
249256 if err != nil {
250257 return domain.ImagePruneReport{}, log.WrapErr(err, "failed to list blobs")
@@ -14,6 +14,7 @@ import (
14 14
15 "github.com/bnema/gordon/internal/boundaries/out"15 "github.com/bnema/gordon/internal/boundaries/out"
16 "github.com/bnema/gordon/internal/domain"16 "github.com/bnema/gordon/internal/domain"
17+ "github.com/bnema/gordon/internal/usecase/registrystate"
17 "github.com/bnema/gordon/pkg/runtime"18 "github.com/bnema/gordon/pkg/runtime"
18 "github.com/bnema/gordon/pkg/validation"19 "github.com/bnema/gordon/pkg/validation"
19 )20 )
@@ -25,6 +26,7 @@ type Service struct {
25 blobStorage out.BlobStorage26 blobStorage out.BlobStorage
26 log zerowrap.Logger27 log zerowrap.Logger
27 mutationMu *sync.RWMutex28 mutationMu *sync.RWMutex
29+ registryState *registrystate.State
28 }30 }
29 31
30 type imageRuntime interface {32 type imageRuntime interface {
@@ -38,18 +40,19 @@ func NewService(
38 manifestStorage out.ManifestStorage,40 manifestStorage out.ManifestStorage,
39 blobStorage out.BlobStorage,41 blobStorage out.BlobStorage,
40 log zerowrap.Logger,42 log zerowrap.Logger,
41- mutationLocks ...*sync.RWMutex,43+ states ...*registrystate.State,
42 ) *Service {44 ) *Service {
43- mutationMu := &sync.RWMutex{}45+ registryState := registrystate.New()
44- if len(mutationLocks) > 0 && mutationLocks[0] != nil {46+ if len(states) > 0 && states[0] != nil {
45- mutationMu = mutationLocks[0]47+ registryState = states[0]
46 }48 }
47 return &Service{49 return &Service{
48 runtime: rt,50 runtime: rt,
49 manifestStorage: manifestStorage,51 manifestStorage: manifestStorage,
50 blobStorage: blobStorage,52 blobStorage: blobStorage,
51 log: log,53 log: log,
52- mutationMu: mutationMu,54+ mutationMu: &registryState.MutationMu,
55+ registryState: registryState,
53 }56 }
54 }57 }
55 58
@@ -245,6 +248,10 @@ func (s *Service) PruneRegistry(ctx context.Context, keepLast int) (domain.Image
245 }248 }
246 }249 }
247 250
251+ for digest := range s.registryState.PendingDigests(time.Now().UTC()) {
252+ referencedDigests[digest] = struct{}{}
253+ }
254+
248 blobs, err := s.blobStorage.ListBlobs()255 blobs, err := s.blobStorage.ListBlobs()
249 if err != nil {256 if err != nil {
250 return domain.ImagePruneReport{}, log.WrapErr(err, "failed to list blobs")257 return domain.ImagePruneReport{}, log.WrapErr(err, "failed to list blobs")
M internal/usecase/images/service_test.go +19 -0
@@ -13,6 +13,7 @@ import (
1313
1414 "github.com/bnema/gordon/internal/boundaries/out"
1515 "github.com/bnema/gordon/internal/domain"
16+ "github.com/bnema/gordon/internal/usecase/registrystate"
1617 pkgruntime "github.com/bnema/gordon/pkg/runtime"
1718 )
1819
@@ -332,6 +333,24 @@ func TestService_PruneRegistry_GarbageCollectsUnreferencedBlobs(t *testing.T) {
332333 assert.Equal(t, []string{"sha256:orphan"}, blobStorage.deletedBlobs)
333334 }
334335
336+func TestService_PruneRegistry_PreservesPendingBlob(t *testing.T) {
337+ manifestStorage := newFakeManifestStorage()
338+ manifestStorage.repositories = []string{"gordon/api"}
339+ manifestStorage.tagsByRepo["gordon/api"] = []string{"latest"}
340+ manifestStorage.modTimes[manifestRefKey("gordon/api", "latest")] = time.Now().UTC()
341+ manifestStorage.manifests[manifestRefKey("gordon/api", "latest")] = mustManifestJSON(t, "sha256:cfg-live", "sha256:layer-live")
342+ blobStorage := &fakeBlobStorage{blobs: []string{"sha256:pending"}}
343+ state := registrystate.New()
344+ state.AddPending("sha256:pending", time.Now().UTC())
345+ svc := NewService(&fakeRuntime{}, manifestStorage, blobStorage, zerowrap.Default(), state)
346+
347+ report, err := svc.PruneRegistry(context.Background(), 1)
348+
349+ require.NoError(t, err)
350+ assert.Zero(t, report.Registry.BlobsRemoved)
351+ assert.Empty(t, blobStorage.deletedBlobs)
352+}
353+
335354 func TestService_PruneRegistry_PreservesSharedBlobs(t *testing.T) {
336355 manifestStorage := newFakeManifestStorage()
337356 manifestStorage.repositories = []string{"gordon/api"}
@@ -13,6 +13,7 @@ import (
13 13
14 "github.com/bnema/gordon/internal/boundaries/out"14 "github.com/bnema/gordon/internal/boundaries/out"
15 "github.com/bnema/gordon/internal/domain"15 "github.com/bnema/gordon/internal/domain"
16+ "github.com/bnema/gordon/internal/usecase/registrystate"
16 pkgruntime "github.com/bnema/gordon/pkg/runtime"17 pkgruntime "github.com/bnema/gordon/pkg/runtime"
17 )18 )
18 19
@@ -332,6 +333,24 @@ func TestService_PruneRegistry_GarbageCollectsUnreferencedBlobs(t *testing.T) {
332 assert.Equal(t, []string{"sha256:orphan"}, blobStorage.deletedBlobs)333 assert.Equal(t, []string{"sha256:orphan"}, blobStorage.deletedBlobs)
333 }334 }
334 335
336+func TestService_PruneRegistry_PreservesPendingBlob(t *testing.T) {
337+ manifestStorage := newFakeManifestStorage()
338+ manifestStorage.repositories = []string{"gordon/api"}
339+ manifestStorage.tagsByRepo["gordon/api"] = []string{"latest"}
340+ manifestStorage.modTimes[manifestRefKey("gordon/api", "latest")] = time.Now().UTC()
341+ manifestStorage.manifests[manifestRefKey("gordon/api", "latest")] = mustManifestJSON(t, "sha256:cfg-live", "sha256:layer-live")
342+ blobStorage := &fakeBlobStorage{blobs: []string{"sha256:pending"}}
343+ state := registrystate.New()
344+ state.AddPending("sha256:pending", time.Now().UTC())
345+ svc := NewService(&fakeRuntime{}, manifestStorage, blobStorage, zerowrap.Default(), state)
346+
347+ report, err := svc.PruneRegistry(context.Background(), 1)
348+
349+ require.NoError(t, err)
350+ assert.Zero(t, report.Registry.BlobsRemoved)
351+ assert.Empty(t, blobStorage.deletedBlobs)
352+}
353+
335 func TestService_PruneRegistry_PreservesSharedBlobs(t *testing.T) {354 func TestService_PruneRegistry_PreservesSharedBlobs(t *testing.T) {
336 manifestStorage := newFakeManifestStorage()355 manifestStorage := newFakeManifestStorage()
337 manifestStorage.repositories = []string{"gordon/api"}356 manifestStorage.repositories = []string{"gordon/api"}
M internal/usecase/registry/service.go +65 -14
@@ -22,6 +22,7 @@ import (
2222 "github.com/bnema/gordon/internal/adapters/out/telemetry"
2323 "github.com/bnema/gordon/internal/boundaries/out"
2424 "github.com/bnema/gordon/internal/domain"
25+ "github.com/bnema/gordon/internal/usecase/registrystate"
2526 "github.com/bnema/gordon/pkg/validation"
2627 )
2728
@@ -35,6 +36,7 @@ type Service struct {
3536 metrics *telemetry.Metrics
3637 suppressedImages sync.Map // imageName -> *time.Timer
3738 mutationMu *sync.RWMutex
39+ registryState *registrystate.State
3840 }
3941
4042 // SetMetrics sets the telemetry metrics for the registry service.
@@ -47,17 +49,18 @@ func NewService(
4749 blobStorage out.BlobStorage,
4850 manifestStorage out.ManifestStorage,
4951 eventBus out.EventPublisher,
50- mutationLocks ...*sync.RWMutex,
52+ states ...*registrystate.State,
5153 ) *Service {
52- mutationMu := &sync.RWMutex{}
53- if len(mutationLocks) > 0 && mutationLocks[0] != nil {
54- mutationMu = mutationLocks[0]
54+ registryState := registrystate.New()
55+ if len(states) > 0 && states[0] != nil {
56+ registryState = states[0]
5557 }
5658 return &Service{
5759 blobStorage: blobStorage,
5860 manifestStorage: manifestStorage,
5961 eventBus: eventBus,
60- mutationMu: mutationMu,
62+ mutationMu: &registryState.MutationMu,
63+ registryState: registryState,
6164 }
6265 }
6366
@@ -202,6 +205,7 @@ func (s *Service) PutManifest(ctx context.Context, manifest *domain.Manifest) (s
202205 if err := s.manifestStorage.PutManifest(manifest.Name, manifest.Reference, manifest.ContentType, manifest.Data); err != nil {
203206 return "", log.WrapErr(err, "failed to store manifest")
204207 }
208+ s.registryState.MarkPublished(manifestReferencedDigests(manifest.Data))
205209
206210 // Record push metrics
207211 if s.metrics != nil {
@@ -312,6 +316,8 @@ type manifestReferences struct {
312316 Subject *manifestDescriptor `json:"subject"`
313317 }
314318
319+const maxManifestTraversal = 10000
320+
315321 func (s *Service) repositoryReferencesDigest(name, target string) (bool, error) {
316322 tags, err := s.manifestStorage.ListTags(name)
317323 if err != nil {
@@ -321,6 +327,9 @@ func (s *Service) repositoryReferencesDigest(name, target string) (bool, error)
321327 return false, fmt.Errorf("list tags for repository %s: %w", name, err)
322328 }
323329
330+ if len(tags) > maxManifestTraversal {
331+ return false, fmt.Errorf("%w: repository %s exceeds manifest traversal limit", domain.ErrBlobNotFound, name)
332+ }
324333 queue := append([]string(nil), tags...)
325334 seen := make(map[string]struct{}, len(queue))
326335 for len(queue) > 0 {
@@ -330,6 +339,9 @@ func (s *Service) repositoryReferencesDigest(name, target string) (bool, error)
330339 continue
331340 }
332341 seen[reference] = struct{}{}
342+ if len(seen) > maxManifestTraversal {
343+ return false, fmt.Errorf("%w: repository %s exceeds manifest traversal limit", domain.ErrBlobNotFound, name)
344+ }
333345
334346 data, _, err := s.manifestStorage.GetManifest(name, reference)
335347 if err != nil {
@@ -340,22 +352,60 @@ func (s *Service) repositoryReferencesDigest(name, target string) (bool, error)
340352 return false, fmt.Errorf("decode manifest %s: %w", reference, err)
341353 }
342354
343- if refs.Config.Digest == target || descriptorListContains(refs.Layers, target) ||
344- descriptorListContains(refs.Blobs, target) || (refs.Subject != nil && refs.Subject.Digest == target) {
355+ if manifestReferencesTarget(refs, target) {
345356 return true, nil
346357 }
347- for _, child := range refs.Manifests {
348- if child.Digest == target {
349- return true, nil
350- }
351- if child.Digest != "" {
352- queue = append(queue, child.Digest)
353- }
358+ nestedManifests := refs.Manifests
359+ if refs.Subject != nil && refs.Subject.Digest != "" {
360+ nestedManifests = append(append([]manifestDescriptor(nil), refs.Manifests...), *refs.Subject)
361+ }
362+ if len(seen)+len(queue)+len(nestedManifests) > maxManifestTraversal {
363+ return false, fmt.Errorf("%w: repository %s exceeds manifest traversal limit", domain.ErrBlobNotFound, name)
354364 }
365+ queue = appendManifestDigests(queue, nestedManifests)
355366 }
356367 return false, nil
357368 }
358369
370+func manifestReferencesTarget(refs manifestReferences, target string) bool {
371+ return refs.Config.Digest == target ||
372+ descriptorListContains(refs.Layers, target) ||
373+ descriptorListContains(refs.Manifests, target) ||
374+ descriptorListContains(refs.Blobs, target) ||
375+ (refs.Subject != nil && refs.Subject.Digest == target)
376+}
377+
378+func appendManifestDigests(queue []string, descriptors []manifestDescriptor) []string {
379+ for _, descriptor := range descriptors {
380+ if descriptor.Digest != "" {
381+ queue = append(queue, descriptor.Digest)
382+ }
383+ }
384+ return queue
385+}
386+
387+func manifestReferencedDigests(data []byte) []string {
388+ var refs manifestReferences
389+ if json.Unmarshal(data, &refs) != nil {
390+ return nil
391+ }
392+ digests := make([]string, 0, 1+len(refs.Layers)+len(refs.Manifests)+len(refs.Blobs))
393+ if refs.Config.Digest != "" {
394+ digests = append(digests, refs.Config.Digest)
395+ }
396+ for _, descriptors := range [][]manifestDescriptor{refs.Layers, refs.Manifests, refs.Blobs} {
397+ for _, descriptor := range descriptors {
398+ if descriptor.Digest != "" {
399+ digests = append(digests, descriptor.Digest)
400+ }
401+ }
402+ }
403+ if refs.Subject != nil && refs.Subject.Digest != "" {
404+ digests = append(digests, refs.Subject.Digest)
405+ }
406+ return digests
407+}
408+
359409 func descriptorListContains(descriptors []manifestDescriptor, digest string) bool {
360410 for _, descriptor := range descriptors {
361411 if descriptor.Digest == digest {
@@ -396,6 +446,7 @@ func (s *Service) PutBlob(ctx context.Context, digest string, data io.Reader, si
396446 if err := s.blobStorage.PutBlob(digest, data, size); err != nil {
397447 return log.WrapErr(err, "failed to store blob")
398448 }
449+ s.registryState.AddPending(digest, time.Now().UTC())
399450
400451 log.Info().Msg("blob stored")
401452 return nil
@@ -22,6 +22,7 @@ import (
22 "github.com/bnema/gordon/internal/adapters/out/telemetry"22 "github.com/bnema/gordon/internal/adapters/out/telemetry"
23 "github.com/bnema/gordon/internal/boundaries/out"23 "github.com/bnema/gordon/internal/boundaries/out"
24 "github.com/bnema/gordon/internal/domain"24 "github.com/bnema/gordon/internal/domain"
25+ "github.com/bnema/gordon/internal/usecase/registrystate"
25 "github.com/bnema/gordon/pkg/validation"26 "github.com/bnema/gordon/pkg/validation"
26 )27 )
27 28
@@ -35,6 +36,7 @@ type Service struct {
35 metrics *telemetry.Metrics36 metrics *telemetry.Metrics
36 suppressedImages sync.Map // imageName -> *time.Timer37 suppressedImages sync.Map // imageName -> *time.Timer
37 mutationMu *sync.RWMutex38 mutationMu *sync.RWMutex
39+ registryState *registrystate.State
38 }40 }
39 41
40 // SetMetrics sets the telemetry metrics for the registry service.42 // SetMetrics sets the telemetry metrics for the registry service.
@@ -47,17 +49,18 @@ func NewService(
47 blobStorage out.BlobStorage,49 blobStorage out.BlobStorage,
48 manifestStorage out.ManifestStorage,50 manifestStorage out.ManifestStorage,
49 eventBus out.EventPublisher,51 eventBus out.EventPublisher,
50- mutationLocks ...*sync.RWMutex,52+ states ...*registrystate.State,
51 ) *Service {53 ) *Service {
52- mutationMu := &sync.RWMutex{}54+ registryState := registrystate.New()
53- if len(mutationLocks) > 0 && mutationLocks[0] != nil {55+ if len(states) > 0 && states[0] != nil {
54- mutationMu = mutationLocks[0]56+ registryState = states[0]
55 }57 }
56 return &Service{58 return &Service{
57 blobStorage: blobStorage,59 blobStorage: blobStorage,
58 manifestStorage: manifestStorage,60 manifestStorage: manifestStorage,
59 eventBus: eventBus,61 eventBus: eventBus,
60- mutationMu: mutationMu,62+ mutationMu: &registryState.MutationMu,
63+ registryState: registryState,
61 }64 }
62 }65 }
63 66
@@ -202,6 +205,7 @@ func (s *Service) PutManifest(ctx context.Context, manifest *domain.Manifest) (s
202 if err := s.manifestStorage.PutManifest(manifest.Name, manifest.Reference, manifest.ContentType, manifest.Data); err != nil {205 if err := s.manifestStorage.PutManifest(manifest.Name, manifest.Reference, manifest.ContentType, manifest.Data); err != nil {
203 return "", log.WrapErr(err, "failed to store manifest")206 return "", log.WrapErr(err, "failed to store manifest")
204 }207 }
208+ s.registryState.MarkPublished(manifestReferencedDigests(manifest.Data))
205 209
206 // Record push metrics210 // Record push metrics
207 if s.metrics != nil {211 if s.metrics != nil {
@@ -312,6 +316,8 @@ type manifestReferences struct {
312 Subject *manifestDescriptor `json:"subject"`316 Subject *manifestDescriptor `json:"subject"`
313 }317 }
314 318
319+const maxManifestTraversal = 10000
320+
315 func (s *Service) repositoryReferencesDigest(name, target string) (bool, error) {321 func (s *Service) repositoryReferencesDigest(name, target string) (bool, error) {
316 tags, err := s.manifestStorage.ListTags(name)322 tags, err := s.manifestStorage.ListTags(name)
317 if err != nil {323 if err != nil {
@@ -321,6 +327,9 @@ func (s *Service) repositoryReferencesDigest(name, target string) (bool, error)
321 return false, fmt.Errorf("list tags for repository %s: %w", name, err)327 return false, fmt.Errorf("list tags for repository %s: %w", name, err)
322 }328 }
323 329
330+ if len(tags) > maxManifestTraversal {
331+ return false, fmt.Errorf("%w: repository %s exceeds manifest traversal limit", domain.ErrBlobNotFound, name)
332+ }
324 queue := append([]string(nil), tags...)333 queue := append([]string(nil), tags...)
325 seen := make(map[string]struct{}, len(queue))334 seen := make(map[string]struct{}, len(queue))
326 for len(queue) > 0 {335 for len(queue) > 0 {
@@ -330,6 +339,9 @@ func (s *Service) repositoryReferencesDigest(name, target string) (bool, error)
330 continue339 continue
331 }340 }
332 seen[reference] = struct{}{}341 seen[reference] = struct{}{}
342+ if len(seen) > maxManifestTraversal {
343+ return false, fmt.Errorf("%w: repository %s exceeds manifest traversal limit", domain.ErrBlobNotFound, name)
344+ }
333 345
334 data, _, err := s.manifestStorage.GetManifest(name, reference)346 data, _, err := s.manifestStorage.GetManifest(name, reference)
335 if err != nil {347 if err != nil {
@@ -340,22 +352,60 @@ func (s *Service) repositoryReferencesDigest(name, target string) (bool, error)
340 return false, fmt.Errorf("decode manifest %s: %w", reference, err)352 return false, fmt.Errorf("decode manifest %s: %w", reference, err)
341 }353 }
342 354
343- if refs.Config.Digest == target || descriptorListContains(refs.Layers, target) ||355+ if manifestReferencesTarget(refs, target) {
344- descriptorListContains(refs.Blobs, target) || (refs.Subject != nil && refs.Subject.Digest == target) {
345 return true, nil356 return true, nil
346 }357 }
347- for _, child := range refs.Manifests {358+ nestedManifests := refs.Manifests
348- if child.Digest == target {359+ if refs.Subject != nil && refs.Subject.Digest != "" {
349- return true, nil360+ nestedManifests = append(append([]manifestDescriptor(nil), refs.Manifests...), *refs.Subject)
350- }361+ }
351- if child.Digest != "" {362+ if len(seen)+len(queue)+len(nestedManifests) > maxManifestTraversal {
352- queue = append(queue, child.Digest)363+ return false, fmt.Errorf("%w: repository %s exceeds manifest traversal limit", domain.ErrBlobNotFound, name)
353- }
354 }364 }
365+ queue = appendManifestDigests(queue, nestedManifests)
355 }366 }
356 return false, nil367 return false, nil
357 }368 }
358 369
370+func manifestReferencesTarget(refs manifestReferences, target string) bool {
371+ return refs.Config.Digest == target ||
372+ descriptorListContains(refs.Layers, target) ||
373+ descriptorListContains(refs.Manifests, target) ||
374+ descriptorListContains(refs.Blobs, target) ||
375+ (refs.Subject != nil && refs.Subject.Digest == target)
376+}
377+
378+func appendManifestDigests(queue []string, descriptors []manifestDescriptor) []string {
379+ for _, descriptor := range descriptors {
380+ if descriptor.Digest != "" {
381+ queue = append(queue, descriptor.Digest)
382+ }
383+ }
384+ return queue
385+}
386+
387+func manifestReferencedDigests(data []byte) []string {
388+ var refs manifestReferences
389+ if json.Unmarshal(data, &refs) != nil {
390+ return nil
391+ }
392+ digests := make([]string, 0, 1+len(refs.Layers)+len(refs.Manifests)+len(refs.Blobs))
393+ if refs.Config.Digest != "" {
394+ digests = append(digests, refs.Config.Digest)
395+ }
396+ for _, descriptors := range [][]manifestDescriptor{refs.Layers, refs.Manifests, refs.Blobs} {
397+ for _, descriptor := range descriptors {
398+ if descriptor.Digest != "" {
399+ digests = append(digests, descriptor.Digest)
400+ }
401+ }
402+ }
403+ if refs.Subject != nil && refs.Subject.Digest != "" {
404+ digests = append(digests, refs.Subject.Digest)
405+ }
406+ return digests
407+}
408+
359 func descriptorListContains(descriptors []manifestDescriptor, digest string) bool {409 func descriptorListContains(descriptors []manifestDescriptor, digest string) bool {
360 for _, descriptor := range descriptors {410 for _, descriptor := range descriptors {
361 if descriptor.Digest == digest {411 if descriptor.Digest == digest {
@@ -396,6 +446,7 @@ func (s *Service) PutBlob(ctx context.Context, digest string, data io.Reader, si
396 if err := s.blobStorage.PutBlob(digest, data, size); err != nil {446 if err := s.blobStorage.PutBlob(digest, data, size); err != nil {
397 return log.WrapErr(err, "failed to store blob")447 return log.WrapErr(err, "failed to store blob")
398 }448 }
449+ s.registryState.AddPending(digest, time.Now().UTC())
399 450
400 log.Info().Msg("blob stored")451 log.Info().Msg("blob stored")
401 return nil452 return nil
M internal/usecase/registry/service_test.go +40 -2
@@ -9,6 +9,7 @@ import (
99 "io"
1010 "strings"
1111 "testing"
12+ "time"
1213
1314 "github.com/bnema/zerowrap"
1415 "github.com/stretchr/testify/assert"
@@ -17,6 +18,7 @@ import (
1718
1819 "github.com/bnema/gordon/internal/boundaries/out/mocks"
1920 "github.com/bnema/gordon/internal/domain"
21+ "github.com/bnema/gordon/internal/usecase/registrystate"
2022 )
2123
2224 func testContext() context.Context {
@@ -90,8 +92,10 @@ func TestService_PutManifest_SHA512DigestDoesNotPublishEvent(t *testing.T) {
9092 blobStorage := mocks.NewMockBlobStorage(t)
9193 manifestStorage := mocks.NewMockManifestStorage(t)
9294 eventBus := mocks.NewMockEventPublisher(t)
93- svc := NewService(blobStorage, manifestStorage, eventBus)
94- data := []byte(`{"schemaVersion":2}`)
95+ state := registrystate.New()
96+ state.AddPending("sha256:config", time.Now().UTC())
97+ svc := NewService(blobStorage, manifestStorage, eventBus, state)
98+ data := []byte(`{"schemaVersion":2,"config":{"digest":"sha256:config"}}`)
9599 reference := fmt.Sprintf("sha512:%x", sha512.Sum512(data))
96100 manifest := &domain.Manifest{Name: "myapp", Reference: reference, ContentType: "application/vnd.oci.image.manifest.v1+json", Data: data}
97101
@@ -101,6 +105,7 @@ func TestService_PutManifest_SHA512DigestDoesNotPublishEvent(t *testing.T) {
101105
102106 require.NoError(t, err)
103107 assert.Equal(t, reference, digest)
108+ assert.Empty(t, state.PendingDigests(time.Now().UTC()))
104109 }
105110
106111 func TestService_PutManifest_RejectsDigestMismatch(t *testing.T) {
@@ -149,6 +154,39 @@ func TestService_GetBlobPath_RequiresRepositoryReference(t *testing.T) {
149154 assert.ErrorIs(t, err, domain.ErrBlobNotFound)
150155 }
151156
157+func TestService_GetBlobPath_FollowsSubjectManifest(t *testing.T) {
158+ const target = "sha256:target"
159+ blobStorage := mocks.NewMockBlobStorage(t)
160+ manifestStorage := mocks.NewMockManifestStorage(t)
161+ svc := NewService(blobStorage, manifestStorage, nil)
162+
163+ manifestStorage.EXPECT().ListTags("artifacts").Return([]string{"latest"}, nil)
164+ manifestStorage.EXPECT().GetManifest("artifacts", "latest").Return(
165+ []byte(`{"subject":{"digest":"sha256:subject"}}`), "application/vnd.oci.artifact.manifest.v1+json", nil,
166+ )
167+ manifestStorage.EXPECT().GetManifest("artifacts", "sha256:subject").Return(
168+ []byte(`{"config":{"digest":"`+target+`"}}`), "application/vnd.oci.image.manifest.v1+json", nil,
169+ )
170+ blobStorage.EXPECT().GetBlobPath(target).Return("/registry/target", nil)
171+
172+ path, err := svc.GetBlobPath(testContext(), "artifacts", target)
173+
174+ require.NoError(t, err)
175+ assert.Equal(t, "/registry/target", path)
176+}
177+
178+func TestService_GetBlobPath_BoundsManifestTraversal(t *testing.T) {
179+ blobStorage := mocks.NewMockBlobStorage(t)
180+ manifestStorage := mocks.NewMockManifestStorage(t)
181+ svc := NewService(blobStorage, manifestStorage, nil)
182+ tags := make([]string, maxManifestTraversal+1)
183+ manifestStorage.EXPECT().ListTags("busy").Return(tags, nil)
184+
185+ _, err := svc.GetBlobPath(testContext(), "busy", "sha256:target")
186+
187+ assert.ErrorIs(t, err, domain.ErrBlobNotFound)
188+}
189+
152190 func TestService_PutManifest_StorageError(t *testing.T) {
153191 blobStorage := mocks.NewMockBlobStorage(t)
154192 manifestStorage := mocks.NewMockManifestStorage(t)
@@ -9,6 +9,7 @@ import (
9 "io"9 "io"
10 "strings"10 "strings"
11 "testing"11 "testing"
12+ "time"
12 13
13 "github.com/bnema/zerowrap"14 "github.com/bnema/zerowrap"
14 "github.com/stretchr/testify/assert"15 "github.com/stretchr/testify/assert"
@@ -17,6 +18,7 @@ import (
17 18
18 "github.com/bnema/gordon/internal/boundaries/out/mocks"19 "github.com/bnema/gordon/internal/boundaries/out/mocks"
19 "github.com/bnema/gordon/internal/domain"20 "github.com/bnema/gordon/internal/domain"
21+ "github.com/bnema/gordon/internal/usecase/registrystate"
20 )22 )
21 23
22 func testContext() context.Context {24 func testContext() context.Context {
@@ -90,8 +92,10 @@ func TestService_PutManifest_SHA512DigestDoesNotPublishEvent(t *testing.T) {
90 blobStorage := mocks.NewMockBlobStorage(t)92 blobStorage := mocks.NewMockBlobStorage(t)
91 manifestStorage := mocks.NewMockManifestStorage(t)93 manifestStorage := mocks.NewMockManifestStorage(t)
92 eventBus := mocks.NewMockEventPublisher(t)94 eventBus := mocks.NewMockEventPublisher(t)
93- svc := NewService(blobStorage, manifestStorage, eventBus)95+ state := registrystate.New()
94- data := []byte(`{"schemaVersion":2}`)96+ state.AddPending("sha256:config", time.Now().UTC())
97+ svc := NewService(blobStorage, manifestStorage, eventBus, state)
98+ data := []byte(`{"schemaVersion":2,"config":{"digest":"sha256:config"}}`)
95 reference := fmt.Sprintf("sha512:%x", sha512.Sum512(data))99 reference := fmt.Sprintf("sha512:%x", sha512.Sum512(data))
96 manifest := &domain.Manifest{Name: "myapp", Reference: reference, ContentType: "application/vnd.oci.image.manifest.v1+json", Data: data}100 manifest := &domain.Manifest{Name: "myapp", Reference: reference, ContentType: "application/vnd.oci.image.manifest.v1+json", Data: data}
97 101
@@ -101,6 +105,7 @@ func TestService_PutManifest_SHA512DigestDoesNotPublishEvent(t *testing.T) {
101 105
102 require.NoError(t, err)106 require.NoError(t, err)
103 assert.Equal(t, reference, digest)107 assert.Equal(t, reference, digest)
108+ assert.Empty(t, state.PendingDigests(time.Now().UTC()))
104 }109 }
105 110
106 func TestService_PutManifest_RejectsDigestMismatch(t *testing.T) {111 func TestService_PutManifest_RejectsDigestMismatch(t *testing.T) {
@@ -149,6 +154,39 @@ func TestService_GetBlobPath_RequiresRepositoryReference(t *testing.T) {
149 assert.ErrorIs(t, err, domain.ErrBlobNotFound)154 assert.ErrorIs(t, err, domain.ErrBlobNotFound)
150 }155 }
151 156
157+func TestService_GetBlobPath_FollowsSubjectManifest(t *testing.T) {
158+ const target = "sha256:target"
159+ blobStorage := mocks.NewMockBlobStorage(t)
160+ manifestStorage := mocks.NewMockManifestStorage(t)
161+ svc := NewService(blobStorage, manifestStorage, nil)
162+
163+ manifestStorage.EXPECT().ListTags("artifacts").Return([]string{"latest"}, nil)
164+ manifestStorage.EXPECT().GetManifest("artifacts", "latest").Return(
165+ []byte(`{"subject":{"digest":"sha256:subject"}}`), "application/vnd.oci.artifact.manifest.v1+json", nil,
166+ )
167+ manifestStorage.EXPECT().GetManifest("artifacts", "sha256:subject").Return(
168+ []byte(`{"config":{"digest":"`+target+`"}}`), "application/vnd.oci.image.manifest.v1+json", nil,
169+ )
170+ blobStorage.EXPECT().GetBlobPath(target).Return("/registry/target", nil)
171+
172+ path, err := svc.GetBlobPath(testContext(), "artifacts", target)
173+
174+ require.NoError(t, err)
175+ assert.Equal(t, "/registry/target", path)
176+}
177+
178+func TestService_GetBlobPath_BoundsManifestTraversal(t *testing.T) {
179+ blobStorage := mocks.NewMockBlobStorage(t)
180+ manifestStorage := mocks.NewMockManifestStorage(t)
181+ svc := NewService(blobStorage, manifestStorage, nil)
182+ tags := make([]string, maxManifestTraversal+1)
183+ manifestStorage.EXPECT().ListTags("busy").Return(tags, nil)
184+
185+ _, err := svc.GetBlobPath(testContext(), "busy", "sha256:target")
186+
187+ assert.ErrorIs(t, err, domain.ErrBlobNotFound)
188+}
189+
152 func TestService_PutManifest_StorageError(t *testing.T) {190 func TestService_PutManifest_StorageError(t *testing.T) {
153 blobStorage := mocks.NewMockBlobStorage(t)191 blobStorage := mocks.NewMockBlobStorage(t)
154 manifestStorage := mocks.NewMockManifestStorage(t)192 manifestStorage := mocks.NewMockManifestStorage(t)
A internal/usecase/registrystate/state.go +57 -0
@@ -0,0 +1,57 @@
1+// Package registrystate coordinates registry mutations and garbage collection.
2+package registrystate
3+
4+import (
5+ "sync"
6+ "time"
7+)
8+
9+// PendingBlobTTL protects uploaded blobs while clients publish their manifest.
10+const PendingBlobTTL = 24 * time.Hour
11+
12+// State is shared by registry writes and registry garbage collection.
13+type State struct {
14+ MutationMu sync.RWMutex
15+
16+ pendingMu sync.Mutex
17+ pending map[string]time.Time
18+}
19+
20+// New creates empty registry coordination state.
21+func New() *State {
22+ return &State{pending: make(map[string]time.Time)}
23+}
24+
25+// AddPending records a blob that has been uploaded but not yet referenced by a manifest.
26+func (s *State) AddPending(digest string, now time.Time) {
27+ s.pendingMu.Lock()
28+ defer s.pendingMu.Unlock()
29+ if s.pending == nil {
30+ s.pending = make(map[string]time.Time)
31+ }
32+ s.pending[digest] = now
33+}
34+
35+// MarkPublished removes blobs now owned by a published manifest from the pending set.
36+func (s *State) MarkPublished(digests []string) {
37+ s.pendingMu.Lock()
38+ defer s.pendingMu.Unlock()
39+ for _, digest := range digests {
40+ delete(s.pending, digest)
41+ }
42+}
43+
44+// PendingDigests returns non-expired pending blobs and drops abandoned entries.
45+func (s *State) PendingDigests(now time.Time) map[string]struct{} {
46+ s.pendingMu.Lock()
47+ defer s.pendingMu.Unlock()
48+ result := make(map[string]struct{}, len(s.pending))
49+ for digest, createdAt := range s.pending {
50+ if now.Sub(createdAt) > PendingBlobTTL {
51+ delete(s.pending, digest)
52+ continue
53+ }
54+ result[digest] = struct{}{}
55+ }
56+ return result
57+}
@@ -0,0 +1,57 @@
1+// Package registrystate coordinates registry mutations and garbage collection.
2+package registrystate
3+
4+import (
5+ "sync"
6+ "time"
7+)
8+
9+// PendingBlobTTL protects uploaded blobs while clients publish their manifest.
10+const PendingBlobTTL = 24 * time.Hour
11+
12+// State is shared by registry writes and registry garbage collection.
13+type State struct {
14+ MutationMu sync.RWMutex
15+
16+ pendingMu sync.Mutex
17+ pending map[string]time.Time
18+}
19+
20+// New creates empty registry coordination state.
21+func New() *State {
22+ return &State{pending: make(map[string]time.Time)}
23+}
24+
25+// AddPending records a blob that has been uploaded but not yet referenced by a manifest.
26+func (s *State) AddPending(digest string, now time.Time) {
27+ s.pendingMu.Lock()
28+ defer s.pendingMu.Unlock()
29+ if s.pending == nil {
30+ s.pending = make(map[string]time.Time)
31+ }
32+ s.pending[digest] = now
33+}
34+
35+// MarkPublished removes blobs now owned by a published manifest from the pending set.
36+func (s *State) MarkPublished(digests []string) {
37+ s.pendingMu.Lock()
38+ defer s.pendingMu.Unlock()
39+ for _, digest := range digests {
40+ delete(s.pending, digest)
41+ }
42+}
43+
44+// PendingDigests returns non-expired pending blobs and drops abandoned entries.
45+func (s *State) PendingDigests(now time.Time) map[string]struct{} {
46+ s.pendingMu.Lock()
47+ defer s.pendingMu.Unlock()
48+ result := make(map[string]struct{}, len(s.pending))
49+ for digest, createdAt := range s.pending {
50+ if now.Sub(createdAt) > PendingBlobTTL {
51+ delete(s.pending, digest)
52+ continue
53+ }
54+ result[digest] = struct{}{}
55+ }
56+ return result
57+}
A internal/usecase/registrystate/state_test.go +21 -0
@@ -0,0 +1,21 @@
1+package registrystate
2+
3+import (
4+ "testing"
5+ "time"
6+
7+ "github.com/stretchr/testify/assert"
8+)
9+
10+func TestStateTracksPublishesAndExpiresPendingBlobs(t *testing.T) {
11+ now := time.Now().UTC()
12+ state := New()
13+ state.AddPending("sha256:published", now)
14+ state.AddPending("sha256:pending", now)
15+ state.AddPending("sha256:expired", now.Add(-PendingBlobTTL-time.Second))
16+
17+ state.MarkPublished([]string{"sha256:published"})
18+ pending := state.PendingDigests(now)
19+
20+ assert.Equal(t, map[string]struct{}{"sha256:pending": {}}, pending)
21+}
@@ -0,0 +1,21 @@
1+package registrystate
2+
3+import (
4+ "testing"
5+ "time"
6+
7+ "github.com/stretchr/testify/assert"
8+)
9+
10+func TestStateTracksPublishesAndExpiresPendingBlobs(t *testing.T) {
11+ now := time.Now().UTC()
12+ state := New()
13+ state.AddPending("sha256:published", now)
14+ state.AddPending("sha256:pending", now)
15+ state.AddPending("sha256:expired", now.Add(-PendingBlobTTL-time.Second))
16+
17+ state.MarkPublished([]string{"sha256:published"})
18+ pending := state.PendingDigests(now)
19+
20+ assert.Equal(t, map[string]struct{}{"sha256:pending": {}}, pending)
21+}
M internal/usecase/services/service.go +38 -6
@@ -2,10 +2,12 @@ package services
22
33 import (
44 "bufio"
5+ "bytes"
56 "context"
67 "crypto/sha256"
78 "encoding/hex"
89 "encoding/json"
10+ "errors"
911 "fmt"
1012 "io"
1113 "maps"
@@ -532,6 +534,9 @@ func (s *Service) waitLogReadiness(ctx context.Context, containerID string, svc
532534 if err == nil && found {
533535 return nil
534536 }
537+ if errors.Is(err, domain.ErrReadinessLogSizeExceeded) {
538+ return err
539+ }
535540 if err != nil {
536541 lastErr = err
537542 }
@@ -559,14 +564,41 @@ func (s *Service) logContains(ctx context.Context, containerID, path, contains s
559564 return false, err
560565 }
561566 defer reader.Close()
562- content, err := io.ReadAll(io.LimitReader(reader, maxReadinessLogSize+1))
563- if err != nil {
564- return false, err
567+ if contains == "" {
568+ return true, nil
569+ }
570+
571+ needle := []byte(contains)
572+ buffer := make([]byte, 32*1024)
573+ carry := make([]byte, 0, len(needle)-1)
574+ remaining := maxReadinessLogSize
575+ for remaining > 0 {
576+ readSize := min(len(buffer), remaining)
577+ n, readErr := reader.Read(buffer[:readSize])
578+ if n > 0 {
579+ window := append(carry, buffer[:n]...)
580+ if bytes.Contains(window, needle) {
581+ return true, nil
582+ }
583+ overlap := min(len(needle)-1, len(window))
584+ carry = append(carry[:0], window[len(window)-overlap:]...)
585+ remaining -= n
586+ }
587+ if errors.Is(readErr, io.EOF) {
588+ return false, nil
589+ }
590+ if readErr != nil {
591+ return false, readErr
592+ }
565593 }
566- if len(content) > maxReadinessLogSize {
567- return false, fmt.Errorf("readiness log exceeds %d bytes", maxReadinessLogSize)
594+
595+ var extra [1]byte
596+ if _, err := reader.Read(extra[:]); errors.Is(err, io.EOF) {
597+ return false, nil
598+ } else if err != nil {
599+ return false, err
568600 }
569- return strings.Contains(string(content), contains), nil
601+ return false, fmt.Errorf("%w: limit is %d bytes", domain.ErrReadinessLogSizeExceeded, maxReadinessLogSize)
570602 }
571603
572604 func normalizeCleanup(cleanup domain.StandaloneServiceCleanup) domain.StandaloneServiceCleanup {
@@ -2,10 +2,12 @@ package services
2 2
3 import (3 import (
4 "bufio"4 "bufio"
5+ "bytes"
5 "context"6 "context"
6 "crypto/sha256"7 "crypto/sha256"
7 "encoding/hex"8 "encoding/hex"
8 "encoding/json"9 "encoding/json"
10+ "errors"
9 "fmt"11 "fmt"
10 "io"12 "io"
11 "maps"13 "maps"
@@ -532,6 +534,9 @@ func (s *Service) waitLogReadiness(ctx context.Context, containerID string, svc
532 if err == nil && found {534 if err == nil && found {
533 return nil535 return nil
534 }536 }
537+ if errors.Is(err, domain.ErrReadinessLogSizeExceeded) {
538+ return err
539+ }
535 if err != nil {540 if err != nil {
536 lastErr = err541 lastErr = err
537 }542 }
@@ -559,14 +564,41 @@ func (s *Service) logContains(ctx context.Context, containerID, path, contains s
559 return false, err564 return false, err
560 }565 }
561 defer reader.Close()566 defer reader.Close()
562- content, err := io.ReadAll(io.LimitReader(reader, maxReadinessLogSize+1))567+ if contains == "" {
563- if err != nil {568+ return true, nil
564- return false, err569+ }
570+
571+ needle := []byte(contains)
572+ buffer := make([]byte, 32*1024)
573+ carry := make([]byte, 0, len(needle)-1)
574+ remaining := maxReadinessLogSize
575+ for remaining > 0 {
576+ readSize := min(len(buffer), remaining)
577+ n, readErr := reader.Read(buffer[:readSize])
578+ if n > 0 {
579+ window := append(carry, buffer[:n]...)
580+ if bytes.Contains(window, needle) {
581+ return true, nil
582+ }
583+ overlap := min(len(needle)-1, len(window))
584+ carry = append(carry[:0], window[len(window)-overlap:]...)
585+ remaining -= n
586+ }
587+ if errors.Is(readErr, io.EOF) {
588+ return false, nil
589+ }
590+ if readErr != nil {
591+ return false, readErr
592+ }
565 }593 }
566- if len(content) > maxReadinessLogSize {594+
567- return false, fmt.Errorf("readiness log exceeds %d bytes", maxReadinessLogSize)595+ var extra [1]byte
596+ if _, err := reader.Read(extra[:]); errors.Is(err, io.EOF) {
597+ return false, nil
598+ } else if err != nil {
599+ return false, err
568 }600 }
569- return strings.Contains(string(content), contains), nil601+ return false, fmt.Errorf("%w: limit is %d bytes", domain.ErrReadinessLogSizeExceeded, maxReadinessLogSize)
570 }602 }
571 603
572 func normalizeCleanup(cleanup domain.StandaloneServiceCleanup) domain.StandaloneServiceCleanup {604 func normalizeCleanup(cleanup domain.StandaloneServiceCleanup) domain.StandaloneServiceCleanup {
M internal/usecase/services/service_test.go +26 -0
@@ -387,6 +387,32 @@ func TestWaitLogReadinessReadsContainerFileUntilTextAppears(t *testing.T) {
387387 require.NoError(t, err)
388388 }
389389
390+func TestLogContainsStopsAtMatchBeforeSizeLimit(t *testing.T) {
391+ runtime := outmocks.NewMockContainerRuntime(t)
392+ svc := NewService(runtime)
393+ runtime.EXPECT().CopyFromContainer(mock.Anything, "container-1", "/logs/server.log").Return(
394+ io.NopCloser(strings.NewReader("ready\n"+strings.Repeat("x", maxReadinessLogSize))), nil,
395+ )
396+
397+ found, err := svc.logContains(context.Background(), "container-1", "/logs/server.log", "ready")
398+
399+ require.NoError(t, err)
400+ assert.True(t, found)
401+}
402+
403+func TestLogContainsReturnsSentinelAtSizeLimit(t *testing.T) {
404+ runtime := outmocks.NewMockContainerRuntime(t)
405+ svc := NewService(runtime)
406+ runtime.EXPECT().CopyFromContainer(mock.Anything, "container-1", "/logs/server.log").Return(
407+ io.NopCloser(strings.NewReader(strings.Repeat("x", maxReadinessLogSize+1))), nil,
408+ )
409+
410+ found, err := svc.logContains(context.Background(), "container-1", "/logs/server.log", "ready")
411+
412+ assert.False(t, found)
413+ assert.ErrorIs(t, err, domain.ErrReadinessLogSizeExceeded)
414+}
415+
390416 func TestWaitReadinessHonorsContextTimeout(t *testing.T) {
391417 svc := sampleService()
392418 svc.Readiness = domain.StandaloneServiceReadiness{Type: domain.StandaloneServiceReadinessTCP}
@@ -387,6 +387,32 @@ func TestWaitLogReadinessReadsContainerFileUntilTextAppears(t *testing.T) {
387 require.NoError(t, err)387 require.NoError(t, err)
388 }388 }
389 389
390+func TestLogContainsStopsAtMatchBeforeSizeLimit(t *testing.T) {
391+ runtime := outmocks.NewMockContainerRuntime(t)
392+ svc := NewService(runtime)
393+ runtime.EXPECT().CopyFromContainer(mock.Anything, "container-1", "/logs/server.log").Return(
394+ io.NopCloser(strings.NewReader("ready\n"+strings.Repeat("x", maxReadinessLogSize))), nil,
395+ )
396+
397+ found, err := svc.logContains(context.Background(), "container-1", "/logs/server.log", "ready")
398+
399+ require.NoError(t, err)
400+ assert.True(t, found)
401+}
402+
403+func TestLogContainsReturnsSentinelAtSizeLimit(t *testing.T) {
404+ runtime := outmocks.NewMockContainerRuntime(t)
405+ svc := NewService(runtime)
406+ runtime.EXPECT().CopyFromContainer(mock.Anything, "container-1", "/logs/server.log").Return(
407+ io.NopCloser(strings.NewReader(strings.Repeat("x", maxReadinessLogSize+1))), nil,
408+ )
409+
410+ found, err := svc.logContains(context.Background(), "container-1", "/logs/server.log", "ready")
411+
412+ assert.False(t, found)
413+ assert.ErrorIs(t, err, domain.ErrReadinessLogSizeExceeded)
414+}
415+
390 func TestWaitReadinessHonorsContextTimeout(t *testing.T) {416 func TestWaitReadinessHonorsContextTimeout(t *testing.T) {
391 svc := sampleService()417 svc := sampleService()
392 svc.Readiness = domain.StandaloneServiceReadiness{Type: domain.StandaloneServiceReadinessTCP}418 svc.Readiness = domain.StandaloneServiceReadiness{Type: domain.StandaloneServiceReadinessTCP}

Keyboard shortcuts

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

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

Branches 7

Tags 106

↑↓ moveenter openesc close