mirror of
https://github.com/docker/cli.git
synced 2026-11-07 13:32:59 -04:00
Compare commits
138
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a195d24d1f | ||
|
|
59e40e6347 | ||
|
|
ec9ac08675 | ||
|
|
1afa7ac002 | ||
|
|
f59d490370 | ||
|
|
c4e317145e | ||
|
|
a46376f216 | ||
|
|
0c2d0f700c | ||
|
|
6d274590f2 | ||
|
|
4e09976ddd | ||
|
|
69c9ccd358 | ||
|
|
1600418944 | ||
|
|
1fab379bcc | ||
|
|
df9a0f430f | ||
|
|
fa65e9a3a0 | ||
|
|
dc66c2a8d0 | ||
|
|
ae317ab44a | ||
|
|
3f2ad8bf05 | ||
|
|
6f8369014a | ||
|
|
e08aa72569 | ||
|
|
f522fffe52 | ||
|
|
5e1e84ee5d | ||
|
|
00bf2d970d | ||
|
|
4a93a41589 | ||
|
|
e300e529d5 | ||
|
|
72f6e50c62 | ||
|
|
52a2671034 | ||
|
|
1d02640576 | ||
|
|
4d74583397 | ||
|
|
de4ddc4fae | ||
|
|
ccac941dd9 | ||
|
|
e6beb40c03 | ||
|
|
dfc520dcc2 | ||
|
|
a3a2bb6118 | ||
|
|
c11295afc4 | ||
|
|
010e125d8b | ||
|
|
5cdf229230 | ||
|
|
66f6797582 | ||
|
|
cb8870a537 | ||
|
|
529f66d2f9 | ||
|
|
4d6ff7d646 | ||
|
|
b997c5b7aa | ||
|
|
1822903c1d | ||
|
|
37013cb262 | ||
|
|
61ee187552 | ||
|
|
433b13a249 | ||
|
|
5c8bfb2844 | ||
|
|
528a2ada2e | ||
|
|
de8f4032ad | ||
|
|
e4078058de | ||
|
|
8da8b00555 | ||
|
|
067d069f0c | ||
|
|
02fbbf3eb0 | ||
|
|
890976a13b | ||
|
|
23c66ccd61 | ||
|
|
3e686a8b59 | ||
|
|
f415da838b | ||
|
|
45c2317672 | ||
|
|
8308969eda | ||
|
|
cf809998ab | ||
|
|
730c880b35 | ||
|
|
e63e4fe498 | ||
|
|
e7df01fe6e | ||
|
|
2e51107855 | ||
|
|
949bf401dd | ||
|
|
9db9802127 | ||
|
|
63445d6b70 | ||
|
|
fdb4ee3e75 | ||
|
|
4ae247756e | ||
|
|
e7c0f5c53c | ||
|
|
87fb2ebe7e | ||
|
|
e8c098c8bf | ||
|
|
374636ff15 | ||
|
|
862e7e6ad4 | ||
|
|
3d2650ce76 | ||
|
|
e11705385d | ||
|
|
fd58705f3a | ||
|
|
5e6d8e3ab6 | ||
|
|
8503a98b68 | ||
|
|
488292ca1f | ||
|
|
75d1d1a9e6 | ||
|
|
cb192aeb41 | ||
|
|
6cb63efe6f | ||
|
|
48988f5c12 | ||
|
|
e3559d29fb | ||
|
|
4f8ad7605e | ||
|
|
886ab250f0 | ||
|
|
58eea06064 | ||
|
|
cd7b9082aa | ||
|
|
fb1cc07c08 | ||
|
|
482a140ce7 | ||
|
|
0123ab82cc | ||
|
|
3caafba34f | ||
|
|
07339fa78f | ||
|
|
637be3d60c | ||
|
|
6868874c98 | ||
|
|
0932e7d9a8 | ||
|
|
b53372e86a | ||
|
|
b1f2c1dc06 | ||
|
|
d6bc11318d | ||
|
|
7fc2dff9bc | ||
|
|
ca5a266c81 | ||
|
|
39f182cbaa | ||
|
|
0684ae0cc1 | ||
|
|
b7dc94c65f | ||
|
|
12566dd80d | ||
|
|
4167943209 | ||
|
|
9dcf8afb5d | ||
|
|
6fdced88c5 | ||
|
|
0a563b177a | ||
|
|
4a63305d74 | ||
|
|
87ff477cb6 | ||
|
|
101ffc09eb | ||
|
|
32ff1e7281 | ||
|
|
d146e672a6 | ||
|
|
ac976d9edb | ||
|
|
48baf6c322 | ||
|
|
47a06aa077 | ||
|
|
1bf3eb68ce | ||
|
|
60dffc9412 | ||
|
|
b0a6803f9b | ||
|
|
f90c2894a8 | ||
|
|
ed4de7d484 | ||
|
|
74b7f5cf27 | ||
|
|
824b68911b | ||
|
|
dbad17f16f | ||
|
|
eb8fab8023 | ||
|
|
7b322e4dc2 | ||
|
|
676ca0067b | ||
|
|
e8bb7f7c81 | ||
|
|
3ee4c06af6 | ||
|
|
ab989d8316 | ||
|
|
d306b9d600 | ||
|
|
0ffb4c4e0b | ||
|
|
e914d34ac3 | ||
|
|
a6538bea40 | ||
|
|
441acb383d | ||
|
|
ed68c49095 |
@@ -1,34 +1,37 @@
|
||||
<!--
|
||||
Make sure you've read and understood our contributing guidelines;
|
||||
Please make sure you've read and understood our contributing guidelines;
|
||||
https://github.com/docker/cli/blob/master/CONTRIBUTING.md
|
||||
|
||||
** Make sure all your commits include a signature generated with `git commit -s` **
|
||||
Make sure commits are signed off (`git commit -s`) with your real name.
|
||||
|
||||
If this is a bug fix, make sure your description includes "fixes #xxxx", or
|
||||
"closes #xxxx"
|
||||
If the PR relates to an existing issue or PR, mention it at the top:
|
||||
|
||||
Provide the following information:
|
||||
- Fixes: https://github.com/docker/cli/issues/12345678
|
||||
- Replaces: https://github.com/docker/cli/pull/12345678
|
||||
- Follow up to: https://github.com/docker/cli/pull/12345678
|
||||
- Related to: https://github.com/docker/cli/issues/12345678
|
||||
-->
|
||||
|
||||
**- What I did**
|
||||
## Summary
|
||||
|
||||
**- How I did it**
|
||||
|
||||
**- How to verify it**
|
||||
|
||||
**- Human readable description for the release notes**
|
||||
## Release notes (optional)
|
||||
|
||||
<!--
|
||||
Write a short (one line) summary that describes the changes in this
|
||||
pull request for inclusion in the changelog.
|
||||
It must be placed inside the below triple backticks section.
|
||||
One-sentence user-facing release note.
|
||||
Leave empty if the change is not changelog-worthy.
|
||||
Use present tense and imperative tone.
|
||||
|
||||
NOTE: Only fill this section if changes introduced in this PR are user-facing.
|
||||
The PR must have a relevant impact/ label.
|
||||
Good:
|
||||
- Fix a panic when running `docker top` on a non-running Windows container.
|
||||
- Don't print warnings in `docker info` for broken symlinks in CLI plugin directories.
|
||||
|
||||
Bad:
|
||||
- Refactor test TestFooWithBar
|
||||
- Fixed a panic when running docker top
|
||||
-->
|
||||
|
||||
```markdown changelog
|
||||
|
||||
```
|
||||
|
||||
**- A picture of a cute animal (not mandatory but encouraged)**
|
||||
## A picture of a cute animal (not mandatory but encouraged)
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
{
|
||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||
"extends": [
|
||||
"config:recommended",
|
||||
// https://docs.renovatebot.com/modules/manager/github-actions/
|
||||
// https://docs.renovatebot.com/presets-helpers/
|
||||
"helpers:pinGitHubActionDigestsToSemver",
|
||||
":gitSignOff"
|
||||
],
|
||||
"gomod": {
|
||||
"managerFilePatterns": ["/(^|/)vendor\\.mod$/"]
|
||||
},
|
||||
"postUpdateOptions": ["gomodTidy"],
|
||||
"minimumReleaseAge": "3 days",
|
||||
"internalChecksFilter": "strict",
|
||||
"packageRules": [
|
||||
{
|
||||
"matchManagers": ["gomod"],
|
||||
"matchFileNames": ["vendor.mod"],
|
||||
"semanticCommits": "disabled",
|
||||
"commitMessagePrefix": "vendor:"
|
||||
},
|
||||
{
|
||||
"matchManagers": ["gomod"],
|
||||
"matchFileNames": ["cmd/docker-trust/go.mod"],
|
||||
// The trust module does not vendor dependencies; vendor/ belongs to the root module.
|
||||
"postUpdateOptions": ["gomodSkipVendor"],
|
||||
"semanticCommits": "disabled",
|
||||
"commitMessagePrefix": "cmd/docker-trust:"
|
||||
},
|
||||
{
|
||||
"matchManagers": ["gomod"],
|
||||
"matchFileNames": ["vendor.mod"],
|
||||
"groupName": "golang.org/x packages",
|
||||
"groupSlug": "golang-x",
|
||||
"matchDepNames": ["/^golang\\.org\\/x\\//"]
|
||||
},
|
||||
{
|
||||
"matchManagers": ["github-actions"],
|
||||
"minimumReleaseAge": "7 days",
|
||||
"addLabels": ["area/ci", "status/2-code-review"]
|
||||
},
|
||||
{
|
||||
"matchManagers": ["github-actions"],
|
||||
"groupName": "docker GitHub Actions",
|
||||
"groupSlug": "docker-actions",
|
||||
"matchDepNames": ["/^docker\\//"]
|
||||
},
|
||||
{
|
||||
// Keep all the google.golang.org/genproto modules at the same version.
|
||||
"matchManagers": ["gomod"],
|
||||
"matchFileNames": ["vendor.mod"],
|
||||
"groupName": "google.golang.org/genproto packages",
|
||||
"groupSlug": "google-genproto",
|
||||
"matchDepNames": ["/^google\\.golang\\.org\\/genproto(\\/|$)/"],
|
||||
"schedule": ["every month"]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -29,7 +29,7 @@ on:
|
||||
|
||||
jobs:
|
||||
prepare:
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: ubuntu-26.04
|
||||
outputs:
|
||||
matrix: ${{ steps.platforms.outputs.matrix }}
|
||||
steps:
|
||||
@@ -49,7 +49,7 @@ jobs:
|
||||
echo ${{ steps.platforms.outputs.matrix }}
|
||||
|
||||
build:
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: ubuntu-26.04
|
||||
needs:
|
||||
- prepare
|
||||
strategy:
|
||||
@@ -65,10 +65,10 @@ jobs:
|
||||
steps:
|
||||
-
|
||||
name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
|
||||
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
|
||||
-
|
||||
name: Build
|
||||
uses: docker/bake-action@d3418bd7d0e9324001bca92fa8ba175ea7e6dc9b # v7.3.0
|
||||
uses: docker/bake-action@018cb6412ab401ebaa809aa5f85966b74628600f # v7.4.0
|
||||
with:
|
||||
targets: ${{ matrix.target }}
|
||||
set: |
|
||||
@@ -91,7 +91,7 @@ jobs:
|
||||
PLATFORM: ${{ matrix.platform }}
|
||||
-
|
||||
name: Upload artifacts
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: ${{ env.ARTIFACT_NAME }}
|
||||
path: /tmp/out/*
|
||||
@@ -126,7 +126,7 @@ jobs:
|
||||
connection_id: ${{ vars.DOCKERHUB_OIDC_CONNECTIONID }}
|
||||
|
||||
prepare-plugins:
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: ubuntu-26.04
|
||||
outputs:
|
||||
matrix: ${{ steps.platforms.outputs.matrix }}
|
||||
steps:
|
||||
@@ -146,7 +146,7 @@ jobs:
|
||||
echo ${{ steps.platforms.outputs.matrix }}
|
||||
|
||||
plugins:
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: ubuntu-26.04
|
||||
needs:
|
||||
- prepare-plugins
|
||||
strategy:
|
||||
@@ -156,10 +156,10 @@ jobs:
|
||||
steps:
|
||||
-
|
||||
name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
|
||||
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
|
||||
-
|
||||
name: Build
|
||||
uses: docker/bake-action@d3418bd7d0e9324001bca92fa8ba175ea7e6dc9b # v7.3.0
|
||||
uses: docker/bake-action@018cb6412ab401ebaa809aa5f85966b74628600f # v7.4.0
|
||||
with:
|
||||
targets: plugins-cross
|
||||
set: |
|
||||
|
||||
@@ -34,7 +34,7 @@ on:
|
||||
|
||||
jobs:
|
||||
codeql:
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: ubuntu-26.04
|
||||
timeout-minutes: 10
|
||||
env:
|
||||
DISABLE_WARN_OUTSIDE_CONTAINER: '1'
|
||||
@@ -64,18 +64,18 @@ jobs:
|
||||
name: Update Go
|
||||
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
|
||||
with:
|
||||
go-version: "1.26.8"
|
||||
go-version: "1.26.10"
|
||||
cache: false
|
||||
-
|
||||
name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9
|
||||
uses: github/codeql-action/init@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3
|
||||
with:
|
||||
languages: go
|
||||
-
|
||||
name: Autobuild
|
||||
uses: github/codeql-action/autobuild@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9
|
||||
uses: github/codeql-action/autobuild@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3
|
||||
-
|
||||
name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9
|
||||
uses: github/codeql-action/analyze@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3
|
||||
with:
|
||||
category: "/language:go"
|
||||
|
||||
@@ -26,7 +26,7 @@ on:
|
||||
|
||||
jobs:
|
||||
tests:
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: ubuntu-26.04
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -37,6 +37,7 @@ jobs:
|
||||
- alpine
|
||||
- debian
|
||||
engine-version:
|
||||
- master # moby/moby-bin:master
|
||||
- rc # latest rc
|
||||
- 29 # latest
|
||||
- 28 # latest - 1
|
||||
@@ -51,7 +52,7 @@ jobs:
|
||||
name: Update daemon.json
|
||||
run: |
|
||||
if [ ! -f /etc/docker/daemon.json ]; then
|
||||
# ubuntu 24.04 runners no longer have a default daemon.json present
|
||||
# ubuntu runners no longer have a default daemon.json present
|
||||
sudo mkdir -p /etc/docker/
|
||||
echo '{"experimental": true}' | sudo tee /etc/docker/daemon.json
|
||||
else
|
||||
@@ -65,7 +66,7 @@ jobs:
|
||||
docker info
|
||||
-
|
||||
name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
|
||||
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
|
||||
-
|
||||
name: Run ${{ matrix.target }}
|
||||
run: |
|
||||
@@ -76,7 +77,7 @@ jobs:
|
||||
TESTFLAGS: -coverprofile=/tmp/coverage/coverage.txt
|
||||
-
|
||||
name: Send to Codecov
|
||||
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
|
||||
uses: codecov/codecov-action@303a32d7a59b442fa8d48b6a1cc6825c09c847a5 # v7.1.1
|
||||
with:
|
||||
files: ./build/coverage/coverage.txt
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
|
||||
@@ -35,7 +35,7 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Upload context
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: pr-review-context
|
||||
path: context/
|
||||
|
||||
@@ -11,7 +11,7 @@ permissions:
|
||||
|
||||
jobs:
|
||||
review:
|
||||
uses: docker/docker-agent-action/.github/workflows/review-pr.yml@06e1767af06263c93d712449cbf859778d9392ee # v2.0.5
|
||||
uses: docker/docker-agent-action/.github/workflows/review-pr.yml@3654ec391b070e247de2e2e3c1ff2519b6378b3a # v2.0.9
|
||||
permissions:
|
||||
contents: read # Read repository files and PR diffs
|
||||
pull-requests: write # Post review comments
|
||||
|
||||
@@ -26,7 +26,7 @@ on:
|
||||
|
||||
jobs:
|
||||
sync-release-branch:
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: ubuntu-26.04
|
||||
permissions:
|
||||
contents: write
|
||||
actions: write
|
||||
@@ -116,7 +116,7 @@ jobs:
|
||||
push-release-branch:
|
||||
needs: sync-release-branch
|
||||
if: ${{ !inputs.dry_run && needs.sync-release-branch.outputs.has_changes == 'true' }}
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: ubuntu-26.04
|
||||
environment: docker-releases
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
@@ -26,19 +26,19 @@ on:
|
||||
|
||||
jobs:
|
||||
ctn:
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: ubuntu-26.04
|
||||
steps:
|
||||
-
|
||||
name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
|
||||
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
|
||||
-
|
||||
name: Test
|
||||
uses: docker/bake-action@d3418bd7d0e9324001bca92fa8ba175ea7e6dc9b # v7.3.0
|
||||
uses: docker/bake-action@018cb6412ab401ebaa809aa5f85966b74628600f # v7.4.0
|
||||
with:
|
||||
targets: test-coverage
|
||||
-
|
||||
name: Send to Codecov
|
||||
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
|
||||
uses: codecov/codecov-action@303a32d7a59b442fa8d48b6a1cc6825c09c847a5 # v7.1.1
|
||||
with:
|
||||
files: ./build/coverage/coverage.txt
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
@@ -68,7 +68,7 @@ jobs:
|
||||
name: Set up Go
|
||||
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
|
||||
with:
|
||||
go-version: "1.26.8"
|
||||
go-version: "1.26.10"
|
||||
cache: false
|
||||
-
|
||||
name: Test
|
||||
@@ -82,7 +82,7 @@ jobs:
|
||||
shell: bash
|
||||
-
|
||||
name: Send to Codecov
|
||||
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
|
||||
uses: codecov/codecov-action@303a32d7a59b442fa8d48b6a1cc6825c09c847a5 # v7.1.1
|
||||
with:
|
||||
files: /tmp/coverage.txt
|
||||
working-directory: ${{ env.GOPATH }}/src/github.com/docker/cli
|
||||
|
||||
@@ -10,7 +10,7 @@ on:
|
||||
|
||||
jobs:
|
||||
validate-milestone:
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: ubuntu-26.04
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Validate milestone matches VERSION
|
||||
|
||||
@@ -15,7 +15,7 @@ on:
|
||||
|
||||
jobs:
|
||||
check-labels:
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: ubuntu-26.04
|
||||
timeout-minutes: 120 # guardrails timeout for the whole job
|
||||
steps:
|
||||
- name: Missing `area/` label
|
||||
@@ -32,7 +32,7 @@ jobs:
|
||||
run: exit 0
|
||||
|
||||
check-changelog:
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: ubuntu-26.04
|
||||
timeout-minutes: 120 # guardrails timeout for the whole job
|
||||
env:
|
||||
HAS_IMPACT_LABEL: ${{ contains(join(github.event.pull_request.labels.*.name, ','), 'impact/') }}
|
||||
@@ -70,7 +70,7 @@ jobs:
|
||||
echo "$desc"
|
||||
|
||||
check-pr-branch:
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: ubuntu-26.04
|
||||
timeout-minutes: 120 # guardrails timeout for the whole job
|
||||
env:
|
||||
PR_TITLE: ${{ github.event.pull_request.title }}
|
||||
|
||||
@@ -26,7 +26,7 @@ on:
|
||||
|
||||
jobs:
|
||||
validate:
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: ubuntu-26.04
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -38,13 +38,13 @@ jobs:
|
||||
steps:
|
||||
-
|
||||
name: Run
|
||||
uses: docker/bake-action@d3418bd7d0e9324001bca92fa8ba175ea7e6dc9b # v7.3.0
|
||||
uses: docker/bake-action@018cb6412ab401ebaa809aa5f85966b74628600f # v7.4.0
|
||||
with:
|
||||
targets: ${{ matrix.target }}
|
||||
|
||||
# check that the generated Markdown and the checked-in files match
|
||||
validate-md:
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: ubuntu-26.04
|
||||
steps:
|
||||
-
|
||||
name: Checkout
|
||||
@@ -66,7 +66,7 @@ jobs:
|
||||
fi
|
||||
|
||||
validate-make:
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: ubuntu-26.04
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -86,7 +86,7 @@ jobs:
|
||||
make -f docker.Makefile ${{ matrix.target }}
|
||||
|
||||
validate-gocompat:
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: ubuntu-26.04
|
||||
env:
|
||||
GOPATH: ${{ github.workspace }}
|
||||
GO111MODULE: off
|
||||
@@ -101,7 +101,7 @@ jobs:
|
||||
name: Set up Go
|
||||
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
|
||||
with:
|
||||
go-version: "1.26.8"
|
||||
go-version: "1.26.10"
|
||||
cache: false
|
||||
-
|
||||
name: Run gocompat check
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@ run:
|
||||
# which causes it to fallback to go1.17 semantics.
|
||||
#
|
||||
# TODO(thaJeztah): update "usetesting" settings to enable go1.24 features once our minimum version is go1.24
|
||||
go: "1.26.8"
|
||||
go: "1.26.10"
|
||||
|
||||
timeout: 5m
|
||||
|
||||
|
||||
+4
-4
@@ -8,11 +8,11 @@ ARG BASE_VARIANT=alpine
|
||||
ARG ALPINE_VERSION=3.23
|
||||
ARG BASE_DEBIAN_DISTRO=bookworm
|
||||
|
||||
ARG GO_VERSION=1.26.8
|
||||
ARG GO_VERSION=1.26.10
|
||||
|
||||
# XX_VERSION specifies the version of the xx utility to use.
|
||||
# It must be a valid tag in the docker.io/tonistiigi/xx image repository.
|
||||
ARG XX_VERSION=1.9.0
|
||||
ARG XX_VERSION=1.10.0
|
||||
|
||||
# GOVERSIONINFO_VERSION is the version of GoVersionInfo to install.
|
||||
# It must be a valid tag from https://github.com/josephspurrier/goversioninfo
|
||||
@@ -25,12 +25,12 @@ ARG GOTESTSUM_VERSION=v1.13.0
|
||||
# BUILDX_VERSION sets the version of buildx to use for the e2e tests.
|
||||
# It must be a tag in the docker.io/docker/buildx-bin image repository
|
||||
# on Docker Hub.
|
||||
ARG BUILDX_VERSION=0.34.1
|
||||
ARG BUILDX_VERSION=0.38.0
|
||||
|
||||
# COMPOSE_VERSION is the version of compose to install in the dev container.
|
||||
# It must be a tag in the docker.io/docker/compose-bin image repository
|
||||
# on Docker Hub.
|
||||
ARG COMPOSE_VERSION=v5.1.3
|
||||
ARG COMPOSE_VERSION=v5.6.0
|
||||
|
||||
FROM --platform=$BUILDPLATFORM tonistiigi/xx:${XX_VERSION} AS xx
|
||||
|
||||
|
||||
@@ -30,7 +30,11 @@ package hooks
|
||||
type ResponseType int
|
||||
|
||||
const (
|
||||
// NextSteps renders suggestions under a shared "What's next:" header.
|
||||
NextSteps ResponseType = 0
|
||||
// GenericMessage renders before NextSteps, without a header or indentation.
|
||||
// CLI versions that do not recognize this type ignore the response.
|
||||
GenericMessage ResponseType = 1
|
||||
)
|
||||
|
||||
// Request is the type representing the information
|
||||
|
||||
@@ -7,17 +7,42 @@ const (
|
||||
indent = " "
|
||||
)
|
||||
|
||||
// PrintNextSteps renders list of [NextSteps] messages and writes them
|
||||
// to out. It is a no-op if messages is empty.
|
||||
func PrintNextSteps(out io.Writer, messages []string) {
|
||||
if len(messages) == 0 {
|
||||
return
|
||||
// PrintMessages groups messages by type and writes them to out. Generic messages
|
||||
// appear first, without a header or indentation, followed by next steps under a
|
||||
// shared "What's next:" header. Order within each type is preserved.
|
||||
// Messages with no lines or an unknown type produce no output.
|
||||
func PrintMessages(out io.Writer, messages []Response, evaluate func(Response) []string) {
|
||||
grouped := make(map[ResponseType][]string)
|
||||
for _, message := range messages {
|
||||
lines := evaluate(message)
|
||||
grouped[message.Type] = append(grouped[message.Type], lines...)
|
||||
}
|
||||
for _, responseType := range []ResponseType{GenericMessage, NextSteps} {
|
||||
lines := grouped[responseType]
|
||||
if len(lines) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
_, _ = io.WriteString(out, whatsNext)
|
||||
for _, msg := range messages {
|
||||
_, _ = io.WriteString(out, indent)
|
||||
_, _ = io.WriteString(out, msg)
|
||||
_, _ = io.WriteString(out, "\n")
|
||||
header, prefix := "\n", ""
|
||||
if responseType == NextSteps {
|
||||
header, prefix = whatsNext, indent
|
||||
}
|
||||
|
||||
_, _ = io.WriteString(out, header)
|
||||
for _, line := range lines {
|
||||
_, _ = io.WriteString(out, prefix)
|
||||
_, _ = io.WriteString(out, line)
|
||||
_, _ = io.WriteString(out, "\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// PrintNextSteps renders list of [NextSteps] messages and writes them
|
||||
// to out. It is a no-op if messages is empty.
|
||||
//
|
||||
// Deprecated: use [PrintMessages] instead.
|
||||
func PrintNextSteps(out io.Writer, messages []string) {
|
||||
PrintMessages(out, []Response{{Type: NextSteps}}, func(Response) []string {
|
||||
return messages
|
||||
})
|
||||
}
|
||||
|
||||
@@ -8,7 +8,85 @@ import (
|
||||
"gotest.tools/v3/assert"
|
||||
)
|
||||
|
||||
func TestPrintHookMessages(t *testing.T) {
|
||||
func TestPrintMessages(t *testing.T) {
|
||||
const header = "\n\x1b[1mWhat's next:\x1b[0m\n"
|
||||
|
||||
for _, tc := range []struct {
|
||||
doc string
|
||||
messages []hooks.Response
|
||||
expected string
|
||||
}{
|
||||
{doc: "no messages"},
|
||||
{
|
||||
doc: "no lines",
|
||||
messages: []hooks.Response{
|
||||
{Type: hooks.GenericMessage},
|
||||
{Type: hooks.NextSteps},
|
||||
},
|
||||
},
|
||||
{
|
||||
doc: "generic message",
|
||||
messages: []hooks.Response{
|
||||
{Type: hooks.GenericMessage, Template: "Session active"},
|
||||
},
|
||||
expected: "\nSession active\n",
|
||||
},
|
||||
{
|
||||
doc: "preserve message formatting",
|
||||
messages: []hooks.Response{
|
||||
{Type: hooks.GenericMessage, Template: "Session active\n\n Details"},
|
||||
},
|
||||
expected: "\nSession active\n\n Details\n",
|
||||
},
|
||||
{
|
||||
doc: "next steps",
|
||||
messages: []hooks.Response{
|
||||
{Type: hooks.NextSteps, Template: "Try another command"},
|
||||
},
|
||||
expected: header + " Try another command\n",
|
||||
},
|
||||
{
|
||||
doc: "group interleaved types and preserve order within each type",
|
||||
messages: []hooks.Response{
|
||||
{Type: hooks.NextSteps, Template: "First suggestion"},
|
||||
{Type: hooks.GenericMessage, Template: "Session active"},
|
||||
{Type: hooks.NextSteps, Template: "Second suggestion\nThird suggestion"},
|
||||
{Type: hooks.GenericMessage, Template: "Another status"},
|
||||
},
|
||||
expected: "\nSession active\nAnother status\n" + header + " First suggestion\n Second suggestion\n Third suggestion\n",
|
||||
},
|
||||
{
|
||||
doc: "unknown type",
|
||||
messages: []hooks.Response{
|
||||
{Type: hooks.ResponseType(99), Template: "Ignored"},
|
||||
},
|
||||
},
|
||||
{
|
||||
doc: "unknown type does not affect known messages",
|
||||
messages: []hooks.Response{
|
||||
{Type: hooks.ResponseType(99), Template: "Ignored"},
|
||||
{Type: hooks.GenericMessage, Template: "Session active"},
|
||||
{Type: hooks.NextSteps, Template: "Try another command"},
|
||||
},
|
||||
expected: "\nSession active\n" + header + " Try another command\n",
|
||||
},
|
||||
} {
|
||||
t.Run(tc.doc, func(t *testing.T) {
|
||||
var w strings.Builder
|
||||
hooks.PrintMessages(&w, tc.messages, func(message hooks.Response) []string {
|
||||
if message.Template == "" {
|
||||
return nil
|
||||
}
|
||||
lines, err := hooks.ParseTemplate(message.Template, nil)
|
||||
assert.NilError(t, err)
|
||||
return lines
|
||||
})
|
||||
assert.Equal(t, w.String(), tc.expected)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintNextSteps(t *testing.T) {
|
||||
const header = "\n\x1b[1mWhat's next:\x1b[0m\n"
|
||||
|
||||
tests := []struct {
|
||||
@@ -34,6 +112,11 @@ func TestPrintHookMessages(t *testing.T) {
|
||||
" Foo\n" +
|
||||
" bar\n",
|
||||
},
|
||||
{
|
||||
doc: "preserve empty and indented lines",
|
||||
messages: []string{"", " Details", ""},
|
||||
expectedOutput: header + " \n Details\n \n",
|
||||
},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.doc, func(t *testing.T) {
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
"text/template"
|
||||
|
||||
@@ -43,7 +44,16 @@ func ParseTemplate(hookTemplate string, cmd *cobra.Command) ([]string, error) {
|
||||
if n := strings.Count(out, "\n"); n > maxMessages {
|
||||
return nil, fmt.Errorf("hook template contains too many messages (%d): maximum is %d", n, maxMessages)
|
||||
}
|
||||
return strings.SplitN(out, "\n", maxMessages), nil
|
||||
lines := strings.SplitN(out, "\n", maxMessages)
|
||||
|
||||
// Skip messages containing only whitespace, but preserve empty lines
|
||||
// in messages that have content (e.g., for paragraph spacing).
|
||||
if !slices.ContainsFunc(lines, func(line string) bool {
|
||||
return strings.TrimSpace(line) != ""
|
||||
}) {
|
||||
return nil, nil
|
||||
}
|
||||
return lines, nil
|
||||
}
|
||||
|
||||
var ErrHookTemplateParse = errors.New("failed to parse hook template")
|
||||
|
||||
@@ -33,7 +33,17 @@ func TestParseTemplate(t *testing.T) {
|
||||
{
|
||||
doc: "empty template",
|
||||
template: "",
|
||||
expectedOutput: []string{""},
|
||||
expectedOutput: nil,
|
||||
},
|
||||
{
|
||||
doc: "only empty lines",
|
||||
template: "\n\n",
|
||||
expectedOutput: nil,
|
||||
},
|
||||
{
|
||||
doc: "whitespace-only lines",
|
||||
template: " \n\t\n ",
|
||||
expectedOutput: nil,
|
||||
},
|
||||
{
|
||||
doc: "plain message",
|
||||
|
||||
@@ -49,11 +49,18 @@ func RunPluginHooks(ctx context.Context, dockerCLI config.Provider, rootCmd, sub
|
||||
}
|
||||
|
||||
func runHooks(ctx context.Context, cfg *configfile.ConfigFile, rootCmd, subCommand *cobra.Command, invokedCommand string, flags map[string]string, cmdErrorMessage string) {
|
||||
nextSteps := invokeAndCollectHooks(ctx, cfg, rootCmd, subCommand, invokedCommand, flags, cmdErrorMessage)
|
||||
hooks.PrintNextSteps(subCommand.ErrOrStderr(), nextSteps)
|
||||
messages := invokeAndCollectHooks(ctx, cfg, rootCmd, invokedCommand, flags, cmdErrorMessage)
|
||||
hooks.PrintMessages(subCommand.ErrOrStderr(), messages, func(message hooks.Response) []string {
|
||||
lines, err := hooks.ParseTemplate(message.Template, subCommand)
|
||||
if err != nil {
|
||||
logrus.WithError(err).Debug("Failed to evaluate hook template")
|
||||
return nil
|
||||
}
|
||||
return lines
|
||||
})
|
||||
}
|
||||
|
||||
func invokeAndCollectHooks(ctx context.Context, cfg *configfile.ConfigFile, rootCmd, subCmd *cobra.Command, subCmdStr string, flags map[string]string, cmdErrorMessage string) []string {
|
||||
func invokeAndCollectHooks(ctx context.Context, cfg *configfile.ConfigFile, rootCmd *cobra.Command, subCmdStr string, flags map[string]string, cmdErrorMessage string) []hooks.Response {
|
||||
if ctx.Err() != nil {
|
||||
return nil
|
||||
}
|
||||
@@ -64,17 +71,17 @@ func invokeAndCollectHooks(ctx context.Context, cfg *configfile.ConfigFile, root
|
||||
}
|
||||
|
||||
pluginDirs := getPluginDirs(cfg)
|
||||
nextSteps := make([]string, 0, len(pluginsCfg))
|
||||
var collected []hooks.Response
|
||||
|
||||
tryInvokeHook := func(pluginName string, pluginCfg map[string]string) (messages []string, ok bool, err error) {
|
||||
tryInvokeHook := func(pluginName string, pluginCfg map[string]string) (hooks.Response, error) {
|
||||
match, matched := pluginMatch(pluginCfg, subCmdStr, cmdErrorMessage)
|
||||
if !matched {
|
||||
return nil, false, nil
|
||||
return hooks.Response{}, nil
|
||||
}
|
||||
|
||||
p, err := getPlugin(pluginName, pluginDirs, rootCmd)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
return hooks.Response{}, err
|
||||
}
|
||||
|
||||
resp, err := p.RunHook(ctx, hooks.Request{
|
||||
@@ -83,29 +90,23 @@ func invokeAndCollectHooks(ctx context.Context, cfg *configfile.ConfigFile, root
|
||||
CommandError: cmdErrorMessage,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
return hooks.Response{}, err
|
||||
}
|
||||
|
||||
var message hooks.Response
|
||||
if err := json.Unmarshal(resp, &message); err != nil {
|
||||
return nil, false, fmt.Errorf("failed to unmarshal hook response (%q): %w", string(resp), err)
|
||||
return hooks.Response{}, fmt.Errorf("failed to unmarshal hook response (%q): %w", string(resp), err)
|
||||
}
|
||||
|
||||
// currently the only hook type
|
||||
if message.Type != hooks.NextSteps {
|
||||
return nil, false, errors.New("unexpected hook response type: " + strconv.Itoa(int(message.Type)))
|
||||
if message.Type != hooks.NextSteps && message.Type != hooks.GenericMessage {
|
||||
return hooks.Response{}, errors.New("unexpected hook response type: " + strconv.Itoa(int(message.Type)))
|
||||
}
|
||||
|
||||
messages, err = hooks.ParseTemplate(message.Template, subCmd)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
return messages, true, nil
|
||||
return message, nil
|
||||
}
|
||||
|
||||
for pluginName, pluginCfg := range pluginsCfg {
|
||||
messages, ok, err := tryInvokeHook(pluginName, pluginCfg)
|
||||
message, err := tryInvokeHook(pluginName, pluginCfg)
|
||||
if err != nil {
|
||||
// skip misbehaving plugins, but don't halt execution
|
||||
logrus.WithFields(logrus.Fields{
|
||||
@@ -114,38 +115,13 @@ func invokeAndCollectHooks(ctx context.Context, cfg *configfile.ConfigFile, root
|
||||
}).Debug("Plugin hook invocation failed")
|
||||
continue
|
||||
}
|
||||
if !ok {
|
||||
if message.Template == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
var appended bool
|
||||
nextSteps, appended = appendNextSteps(nextSteps, messages)
|
||||
if !appended {
|
||||
logrus.WithFields(logrus.Fields{
|
||||
"plugin": pluginName,
|
||||
}).Debug("Plugin responded with an empty hook message; ignoring")
|
||||
}
|
||||
collected = append(collected, message)
|
||||
}
|
||||
return nextSteps
|
||||
}
|
||||
|
||||
// appendNextSteps appends the processed hook output to the nextSteps slice.
|
||||
// If the processed hook output is empty, it is not appended.
|
||||
// Empty lines are not stripped if there's at least one non-empty line.
|
||||
func appendNextSteps(nextSteps []string, processed []string) ([]string, bool) {
|
||||
empty := true
|
||||
for _, l := range processed {
|
||||
if strings.TrimSpace(l) != "" {
|
||||
empty = false
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if empty {
|
||||
return nextSteps, false
|
||||
}
|
||||
|
||||
return append(nextSteps, processed...), true
|
||||
return collected
|
||||
}
|
||||
|
||||
// pluginMatch takes a plugin configuration and a string representing the
|
||||
|
||||
@@ -2,6 +2,11 @@ package manager
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/docker/cli/cli/config/configfile"
|
||||
@@ -258,38 +263,6 @@ func TestMatchHookConfig(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppendNextSteps(t *testing.T) {
|
||||
testCases := []struct {
|
||||
processed []string
|
||||
expectedOut []string
|
||||
}{
|
||||
{
|
||||
processed: []string{},
|
||||
expectedOut: []string{},
|
||||
},
|
||||
{
|
||||
processed: []string{"", ""},
|
||||
expectedOut: []string{},
|
||||
},
|
||||
{
|
||||
processed: []string{"Some hint", "", "Some other hint"},
|
||||
expectedOut: []string{"Some hint", "", "Some other hint"},
|
||||
},
|
||||
{
|
||||
processed: []string{"Hint 1", "Hint 2"},
|
||||
expectedOut: []string{"Hint 1", "Hint 2"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run("", func(t *testing.T) {
|
||||
got, appended := appendNextSteps([]string{}, tc.processed)
|
||||
assert.Check(t, is.DeepEqual(got, tc.expectedOut))
|
||||
assert.Check(t, is.Equal(appended, len(got) > 0))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunPluginHooksPassesErrorMessage(t *testing.T) {
|
||||
cfg := configfile.New("")
|
||||
cfg.Plugins = map[string]map[string]string{
|
||||
@@ -336,7 +309,7 @@ func TestInvokeAndCollectHooksErrorHooksSkippedOnSuccess(t *testing.T) {
|
||||
// On success, error-hooks should not match, so the plugin
|
||||
// binary is never looked up and no results are returned.
|
||||
result := invokeAndCollectHooks(
|
||||
context.Background(), cfg, root, sub,
|
||||
context.Background(), cfg, root,
|
||||
"build", map[string]string{}, "",
|
||||
)
|
||||
assert.Check(t, is.Len(result, 0))
|
||||
@@ -349,7 +322,7 @@ func TestInvokeAndCollectHooksNoPlugins(t *testing.T) {
|
||||
root.AddCommand(sub)
|
||||
|
||||
result := invokeAndCollectHooks(
|
||||
context.Background(), cfg, root, sub,
|
||||
context.Background(), cfg, root,
|
||||
"build", map[string]string{}, "some error",
|
||||
)
|
||||
assert.Check(t, is.Len(result, 0))
|
||||
@@ -368,8 +341,108 @@ func TestInvokeAndCollectHooksCancelledContext(t *testing.T) {
|
||||
cancel() // cancel immediately
|
||||
|
||||
result := invokeAndCollectHooks(
|
||||
ctx, cfg, root, sub,
|
||||
ctx, cfg, root,
|
||||
"build", map[string]string{}, "exit status 1",
|
||||
)
|
||||
assert.Check(t, is.Nil(result))
|
||||
}
|
||||
|
||||
func TestRunHooksMessages(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("test plugins require a POSIX shell")
|
||||
}
|
||||
|
||||
const header = "\n\x1b[1mWhat's next:\x1b[0m\n"
|
||||
for _, tc := range []struct {
|
||||
doc string
|
||||
responses map[string]string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
doc: "generic message preserves formatting and expands templates",
|
||||
responses: map[string]string{
|
||||
"status": `{"Type":1,"Template":"Status for {{command}}\n\n Details"}`,
|
||||
},
|
||||
expected: "\nStatus for build\n\n Details\n",
|
||||
},
|
||||
{
|
||||
doc: "omitted type defaults to next steps",
|
||||
responses: map[string]string{
|
||||
"suggestion": `{"Template":"Try another command"}`,
|
||||
},
|
||||
expected: header + " Try another command\n",
|
||||
},
|
||||
{
|
||||
doc: "generic messages precede shared next steps header",
|
||||
responses: map[string]string{
|
||||
"statusone": `{"Type":1,"Template":"Session active"}`,
|
||||
"statustwo": `{"Type":1,"Template":"Session active"}`,
|
||||
"suggestionone": `{"Type":0,"Template":"Try another command"}`,
|
||||
"suggestiontwo": `{"Type":0,"Template":"Try another command"}`,
|
||||
},
|
||||
expected: "\nSession active\nSession active\n" + header + " Try another command\n Try another command\n",
|
||||
},
|
||||
{
|
||||
doc: "empty responses produce no output",
|
||||
responses: map[string]string{
|
||||
"status": `{"Type":1,"Template":" \n\t"}`,
|
||||
"suggestion": `{"Type":0,"Template":""}`,
|
||||
},
|
||||
},
|
||||
{
|
||||
doc: "empty generic message does not affect next steps",
|
||||
responses: map[string]string{
|
||||
"status": `{"Type":1,"Template":""}`,
|
||||
"suggestion": `{"Type":0,"Template":"Try another command"}`,
|
||||
},
|
||||
expected: header + " Try another command\n",
|
||||
},
|
||||
{
|
||||
doc: "invalid responses do not affect other plugins",
|
||||
responses: map[string]string{
|
||||
"unknown": `{"Type":99,"Template":"Ignored"}`,
|
||||
"invalid": `{"Type":1,"Template":"{{"}`,
|
||||
"status": `{"Type":1,"Template":"Session active"}`,
|
||||
"suggestion": `{"Type":0,"Template":"Try another command"}`,
|
||||
},
|
||||
expected: "\nSession active\n" + header + " Try another command\n",
|
||||
},
|
||||
} {
|
||||
t.Run(tc.doc, func(t *testing.T) {
|
||||
pluginDir := t.TempDir()
|
||||
cfg := configfile.New("")
|
||||
cfg.CLIPluginsExtraDirs = []string{pluginDir}
|
||||
cfg.Plugins = make(map[string]map[string]string)
|
||||
for name, response := range tc.responses {
|
||||
plugin := fmt.Sprintf(`#!/bin/sh
|
||||
case "$1" in
|
||||
docker-cli-plugin-metadata)
|
||||
printf '%%s\n' '{"SchemaVersion":"0.1.0","Vendor":"test"}'
|
||||
;;
|
||||
*)
|
||||
test "$2" = docker-cli-plugin-hooks || exit 1
|
||||
printf 'called\n' >> "$0.calls"
|
||||
printf '%%s\n' '%s'
|
||||
;;
|
||||
esac
|
||||
`, response)
|
||||
assert.NilError(t, os.WriteFile(filepath.Join(pluginDir, "docker-"+name), []byte(plugin), 0o755))
|
||||
cfg.Plugins[name] = map[string]string{"hooks": "build"}
|
||||
}
|
||||
|
||||
root := &cobra.Command{Use: "docker"}
|
||||
sub := &cobra.Command{Use: "build"}
|
||||
root.AddCommand(sub)
|
||||
var output strings.Builder
|
||||
sub.SetErr(&output)
|
||||
|
||||
runHooks(context.Background(), cfg, root, sub, "build", nil, "")
|
||||
assert.Equal(t, output.String(), tc.expected)
|
||||
for name := range tc.responses {
|
||||
calls, err := os.ReadFile(filepath.Join(pluginDir, "docker-"+name+".calls"))
|
||||
assert.NilError(t, err)
|
||||
assert.Equal(t, string(calls), "called\n", "each plugin hook must run exactly once")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// FIXME(thaJeztah): remove once we are a module; the go:build directive prevents go from downgrading language version to go1.16:
|
||||
//go:build go1.26
|
||||
|
||||
package metadata
|
||||
|
||||
const (
|
||||
@@ -35,4 +38,7 @@ type Metadata struct {
|
||||
URL string `json:",omitempty"`
|
||||
// Hidden hides the plugin in completion and help message output.
|
||||
Hidden bool `json:",omitempty"`
|
||||
// Features declares optional contracts supported by the plugin, keyed by
|
||||
// feature name.
|
||||
Features map[string]any `json:",omitempty"`
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
|
||||
"github.com/docker/cli/cli/config"
|
||||
"github.com/docker/cli/cli/config/configfile"
|
||||
configtypes "github.com/docker/cli/cli/config/types"
|
||||
dcontext "github.com/docker/cli/cli/context"
|
||||
"github.com/docker/cli/cli/context/docker"
|
||||
"github.com/docker/cli/cli/context/store"
|
||||
@@ -23,6 +24,7 @@ import (
|
||||
cliflags "github.com/docker/cli/cli/flags"
|
||||
"github.com/docker/cli/cli/streams"
|
||||
"github.com/docker/cli/cli/version"
|
||||
"github.com/docker/cli/internal/secretsengine"
|
||||
dopts "github.com/docker/cli/opts"
|
||||
"github.com/moby/moby/api/types/build"
|
||||
"github.com/moby/moby/client"
|
||||
@@ -68,12 +70,15 @@ type DockerCli struct {
|
||||
serverInfo ServerInfo
|
||||
contextStore store.Store
|
||||
currentContext string
|
||||
contextResolver func(*DockerCli) (string, error)
|
||||
init sync.Once
|
||||
initErr error
|
||||
dockerEndpoint docker.Endpoint
|
||||
contextStoreConfig *store.Config
|
||||
initTimeout time.Duration
|
||||
res telemetryResource
|
||||
registryAuthOnce sync.Once
|
||||
registryAuth secretsengine.AuthProvider
|
||||
|
||||
// baseCtx is the base context used for internal operations. In the future
|
||||
// this may be replaced by explicitly passing a context to functions that
|
||||
@@ -140,6 +145,21 @@ func (cli *DockerCli) ConfigFile() *configfile.ConfigFile {
|
||||
return cli.configFile
|
||||
}
|
||||
|
||||
func (cli *DockerCli) RegistryAuthProvider() secretsengine.AuthProvider {
|
||||
cli.registryAuthOnce.Do(func() {
|
||||
cli.registryAuth = secretsengine.NewDesktop(configFileAuth{cli})
|
||||
})
|
||||
return cli.registryAuth
|
||||
}
|
||||
|
||||
type configFileAuth struct {
|
||||
cli *DockerCli
|
||||
}
|
||||
|
||||
func (c configFileAuth) GetAuthConfig(registryHostname string) (configtypes.AuthConfig, error) {
|
||||
return c.cli.ConfigFile().GetAuthConfig(registryHostname)
|
||||
}
|
||||
|
||||
// ServerInfo returns the server version details for the host this client is
|
||||
// connected to
|
||||
func (cli *DockerCli) ServerInfo() ServerInfo {
|
||||
@@ -208,6 +228,8 @@ func (cli *DockerCli) HooksEnabled() bool {
|
||||
|
||||
// Initialize the dockerCli runs initialization that must happen after command
|
||||
// line flags are parsed.
|
||||
//
|
||||
//nolint:gocyclo
|
||||
func (cli *DockerCli) Initialize(opts *cliflags.ClientOptions, ops ...CLIOption) error {
|
||||
for _, o := range ops {
|
||||
if err := o(cli); err != nil {
|
||||
@@ -257,6 +279,16 @@ func (cli *DockerCli) Initialize(opts *cliflags.ClientOptions, ops ...CLIOption)
|
||||
},
|
||||
}
|
||||
|
||||
if cli.contextResolver != nil {
|
||||
contextName, err := cli.contextResolver(cli)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if contextName != "" {
|
||||
cli.currentContext = contextName
|
||||
}
|
||||
}
|
||||
|
||||
// TODO(krissetto): pass ctx to the funcs instead of using this
|
||||
if cli.enableGlobalMeter {
|
||||
cli.createGlobalMeterProvider(cli.baseCtx)
|
||||
|
||||
@@ -84,6 +84,19 @@ func WithDefaultContextStoreConfig() CLIOption {
|
||||
}
|
||||
}
|
||||
|
||||
// WithContextResolver overrides context selection during [DockerCli.Initialize].
|
||||
// The resolver runs after configuration and the context store are loaded, but
|
||||
// before telemetry and endpoint initialization.
|
||||
// An empty result preserves normal context selection; an error aborts
|
||||
// initialization.
|
||||
// The resolver must not initialize the API client.
|
||||
func WithContextResolver(resolve func(*DockerCli) (string, error)) CLIOption {
|
||||
return func(cli *DockerCli) error {
|
||||
cli.contextResolver = resolve
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// WithAPIClient configures the cli to use the given API client.
|
||||
func WithAPIClient(c client.APIClient) CLIOption {
|
||||
return func(cli *DockerCli) error {
|
||||
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
|
||||
"github.com/docker/cli/cli/config"
|
||||
"github.com/docker/cli/cli/config/configfile"
|
||||
configtypes "github.com/docker/cli/cli/config/types"
|
||||
"github.com/docker/cli/cli/context/store"
|
||||
"github.com/docker/cli/cli/flags"
|
||||
"github.com/moby/moby/client"
|
||||
@@ -325,6 +326,64 @@ func TestInitializeShouldAlwaysCreateTheContextStore(t *testing.T) {
|
||||
assert.Check(t, cli.ContextStore() != nil)
|
||||
}
|
||||
|
||||
func TestInitializeContextResolver(t *testing.T) {
|
||||
resolverErr := errors.New("resolver failed")
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
context string
|
||||
err error
|
||||
}{
|
||||
{name: "resolved context", context: "resolved"},
|
||||
{name: "normal selection"},
|
||||
{name: "resolver failure", err: resolverErr},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
originalConfigDir := config.Dir()
|
||||
t.Cleanup(func() { config.SetDir(originalConfigDir) })
|
||||
config.SetDir(t.TempDir())
|
||||
|
||||
configDir := t.TempDir()
|
||||
cfg := configfile.New(filepath.Join(configDir, config.ConfigFileName))
|
||||
cfg.Features = map[string]string{"cloud": "foobar"}
|
||||
assert.NilError(t, cfg.Save())
|
||||
|
||||
cli, err := NewDockerCli()
|
||||
assert.NilError(t, err)
|
||||
|
||||
var loadedConfig *configfile.ConfigFile
|
||||
calls := 0
|
||||
|
||||
err = cli.Initialize(&flags.ClientOptions{ConfigDir: configDir, Context: "original"},
|
||||
WithContextResolver(func(cli *DockerCli) (string, error) {
|
||||
calls++
|
||||
assert.Equal(t, config.Dir(), configDir)
|
||||
loadedConfig = cli.ConfigFile()
|
||||
assert.Equal(t, loadedConfig.Features["cloud"], "foobar")
|
||||
assert.Assert(t, cli.ContextStore() != nil)
|
||||
assert.Assert(t, cli.client == nil)
|
||||
return tc.context, tc.err
|
||||
}),
|
||||
)
|
||||
assert.Equal(t, calls, 1)
|
||||
assert.Assert(t, cli.ConfigFile() == loadedConfig)
|
||||
assert.Assert(t, cli.client == nil)
|
||||
|
||||
if tc.err != nil {
|
||||
assert.ErrorIs(t, err, tc.err)
|
||||
return
|
||||
}
|
||||
assert.NilError(t, err)
|
||||
|
||||
wantContext := tc.context
|
||||
if wantContext == "" {
|
||||
wantContext = "original"
|
||||
}
|
||||
assert.Equal(t, cli.CurrentContext(), wantContext)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHooksEnabled(t *testing.T) {
|
||||
t.Run("disabled by default", func(t *testing.T) {
|
||||
// Make sure we don't depend on any existing ~/.docker/config.json
|
||||
@@ -443,3 +502,17 @@ func TestNewDockerCliWithAPIClientOptions(t *testing.T) {
|
||||
assert.NilError(t, err)
|
||||
assert.DeepEqual(t, received, "fake-agent/0.0.1")
|
||||
}
|
||||
|
||||
func TestRegistryAuthProvider(t *testing.T) {
|
||||
t.Parallel()
|
||||
cli := &DockerCli{configFile: &configfile.ConfigFile{}}
|
||||
p := cli.RegistryAuthProvider()
|
||||
assert.Assert(t, p == cli.RegistryAuthProvider(), "the provider must be created once per DockerCli")
|
||||
|
||||
cli.configFile = &configfile.ConfigFile{AuthConfigs: map[string]configtypes.AuthConfig{
|
||||
"ghcr.io": {Username: "user", Password: "pass"},
|
||||
}}
|
||||
got, err := p.GetAuthConfig("ghcr.io")
|
||||
assert.NilError(t, err)
|
||||
assert.DeepEqual(t, got, configtypes.AuthConfig{Username: "user", Password: "pass"})
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ import (
|
||||
"github.com/docker/cli/cli/config/types"
|
||||
"github.com/docker/cli/cli/streams"
|
||||
"github.com/docker/cli/internal/jsonstream"
|
||||
"github.com/docker/cli/internal/registryauth"
|
||||
"github.com/docker/cli/opts"
|
||||
"github.com/moby/moby/api/types/mount"
|
||||
"github.com/moby/moby/client"
|
||||
@@ -133,7 +134,7 @@ func runCreate(ctx context.Context, dockerCLI command.Cli, flags *pflag.FlagSet,
|
||||
}
|
||||
|
||||
func pullImage(ctx context.Context, dockerCLI command.Cli, img string, options *createOptions) error {
|
||||
encodedAuth, err := command.RetrieveAuthTokenFromImage(dockerCLI.ConfigFile(), img)
|
||||
encodedAuth, err := registryauth.EncodedAuth(dockerCLI, img)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -260,8 +260,11 @@ func runBuild(ctx context.Context, dockerCli command.Cli, options buildOptions)
|
||||
contextDir = tempDir
|
||||
case build.ContextTypeRemote:
|
||||
buildCtx, relDockerfile, err = build.GetContextFromURL(progBuff, options.context, options.dockerfileName)
|
||||
if err != nil && options.quiet {
|
||||
_, _ = fmt.Fprintln(dockerCli.Err(), progBuff)
|
||||
if err != nil {
|
||||
if options.quiet {
|
||||
_, _ = fmt.Fprintln(dockerCli.Err(), progBuff)
|
||||
}
|
||||
return err
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("unable to prepare context: path %q not found", options.context)
|
||||
|
||||
@@ -6,6 +6,8 @@ import (
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
@@ -172,6 +174,35 @@ RUN echo hello world
|
||||
assert.DeepEqual(t, fakeBuild.filenames(t), []string{"Dockerfile"})
|
||||
}
|
||||
|
||||
// TestRunBuildRemoteContextDownloadError verifies that remote context
|
||||
// download errors are propagated.
|
||||
//
|
||||
// regression test for https://github.com/docker/cli/issues/7372
|
||||
func TestRunBuildRemoteContextDownloadError(t *testing.T) {
|
||||
t.Setenv("DOCKER_BUILDKIT", "0")
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "not found", http.StatusNotFound)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
imageBuildCalled := false
|
||||
cli := test.NewFakeCli(&fakeClient{
|
||||
imageBuildFunc: func(ctx context.Context, buildContext io.Reader, options client.ImageBuildOptions) (client.ImageBuildResult, error) {
|
||||
imageBuildCalled = true
|
||||
return client.ImageBuildResult{Body: io.NopCloser(bytes.NewReader(nil))}, nil
|
||||
},
|
||||
})
|
||||
|
||||
options := newBuildOptions()
|
||||
options.context = server.URL
|
||||
|
||||
err := runBuild(context.Background(), cli, options)
|
||||
|
||||
assert.ErrorContains(t, err, "unable to download remote context")
|
||||
assert.Assert(t, !imageBuildCalled, "ImageBuild should not be called when downloading the remote context fails")
|
||||
}
|
||||
|
||||
type fakeBuild struct {
|
||||
context *tar.Reader
|
||||
options client.ImageBuildOptions
|
||||
|
||||
@@ -52,6 +52,14 @@ func TestNewHistoryCommandErrors(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestNewHistoryCommandSuccess(t *testing.T) {
|
||||
// Set to UTC timezone as timestamps in output are
|
||||
// printed in the current timezone
|
||||
local := time.Local
|
||||
time.Local = time.UTC
|
||||
t.Cleanup(func() {
|
||||
time.Local = local
|
||||
})
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
args []string
|
||||
@@ -118,9 +126,6 @@ func TestNewHistoryCommandSuccess(t *testing.T) {
|
||||
}
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
// Set to UTC timezone as timestamps in output are
|
||||
// printed in the current timezone
|
||||
t.Setenv("TZ", "UTC")
|
||||
cli := test.NewFakeCli(&fakeClient{imageHistoryFunc: tc.imageHistoryFunc})
|
||||
cmd := newHistoryCommand(cli)
|
||||
cmd.SetOut(io.Discard)
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"github.com/docker/cli/cli/command/completion"
|
||||
"github.com/docker/cli/cli/streams"
|
||||
"github.com/docker/cli/internal/jsonstream"
|
||||
"github.com/docker/cli/internal/registryauth"
|
||||
"github.com/moby/moby/client"
|
||||
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
|
||||
"github.com/spf13/cobra"
|
||||
@@ -90,7 +91,7 @@ func runPull(ctx context.Context, dockerCLI command.Cli, opts pullOptions) error
|
||||
ociPlatforms = append(ociPlatforms, p)
|
||||
}
|
||||
|
||||
encodedAuth, err := command.RetrieveAuthTokenFromImage(dockerCLI.ConfigFile(), distributionRef.String())
|
||||
encodedAuth, err := registryauth.EncodedAuth(dockerCLI, distributionRef.String())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"github.com/docker/cli/cli/command/completion"
|
||||
"github.com/docker/cli/cli/streams"
|
||||
"github.com/docker/cli/internal/jsonstream"
|
||||
"github.com/docker/cli/internal/registryauth"
|
||||
"github.com/docker/cli/internal/tui"
|
||||
"github.com/moby/moby/api/types/auxprogress"
|
||||
"github.com/moby/moby/client"
|
||||
@@ -108,7 +109,7 @@ To push the complete multi-platform image, remove the --platform flag.
|
||||
}
|
||||
|
||||
// Resolve the Auth config relevant for this server
|
||||
encodedAuth, err := command.RetrieveAuthTokenFromImage(dockerCli.ConfigFile(), ref.String())
|
||||
encodedAuth, err := registryauth.EncodedAuth(dockerCli, ref.String())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"github.com/docker/cli/cli/command"
|
||||
"github.com/docker/cli/internal/jsonstream"
|
||||
"github.com/docker/cli/internal/prompt"
|
||||
"github.com/docker/cli/internal/registryauth"
|
||||
"github.com/moby/moby/api/types/plugin"
|
||||
"github.com/moby/moby/client"
|
||||
"github.com/spf13/cobra"
|
||||
@@ -60,7 +61,7 @@ func buildPullConfig(dockerCLI command.Cli, opts pluginOptions) (client.PluginIn
|
||||
return client.PluginInstallOptions{}, err
|
||||
}
|
||||
|
||||
encodedAuth, err := command.RetrieveAuthTokenFromImage(dockerCLI.ConfigFile(), ref.String())
|
||||
encodedAuth, err := registryauth.EncodedAuth(dockerCLI, ref.String())
|
||||
if err != nil {
|
||||
return client.PluginInstallOptions{}, err
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"github.com/docker/cli/cli"
|
||||
"github.com/docker/cli/cli/command"
|
||||
"github.com/docker/cli/internal/jsonstream"
|
||||
"github.com/docker/cli/internal/registryauth"
|
||||
"github.com/moby/moby/client"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
@@ -42,7 +43,7 @@ func runPush(ctx context.Context, dockerCli command.Cli, name string) error {
|
||||
}
|
||||
|
||||
named = reference.TagNameOnly(named)
|
||||
encodedAuth, err := command.RetrieveAuthTokenFromImage(dockerCli.ConfigFile(), named.String())
|
||||
encodedAuth, err := registryauth.EncodedAuth(dockerCli, named.String())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
"github.com/docker/cli/internal/tui"
|
||||
registrytypes "github.com/moby/moby/api/types/registry"
|
||||
"github.com/moby/moby/client"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/pflag"
|
||||
)
|
||||
@@ -226,7 +227,7 @@ func loginWithStoredCredentials(ctx context.Context, dockerCLI command.Cli, auth
|
||||
return resp.Auth.Status, err
|
||||
}
|
||||
|
||||
func loginUser(ctx context.Context, dockerCLI command.Cli, opts loginOptions, defaultUsername, serverAddress string) (msg string, _ error) {
|
||||
func loginUser(ctx context.Context, dockerCLI command.Cli, opts loginOptions, defaultUsername, serverAddress string) (message string, _ error) {
|
||||
// Some links documenting this:
|
||||
// - https://code.google.com/archive/p/mintty/issues/56
|
||||
// - https://github.com/docker/docker/issues/15272
|
||||
@@ -240,13 +241,12 @@ func loginUser(ctx context.Context, dockerCLI command.Cli, opts loginOptions, de
|
||||
|
||||
// If we're logging into the index server and the user didn't provide a username or password, use the device flow
|
||||
if serverAddress == registry.IndexServer && opts.user == "" && opts.password == "" {
|
||||
var err error
|
||||
msg, err = loginWithDeviceCodeFlow(ctx, dockerCLI)
|
||||
// if the error represents a failure to initiate the device-code flow,
|
||||
// then we fallback to regular cli credentials login
|
||||
if !errors.Is(err, manager.ErrDeviceLoginStartFail) {
|
||||
msg, err := loginWithDeviceCodeFlow(ctx, dockerCLI)
|
||||
if err == nil || !errors.Is(err, manager.ErrDeviceLoginStartFail) {
|
||||
return msg, err
|
||||
}
|
||||
|
||||
logrus.WithError(err).Debug("failed to start web-based login")
|
||||
_, _ = fmt.Fprint(dockerCLI.Err(), "Failed to start web-based login - falling back to command line login...\n\n")
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"github.com/docker/cli/cli"
|
||||
"github.com/docker/cli/cli/command"
|
||||
"github.com/docker/cli/cli/command/completion"
|
||||
"github.com/docker/cli/internal/registryauth"
|
||||
cliopts "github.com/docker/cli/opts"
|
||||
"github.com/moby/moby/api/types/swarm"
|
||||
"github.com/moby/moby/client"
|
||||
@@ -120,7 +121,7 @@ func runCreate(ctx context.Context, dockerCLI command.Cli, flags *pflag.FlagSet,
|
||||
if opts.registryAuth {
|
||||
// Retrieve encoded auth token from the image reference
|
||||
var err error
|
||||
encodedAuth, err = command.RetrieveAuthTokenFromImage(dockerCLI.ConfigFile(), opts.image)
|
||||
encodedAuth, err = registryauth.EncodedAuth(dockerCLI, opts.image)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"github.com/docker/cli/cli"
|
||||
"github.com/docker/cli/cli/command"
|
||||
"github.com/docker/cli/cli/command/completion"
|
||||
"github.com/docker/cli/internal/registryauth"
|
||||
"github.com/docker/cli/opts"
|
||||
"github.com/docker/cli/opts/swarmopts"
|
||||
"github.com/moby/moby/api/types/container"
|
||||
@@ -223,7 +224,7 @@ func runUpdate(ctx context.Context, dockerCLI command.Cli, flags *pflag.FlagSet,
|
||||
// Retrieve encoded auth token from the image reference
|
||||
// This would be the old image if it didn't change in this update
|
||||
var err error
|
||||
encodedAuth, err = command.RetrieveAuthTokenFromImage(dockerCLI.ConfigFile(), spec.TaskTemplate.ContainerSpec.Image)
|
||||
encodedAuth, err = registryauth.EncodedAuth(dockerCLI, spec.TaskTemplate.ContainerSpec.Image)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"github.com/docker/cli/cli/command/service"
|
||||
"github.com/docker/cli/cli/compose/convert"
|
||||
composetypes "github.com/docker/cli/cli/compose/types"
|
||||
"github.com/docker/cli/internal/registryauth"
|
||||
"github.com/moby/moby/api/types/container"
|
||||
"github.com/moby/moby/api/types/network"
|
||||
"github.com/moby/moby/api/types/swarm"
|
||||
@@ -224,7 +225,7 @@ func deployServices(ctx context.Context, dockerCLI command.Cli, services map[str
|
||||
|
||||
if sendAuth {
|
||||
// Retrieve encoded auth token from the image reference
|
||||
encodedAuth, err = command.RetrieveAuthTokenFromImage(dockerCLI.ConfigFile(), image)
|
||||
encodedAuth, err = registryauth.EncodedAuth(dockerCLI, image)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
+10
-10
@@ -5,13 +5,13 @@ go 1.26.0
|
||||
require (
|
||||
github.com/containerd/errdefs v1.0.0
|
||||
github.com/distribution/reference v0.6.0
|
||||
github.com/docker/cli v29.7.2+incompatible
|
||||
github.com/docker/cli v29.8.2+incompatible
|
||||
github.com/docker/cli-docs-tool v0.11.0
|
||||
github.com/docker/distribution v2.8.3+incompatible
|
||||
github.com/docker/go-connections v0.8.1
|
||||
github.com/docker/go-connections v0.8.2
|
||||
github.com/fvbommel/sortorder v1.2.0
|
||||
github.com/moby/moby/api v1.55.0
|
||||
github.com/moby/moby/client v0.5.1
|
||||
github.com/moby/moby/api v1.56.1
|
||||
github.com/moby/moby/client v0.6.2
|
||||
github.com/opencontainers/go-digest v1.0.0
|
||||
github.com/sirupsen/logrus v1.10.2
|
||||
github.com/spf13/cobra v1.10.2
|
||||
@@ -22,7 +22,7 @@ require (
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect
|
||||
github.com/Azure/go-ansiterm v0.0.0-20261006220739-8c912ac31dd4 // indirect
|
||||
github.com/Microsoft/go-winio v0.6.2 // indirect
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
|
||||
@@ -66,11 +66,11 @@ require (
|
||||
go.opentelemetry.io/otel/sdk/metric v1.46.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.46.0 // indirect
|
||||
go.opentelemetry.io/proto/otlp v1.11.0 // indirect
|
||||
golang.org/x/crypto v0.56.0 // indirect
|
||||
golang.org/x/net v0.58.0 // indirect
|
||||
golang.org/x/sys v0.47.0 // indirect
|
||||
golang.org/x/term v0.45.0 // indirect
|
||||
golang.org/x/text v0.41.0 // indirect
|
||||
golang.org/x/crypto v0.57.0 // indirect
|
||||
golang.org/x/net v0.60.0 // indirect
|
||||
golang.org/x/sys v0.48.0 // indirect
|
||||
golang.org/x/term v0.46.0 // indirect
|
||||
golang.org/x/text v0.42.0 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260825221802-da73d73af1c5 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260825221802-da73d73af1c5 // indirect
|
||||
google.golang.org/grpc v1.83.2 // indirect
|
||||
|
||||
+20
-21
@@ -1,5 +1,5 @@
|
||||
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg=
|
||||
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
|
||||
github.com/Azure/go-ansiterm v0.0.0-20261006220739-8c912ac31dd4 h1:m2xcjRABtG288vnlV3U/PKh/HnPURxZp50RKNUwBxM8=
|
||||
github.com/Azure/go-ansiterm v0.0.0-20261006220739-8c912ac31dd4/go.mod h1:MTUE7ZuZO89FAtAwozGg6AWD83pcdefnEq5gF82MB8s=
|
||||
github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ=
|
||||
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||
@@ -42,8 +42,8 @@ github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs
|
||||
github.com/denisenkom/go-mssqldb v0.0.0-20191128021309-1d7a30a10f73/go.mod h1:xbL0rPBG9cCiLr28tMa8zpbdarY27NDyej4t/EjAShU=
|
||||
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
|
||||
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
|
||||
github.com/docker/cli v29.7.2+incompatible h1:dlkwallR8XqfeVnA2ELEhdwvb4lsSwuB4IgsG8Q9cLY=
|
||||
github.com/docker/cli v29.7.2+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
|
||||
github.com/docker/cli v29.8.2+incompatible h1:2zgdFuoFst2T80oS42vhKGxkd0JRo305eIEKTsxR7pQ=
|
||||
github.com/docker/cli v29.8.2+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
|
||||
github.com/docker/cli-docs-tool v0.11.0 h1:7d8QARFb7QEobizqxmEM7fOteZEHwH/zWgHQtHZEcfE=
|
||||
github.com/docker/cli-docs-tool v0.11.0/go.mod h1:ma8BKiisUo8D6W05XEYIh3oa1UbgrZhi1nowyKFJa8Q=
|
||||
github.com/docker/distribution v2.7.1+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w=
|
||||
@@ -54,8 +54,8 @@ github.com/docker/docker-credential-helpers v0.9.9/go.mod h1:v1S+hepowrQXITkEfw6
|
||||
github.com/docker/go v1.5.1-1.0.20160303222718-d30aec9fd63c h1:lzqkGL9b3znc+ZUgi7FlLnqjQhcXxkNM/quxIjBVMD0=
|
||||
github.com/docker/go v1.5.1-1.0.20160303222718-d30aec9fd63c/go.mod h1:CADgU4DSXK5QUlFslkQu2yW2TKzFZcXq/leZfM0UH5Q=
|
||||
github.com/docker/go-connections v0.4.0/go.mod h1:Gbd7IOopHjR8Iph03tsViu4nIes5XhDvyHbTtUxmeec=
|
||||
github.com/docker/go-connections v0.8.1 h1:JibmG5hULs5qXSr/cp/w3Pw5fZuStt4MOHMUExb29/M=
|
||||
github.com/docker/go-connections v0.8.1/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q=
|
||||
github.com/docker/go-connections v0.8.2 h1:9d9DEokx5W7qlr0shkFXIxEA7+TTD+llwzcykSlxMNc=
|
||||
github.com/docker/go-connections v0.8.2/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q=
|
||||
github.com/docker/go-metrics v0.0.0-20180209012529-399ea8c73916/go.mod h1:/u0gXw0Gay3ceNrsHubL3BtdOL2fHf93USgMTe0W5dI=
|
||||
github.com/docker/go-metrics v0.1.0 h1:r76KPNpstz+IvQKSWpYegSkkyzex0V3A1ZGVx6bhGlY=
|
||||
github.com/docker/go-metrics v0.1.0/go.mod h1:PciI3sONtB051kXALN1JoIlpcu54E1FuPh+4DuqEzyw=
|
||||
@@ -133,10 +133,10 @@ github.com/mitchellh/mapstructure v1.0.0 h1:vVpGvMXJPqSDh2VYHF7gsfQj8Ncx+Xw5Y1KH
|
||||
github.com/mitchellh/mapstructure v1.0.0/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
|
||||
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
|
||||
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
|
||||
github.com/moby/moby/api v1.55.0 h1:2/sexvQyqIWS8pRSCFddBfpW2qE7vR7FCL+vN8pxwMc=
|
||||
github.com/moby/moby/api v1.55.0/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs=
|
||||
github.com/moby/moby/client v0.5.1 h1:tYNaJno4c0HXz12y5BiqEDy0rVTYkWzI26lGvnTMiJw=
|
||||
github.com/moby/moby/client v0.5.1/go.mod h1:odLstlZ6uSnfvAgVxMpvgmb8SUdd+siH2T0GBuxVAlM=
|
||||
github.com/moby/moby/api v1.56.1 h1:PpWkvVPB7Fr/No8w+TfyJ/I6rWZ2YPZhT3JorIg+06c=
|
||||
github.com/moby/moby/api v1.56.1/go.mod h1:sZ+THbVWkjOmBPPfbnzdD/G1LuIexWhqlSHHPTDQ1Uk=
|
||||
github.com/moby/moby/client v0.6.2 h1:ekSPlMccrZGcw6Scb8T9D39qE0mB+XupU/yS0zNw9N4=
|
||||
github.com/moby/moby/client v0.6.2/go.mod h1:DGcnO7qmPJrqobx/ggQgQ/AUk+2wgVdQv7uxB0/mEsc=
|
||||
github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw=
|
||||
github.com/moby/sys/atomicwriter v0.1.0/go.mod h1:Ul8oqv2ZMNHOceF643P6FKPXeCmYtlQMvpizfsSoaWs=
|
||||
github.com/moby/sys/sequential v0.7.0 h1:ASQNGNROJSuOO6LL6bPHbKvuZu6NU8P4ldPWk31zj/8=
|
||||
@@ -236,27 +236,26 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk
|
||||
golang.org/x/crypto v0.0.0-20190325154230-a5d413f7728c/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20200302210943-78000ba7a073/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20201117144127-c1f2f97bffc9/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I=
|
||||
golang.org/x/crypto v0.56.0 h1:GUh5Ii4J5jtcseSMiRqr1jXCNHoxjeV9Fmekc2oLy6Y=
|
||||
golang.org/x/crypto v0.56.0/go.mod h1:OMW5y6CY9l38uPLmxU6l6pwcXp1obtLo3e6gT7gQR2I=
|
||||
golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
|
||||
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
|
||||
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
|
||||
golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
|
||||
golang.org/x/net v0.60.0 h1:79p50tfZlm0J9YfoDsSi639qSXNGVwEzOPLCxM2FsYU=
|
||||
golang.org/x/net v0.60.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
|
||||
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
||||
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
|
||||
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
|
||||
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
|
||||
golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE=
|
||||
golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
|
||||
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
|
||||
golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
|
||||
golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
|
||||
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
|
||||
|
||||
@@ -11,8 +11,9 @@ import (
|
||||
|
||||
"github.com/distribution/reference"
|
||||
"github.com/docker/cli/cli/streams"
|
||||
"github.com/docker/cli/internal/jsonstream"
|
||||
"github.com/moby/moby/api/types/jsonstream"
|
||||
registrytypes "github.com/moby/moby/api/types/registry"
|
||||
"github.com/moby/moby/client/pkg/jsonmessage"
|
||||
"github.com/opencontainers/go-digest"
|
||||
"github.com/theupdateframework/notary/client"
|
||||
"github.com/theupdateframework/notary/tuf/data"
|
||||
@@ -46,7 +47,7 @@ func PushTrustedReference(ctx context.Context, ioStreams Streams, repoInfo *Repo
|
||||
// Count the times of calling for handleTarget,
|
||||
// if it is called more that once, that should be considered an error in a trusted push.
|
||||
cnt := 0
|
||||
handleTarget := func(msg jsonstream.JSONMessage) {
|
||||
handleTarget := func(msg jsonstream.Message) {
|
||||
cnt++
|
||||
if cnt > 1 {
|
||||
// handleTarget should only be called once. This will be treated as an error.
|
||||
@@ -78,14 +79,14 @@ func PushTrustedReference(ctx context.Context, ioStreams Streams, repoInfo *Repo
|
||||
default:
|
||||
// We want trust signatures to always take an explicit tag,
|
||||
// otherwise it will act as an untrusted push.
|
||||
if err := jsonstream.Display(ctx, in, ioStreams.Out()); err != nil {
|
||||
if err := displayJSONMessages(ctx, in, ioStreams.Out(), nil); err != nil {
|
||||
return err
|
||||
}
|
||||
_, _ = fmt.Fprintln(ioStreams.Err(), "No tag specified, skipping trust metadata push")
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := jsonstream.Display(ctx, in, ioStreams.Out(), jsonstream.WithAuxCallback(handleTarget)); err != nil {
|
||||
if err := displayJSONMessages(ctx, in, ioStreams.Out(), handleTarget); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -147,3 +148,34 @@ func PushTrustedReference(ctx context.Context, ioStreams Streams, repoInfo *Repo
|
||||
_, _ = fmt.Fprintf(ioStreams.Out(), "Successfully signed %s:%s\n", repoInfo.Name.Name(), tag)
|
||||
return nil
|
||||
}
|
||||
|
||||
type ctxReader struct {
|
||||
err chan error
|
||||
r io.Reader
|
||||
}
|
||||
|
||||
func (r *ctxReader) Read(p []byte) (int, error) {
|
||||
select {
|
||||
case err := <-r.err:
|
||||
return 0, err
|
||||
default:
|
||||
return r.r.Read(p)
|
||||
}
|
||||
}
|
||||
|
||||
// displayJSONMessages prints the JSON messages from in to out. It returns
|
||||
// ctx.Err() if ctx is canceled, but not when in is closed.
|
||||
func displayJSONMessages(ctx context.Context, in io.Reader, out *streams.Out, auxCallback func(jsonstream.Message)) error {
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
|
||||
reader := &ctxReader{err: make(chan error, 1), r: in}
|
||||
stop := context.AfterFunc(ctx, func() { reader.err <- ctx.Err() })
|
||||
defer stop()
|
||||
|
||||
if err := jsonmessage.DisplayJSONMessagesStream(reader, out, out.FD(), out.IsTerminal(), auxCallback); err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Err()
|
||||
}
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
package trust
|
||||
|
||||
import (
|
||||
"github.com/docker/cli/cli"
|
||||
"github.com/docker/cli/cli/command"
|
||||
"github.com/docker/cli/internal/commands"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func init() {
|
||||
commands.Register(newTrustCommand)
|
||||
}
|
||||
|
||||
// newTrustCommand returns a cobra command for `trust` subcommands.
|
||||
func newTrustCommand(dockerCLI command.Cli) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "trust",
|
||||
Short: "Manage trust on Docker images",
|
||||
Args: cli.NoArgs,
|
||||
RunE: command.ShowHelp(dockerCLI.Err()),
|
||||
|
||||
DisableFlagsInUseLine: true,
|
||||
}
|
||||
cmd.AddCommand(
|
||||
newRevokeCommand(dockerCLI),
|
||||
newSignCommand(dockerCLI),
|
||||
newTrustKeyCommand(dockerCLI),
|
||||
newTrustSignerCommand(dockerCLI),
|
||||
newInspectCommand(dockerCLI),
|
||||
)
|
||||
return cmd
|
||||
}
|
||||
@@ -5,12 +5,12 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/docker/cli/cli"
|
||||
"github.com/docker/cli/cli/command"
|
||||
"github.com/docker/cli/cmd/docker-trust/internal/trust"
|
||||
"github.com/docker/cli/internal/lazyregexp"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/theupdateframework/notary"
|
||||
"github.com/theupdateframework/notary/trustmanager"
|
||||
@@ -41,7 +41,7 @@ func newKeyGenerateCommand(dockerCLI command.Streams) *cobra.Command {
|
||||
}
|
||||
|
||||
// key names can use lowercase alphanumeric + _ + - characters
|
||||
var validKeyName = lazyregexp.New(`^[a-z0-9][a-z0-9\_\-]*$`).MatchString
|
||||
var validKeyName = regexp.MustCompile(`^[a-z0-9][a-z0-9\_\-]*$`).MatchString
|
||||
|
||||
// validate that all of the key names are unique and are alphanumeric + _ + -
|
||||
// and that we do not already have public key files in the target dir on disk
|
||||
|
||||
@@ -7,12 +7,12 @@ import (
|
||||
"io"
|
||||
"os"
|
||||
"path"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/docker/cli/cli"
|
||||
"github.com/docker/cli/cli/command"
|
||||
"github.com/docker/cli/cmd/docker-trust/internal/trust"
|
||||
"github.com/docker/cli/internal/lazyregexp"
|
||||
"github.com/docker/cli/opts"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/theupdateframework/notary/client"
|
||||
@@ -45,7 +45,7 @@ func newSignerAddCommand(dockerCLI command.Cli) *cobra.Command {
|
||||
return cmd
|
||||
}
|
||||
|
||||
var validSignerName = lazyregexp.New(`^[a-z0-9][a-z0-9\_\-]*$`).MatchString
|
||||
var validSignerName = regexp.MustCompile(`^[a-z0-9][a-z0-9\_\-]*$`).MatchString
|
||||
|
||||
func addSigner(ctx context.Context, dockerCLI command.Cli, options signerAddOptions) error {
|
||||
signerName := options.signer
|
||||
|
||||
@@ -0,0 +1,322 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/containerd/errdefs"
|
||||
"github.com/docker/cli/cli"
|
||||
pluginmanager "github.com/docker/cli/cli-plugins/manager"
|
||||
"github.com/docker/cli/cli-plugins/metadata"
|
||||
"github.com/docker/cli/cli/command"
|
||||
"github.com/docker/cli/cli/config"
|
||||
contextdocker "github.com/docker/cli/cli/context/docker"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/pflag"
|
||||
)
|
||||
|
||||
// processCloud resolves the global --cloud[=NAME] option after configuration is
|
||||
// loaded, but before telemetry and endpoint initialization.
|
||||
// The option appears in help and completion only when the selected provider is
|
||||
// installed, valid, and declares support for the resolver contract.
|
||||
// The provider defaults to the offload plugin; features.cloud in the effective
|
||||
// Docker config.json can override the plugin name, not its executable path.
|
||||
// Explicit use also requires the provider's capability declaration.
|
||||
// Discovery for visibility does not provision a context or connect to a daemon.
|
||||
//
|
||||
// # Provider contract
|
||||
//
|
||||
// The provider must include "Features": {"cloud-context-resolver": true} in its
|
||||
// docker-cli-plugin-metadata response to declare support for this contract.
|
||||
// Older plugins without this declaration leave the flag hidden and cannot be
|
||||
// used for resolution.
|
||||
// The CLI uses normal plugin discovery and invokes:
|
||||
//
|
||||
// docker-<provider> --config=<dir> <provider> __resolve-context -- <name>
|
||||
//
|
||||
// Bare --cloud and --cloud= pass default as the target name.
|
||||
// The effective config directory is also passed as DOCKER_CONFIG.
|
||||
// The provider must provision into that context store without changing the saved
|
||||
// current context or recursively forwarding --cloud.
|
||||
// It inherits the environment and sends progress and prompts to stderr. Stdin
|
||||
// is forwarded only when both stdin and stderr are terminals with file handles;
|
||||
// wrapped Windows consoles use the corresponding standard handles. Otherwise
|
||||
// the provider receives EOF and must not prompt or open a terminal
|
||||
// separately. Piped and redirected input belongs to the requested command.
|
||||
// Stdout must contain exactly one JSON object:
|
||||
//
|
||||
// {"DOCKER_CONTEXT":"provisioned-context"}
|
||||
//
|
||||
// The returned context must exist, must not be the virtual default context, and
|
||||
// must have a Docker endpoint with a nonempty host.
|
||||
// Resolver failures abort command execution without falling back to another
|
||||
// context.
|
||||
// Cancellation terminates the resolver process; provisioning subprocesses and
|
||||
// resource cleanup remain the provider's responsibility.
|
||||
// Downstream plugins receive --context=<resolved-name> instead of the global
|
||||
// --cloud option.
|
||||
// The provider implementation ships separately and must support this operation
|
||||
// before enabling the integration.
|
||||
func processCloud(ctx context.Context, dockerCli *command.DockerCli, cmd *cobra.Command, args, osArgs []string) ([]string, error) {
|
||||
if !cmd.Flags().Changed("cloud") {
|
||||
return osArgs, nil
|
||||
}
|
||||
if cmd.Flags().Changed("context") || cmd.Flags().Changed("host") {
|
||||
return osArgs, errors.New("conflicting options: cannot specify --cloud together with --context or --host")
|
||||
}
|
||||
|
||||
help, err := cloudHelpRequest(cmd, args)
|
||||
if err != nil {
|
||||
return osArgs, err
|
||||
}
|
||||
|
||||
var contextName string
|
||||
if !help {
|
||||
name, _ := cmd.Flags().GetString("cloud")
|
||||
if name == "" {
|
||||
name = "default"
|
||||
}
|
||||
|
||||
contextName, err = resolveCloudContext(ctx, dockerCli, cmd, name)
|
||||
if err != nil {
|
||||
return osArgs, fmt.Errorf("--cloud: %w", err)
|
||||
}
|
||||
if err := cmd.Flags().Set("context", contextName); err != nil {
|
||||
return osArgs, err
|
||||
}
|
||||
}
|
||||
|
||||
return cloudPluginArgs(cmd, osArgs, contextName)
|
||||
}
|
||||
|
||||
// cloudResolverFeature is the plugin metadata feature that declares support for
|
||||
// the __resolve-context contract.
|
||||
const cloudResolverFeature = "cloud-context-resolver"
|
||||
|
||||
const cloudPluginHelp = "to use --cloud, install the plugin. See https://docs.docker.com/go/cloud-flag/"
|
||||
|
||||
func cloudProvider(dockerCli config.Provider, rootCmd *cobra.Command) (*pluginmanager.Plugin, error) {
|
||||
provider := dockerCli.ConfigFile().Features["cloud"]
|
||||
if provider == "" {
|
||||
provider = "offload"
|
||||
}
|
||||
var help string
|
||||
if provider == "offload" {
|
||||
help = "\n\n" + cloudPluginHelp
|
||||
}
|
||||
|
||||
plugin, err := pluginmanager.GetPlugin(provider, dockerCli, rootCmd)
|
||||
if err != nil {
|
||||
if provider == "offload" && errdefs.IsNotFound(err) {
|
||||
return nil, errors.New(cloudPluginHelp)
|
||||
}
|
||||
return nil, fmt.Errorf("plugin %q unavailable: %w%s", provider, err, help)
|
||||
}
|
||||
if plugin.Err != nil {
|
||||
return nil, fmt.Errorf("invalid plugin %q: %w%s", provider, plugin.Err, help)
|
||||
}
|
||||
|
||||
if supported, _ := plugin.Features[cloudResolverFeature].(bool); !supported {
|
||||
return nil, fmt.Errorf("plugin %q does not support --cloud%s", provider, help)
|
||||
}
|
||||
|
||||
return plugin, nil
|
||||
}
|
||||
|
||||
// updateCloudFlagVisibility runs only for help and completion, avoiding plugin
|
||||
// discovery on ordinary invocations that do not use --cloud.
|
||||
func updateCloudFlagVisibility(dockerCli config.Provider, rootCmd *cobra.Command) {
|
||||
flag := rootCmd.Flags().Lookup("cloud")
|
||||
if flag == nil {
|
||||
return
|
||||
}
|
||||
_, err := cloudProvider(dockerCli, rootCmd)
|
||||
flag.Hidden = err != nil
|
||||
}
|
||||
|
||||
func resolveCloudContext(ctx context.Context, dockerCli *command.DockerCli, rootCmd *cobra.Command, name string) (string, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
plugin, err := cloudProvider(dockerCli, rootCmd)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
cmd := exec.CommandContext(ctx, plugin.Path, "--config="+config.Dir(), plugin.Name, "__resolve-context", "--", name) // #nosec G204 -- executable validated through CLI plugin discovery
|
||||
cmd.Env = append(os.Environ(), config.EnvOverrideConfigDir+"="+config.Dir(), metadata.ReexecEnvvar+"="+os.Args[0])
|
||||
setResolverStdio(cmd, dockerCli)
|
||||
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return "", ctx.Err()
|
||||
}
|
||||
return "", fmt.Errorf("cloud resolver failed: %w", err)
|
||||
}
|
||||
|
||||
var response struct {
|
||||
DockerContext string `json:"DOCKER_CONTEXT"`
|
||||
}
|
||||
if err := json.Unmarshal(out, &response); err != nil {
|
||||
return "", fmt.Errorf("invalid cloud resolver response: %w", err)
|
||||
}
|
||||
response.DockerContext = strings.TrimSpace(response.DockerContext)
|
||||
if response.DockerContext == "" || response.DockerContext == command.DefaultContextName {
|
||||
return "", errors.New("cloud resolver must return a non-default DOCKER_CONTEXT")
|
||||
}
|
||||
|
||||
if err := validateResolvedContext(dockerCli, response.DockerContext); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return response.DockerContext, nil
|
||||
}
|
||||
|
||||
// setResolverStdio connects the resolver to the terminal when both stdin and
|
||||
// stderr are terminals, so the plugin can prompt interactively. Otherwise
|
||||
// stderr is forwarded and stdin is left unset.
|
||||
func setResolverStdio(cmd *exec.Cmd, dockerCli *command.DockerCli) {
|
||||
cmd.Stderr = dockerCli.Err()
|
||||
stdinTerminal := dockerCli.In().IsTerminal()
|
||||
stderrTerminal := dockerCli.Err().IsTerminal()
|
||||
stdin, stdinFile := dockerCli.In().File()
|
||||
stderr, stderrFile := dockerCli.Err().File()
|
||||
if runtime.GOOS == "windows" {
|
||||
// term.StdStreams can wrap console handles for terminal emulation,
|
||||
// preventing File from exposing them to the child process.
|
||||
if stdinTerminal && !stdinFile {
|
||||
stdin, stdinFile = os.Stdin, true
|
||||
}
|
||||
if stderrTerminal && !stderrFile {
|
||||
stderr, stderrFile = os.Stderr, true
|
||||
}
|
||||
}
|
||||
if stdinTerminal && stderrTerminal && stdinFile && stderrFile {
|
||||
// Pass files directly: wrapping them makes os/exec copy through pipes,
|
||||
// hiding terminal identity and potentially consuming the command's input.
|
||||
cmd.Stdin = stdin
|
||||
cmd.Stderr = stderr
|
||||
}
|
||||
}
|
||||
|
||||
// validateResolvedContext checks that the context exists and has a Docker
|
||||
// endpoint host, so a missing context or endpoint cannot fall back to the
|
||||
// local engine.
|
||||
func validateResolvedContext(dockerCli *command.DockerCli, name string) error {
|
||||
meta, err := dockerCli.ContextStore().GetMetadata(name)
|
||||
if err != nil {
|
||||
return fmt.Errorf("loading resolved context %q: %w", name, err)
|
||||
}
|
||||
|
||||
endpoint, err := contextdocker.EndpointFromContext(meta)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid resolved context %q: %w", name, err)
|
||||
}
|
||||
if endpoint.Host == "" {
|
||||
return fmt.Errorf("resolved context %q has no Docker endpoint host", name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// cloudHelpRequest avoids provisioning for help and shell completion.
|
||||
// Parse known command flags rather than scanning argv: --help may be an option
|
||||
// value or an argument to a container, not a request for Docker help.
|
||||
// Unknown flags and missing flag values return a usage error, so a mistyped
|
||||
// invocation fails without provisioning or falling back to the local engine.
|
||||
// Flag values are validated only by the command's own parse, after resolution.
|
||||
func cloudHelpRequest(rootCmd *cobra.Command, args []string) (bool, error) {
|
||||
help, _ := rootCmd.PersistentFlags().GetBool("help")
|
||||
version, _ := rootCmd.Flags().GetBool("version")
|
||||
if help || version || len(args) == 0 {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
switch args[0] {
|
||||
case "help", "completion", cobra.ShellCompRequestCmd, cobra.ShellCompNoDescRequestCmd:
|
||||
return true, nil
|
||||
}
|
||||
|
||||
cmd, remaining, err := rootCmd.Find(args)
|
||||
if err != nil || cmd == rootCmd || pluginmanager.IsPluginCommand(cmd) {
|
||||
// Plugin flags are opaque. Only recognize help immediately after the
|
||||
// plugin name; deeper help is available through "docker help PLUGIN".
|
||||
return len(args) > 1 && isHelpFlag(args[1]), nil
|
||||
}
|
||||
|
||||
// "build", "bake", "builder", and "image build" are builtin commands that
|
||||
// processAliases may still rewrite into a call to the builder plugin
|
||||
// (buildx) after cloud resolution runs, so their flag set here is only the
|
||||
// legacy builder's and does not reflect what will actually parse the
|
||||
// remaining args. Treat them like a plugin command above: only recognize
|
||||
// help immediately after the resolved command path.
|
||||
if _, _, _, forwarded := forwardBuilder(builderDefaultPlugin, args, args); forwarded {
|
||||
return len(remaining) > 0 && isHelpFlag(remaining[0]), nil
|
||||
}
|
||||
|
||||
cmd.InitDefaultHelpFlag()
|
||||
flags := cmd.Flags()
|
||||
flags.AddFlagSet(cmd.PersistentFlags())
|
||||
flags.AddFlagSet(cmd.InheritedFlags())
|
||||
|
||||
err = flags.ParseAll(remaining, func(flag *pflag.Flag, value string) error {
|
||||
if flag.Name == "help" {
|
||||
var err error
|
||||
if help, err = strconv.ParseBool(value); err != nil {
|
||||
return fmt.Errorf("invalid argument %q for \"--help\" flag: %w", value, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
// Format the usage error directly. The root command's FlagErrorFunc
|
||||
// first checks whether the daemon supports the command, which would
|
||||
// connect to the engine selected before --cloud is resolved.
|
||||
return false, cli.FlagErrorFunc(cmd, err)
|
||||
}
|
||||
|
||||
return help, nil
|
||||
}
|
||||
|
||||
// isHelpFlag reports whether arg requests help, in any of the forms pflag
|
||||
// accepts for a boolean flag.
|
||||
func isHelpFlag(arg string) bool {
|
||||
return arg == "--help" || arg == "-h" || arg == "--help=true"
|
||||
}
|
||||
|
||||
// cloudPluginArgs only rewrites the global prefix, leaving subcommand arguments
|
||||
// untouched. Parsing also distinguishes --cloud from another flag's value.
|
||||
func cloudPluginArgs(cmd *cobra.Command, osArgs []string, contextName string) ([]string, error) {
|
||||
flags := pflag.NewFlagSet(cmd.Name(), pflag.ContinueOnError)
|
||||
flags.SetInterspersed(false)
|
||||
flags.AddFlagSet(cmd.Flags())
|
||||
flags.AddFlagSet(cmd.PersistentFlags())
|
||||
|
||||
result := []string{osArgs[0]}
|
||||
if contextName != "" {
|
||||
result = append(result, "--context="+contextName)
|
||||
}
|
||||
|
||||
if err := flags.ParseAll(osArgs[1:], func(flag *pflag.Flag, value string) error {
|
||||
if flag.Name != "cloud" {
|
||||
result = append(result, "--"+flag.Name+"="+value)
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
return osArgs, err
|
||||
}
|
||||
|
||||
if flags.ArgsLenAtDash() >= 0 {
|
||||
result = append(result, "--")
|
||||
}
|
||||
|
||||
return append(result, flags.Args()...), nil
|
||||
}
|
||||
@@ -0,0 +1,682 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"cmp"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/creack/pty"
|
||||
"github.com/docker/cli/cli-plugins/metadata"
|
||||
"github.com/docker/cli/cli/command"
|
||||
"github.com/docker/cli/cli/config"
|
||||
"github.com/docker/cli/cli/config/configfile"
|
||||
contextdocker "github.com/docker/cli/cli/context/docker"
|
||||
"github.com/docker/cli/cli/context/store"
|
||||
"gotest.tools/v3/assert"
|
||||
is "gotest.tools/v3/assert/cmp"
|
||||
)
|
||||
|
||||
func TestCloudFlagHidden(t *testing.T) {
|
||||
dockerCli, err := command.NewDockerCli()
|
||||
assert.NilError(t, err)
|
||||
|
||||
tcmd := newDockerCommand(dockerCli)
|
||||
tcmd.SetArgs([]string{"--cloud=team", "--help"})
|
||||
cmd, _, err := tcmd.HandleGlobalFlags()
|
||||
assert.NilError(t, err)
|
||||
|
||||
name, err := cmd.Flags().GetString("cloud")
|
||||
assert.NilError(t, err)
|
||||
assert.Equal(t, name, "team")
|
||||
assert.Assert(t, cmd.Flags().Lookup("cloud").Hidden)
|
||||
assert.Assert(t, !strings.Contains(cmd.UsageString(), "--cloud"))
|
||||
}
|
||||
|
||||
func TestCloudHelpRequest(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
args []string
|
||||
help bool
|
||||
}{
|
||||
{name: "no command", help: true},
|
||||
{name: "global help", args: []string{"--help"}, help: true},
|
||||
{name: "global version", args: []string{"--version"}, help: true},
|
||||
{name: "help command", args: []string{"help", "run"}, help: true},
|
||||
{name: "completion request", args: []string{"__complete", "run", ""}, help: true},
|
||||
{name: "completion script", args: []string{"completion", "fish"}, help: true},
|
||||
{name: "builtin help", args: []string{"run", "--help"}, help: true},
|
||||
{name: "boolean help value", args: []string{"run", "--help=1"}, help: true},
|
||||
{name: "nested help", args: []string{"container", "run", "--help"}, help: true},
|
||||
{name: "plugin help", args: []string{"compose", "--help"}, help: true},
|
||||
{name: "command execution", args: []string{"ps"}},
|
||||
{name: "container help", args: []string{"run", "alpine", "--help"}},
|
||||
{name: "flag value", args: []string{"run", "--name", "--help", "alpine"}},
|
||||
{name: "help disabled", args: []string{"run", "--help=false", "alpine"}},
|
||||
{name: "build help", args: []string{"build", "--help"}, help: true},
|
||||
{name: "build buildkit-only flag", args: []string{"build", "--secret", "id=s,src=./s", "."}},
|
||||
{name: "image build buildkit-only flag", args: []string{"image", "build", "--push", "."}},
|
||||
{name: "builder build buildkit-only flag", args: []string{"builder", "build", "--ssh", "default", "."}},
|
||||
{name: "bake help", args: []string{"bake", "--help"}, help: true},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
dockerCli, err := command.NewDockerCli()
|
||||
assert.NilError(t, err)
|
||||
tcmd := newDockerCommand(dockerCli)
|
||||
tcmd.SetArgs(append([]string{"--cloud"}, tc.args...))
|
||||
cmd, args, err := tcmd.HandleGlobalFlags()
|
||||
assert.NilError(t, err)
|
||||
|
||||
help, err := cloudHelpRequest(cmd, args)
|
||||
assert.NilError(t, err)
|
||||
assert.Equal(t, help, tc.help)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Flag errors are reported before cloud resolution, so they must not run the
|
||||
// command's daemon feature checks against the engine selected before it.
|
||||
func TestCloudFlagErrorSkipsDaemon(t *testing.T) {
|
||||
originalConfig := config.Dir()
|
||||
t.Cleanup(func() { config.SetDir(originalConfig) })
|
||||
config.SetDir(t.TempDir())
|
||||
|
||||
var pings atomic.Int32
|
||||
daemon := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
pings.Add(1)
|
||||
w.Header().Set("Docker-Experimental", "false")
|
||||
}))
|
||||
defer daemon.Close()
|
||||
t.Setenv("DOCKER_HOST", "tcp://"+daemon.Listener.Addr().String())
|
||||
|
||||
dockerCli, err := command.NewDockerCli()
|
||||
assert.NilError(t, err)
|
||||
tcmd := newDockerCommand(dockerCli)
|
||||
tcmd.SetArgs([]string{"--cloud", "checkpoint", "ls", "--unknown"})
|
||||
cmd, args, err := tcmd.HandleGlobalFlags()
|
||||
assert.NilError(t, err)
|
||||
assert.NilError(t, tcmd.Initialize())
|
||||
|
||||
_, err = cloudHelpRequest(cmd, args)
|
||||
assert.Check(t, is.ErrorContains(err, "unknown flag: --unknown"))
|
||||
assert.Check(t, is.Equal(pings.Load(), int32(0)))
|
||||
}
|
||||
|
||||
func TestCloudPluginArgs(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
args []string
|
||||
context string
|
||||
want []string
|
||||
}{
|
||||
{
|
||||
name: "global flag only",
|
||||
args: []string{"docker", "--cloud=foo", "compose", "up", "--cloud=child"},
|
||||
context: "cloud-foo",
|
||||
want: []string{"docker", "--context=cloud-foo", "compose", "up", "--cloud=child"},
|
||||
},
|
||||
{
|
||||
name: "flag value is not cloud flag",
|
||||
args: []string{"docker", "--config", "--cloud", "--cloud", "-D", "compose", "up"},
|
||||
context: "cloud-default",
|
||||
want: []string{"docker", "--context=cloud-default", "--config=--cloud", "--debug=true", "compose", "up"},
|
||||
},
|
||||
{
|
||||
name: "delimiter preserved",
|
||||
args: []string{"docker", "--cloud=foo", "--", "compose", "--", "--cloud"},
|
||||
context: "cloud-foo",
|
||||
want: []string{"docker", "--context=cloud-foo", "--", "compose", "--", "--cloud"},
|
||||
},
|
||||
{
|
||||
name: "repeated cloud flags",
|
||||
args: []string{"docker", "--cloud=foo", "--cloud=bar", "compose", "up"},
|
||||
context: "cloud-bar",
|
||||
want: []string{"docker", "--context=cloud-bar", "compose", "up"},
|
||||
},
|
||||
{
|
||||
name: "help without resolution",
|
||||
args: []string{"docker", "--cloud", "compose", "--help"},
|
||||
want: []string{"docker", "compose", "--help"},
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
dockerCli, err := command.NewDockerCli()
|
||||
assert.NilError(t, err)
|
||||
tcmd := newDockerCommand(dockerCli)
|
||||
tcmd.SetArgs(tc.args[1:])
|
||||
cmd, _, err := tcmd.HandleGlobalFlags()
|
||||
assert.NilError(t, err)
|
||||
|
||||
got, err := cloudPluginArgs(cmd, tc.args, tc.context)
|
||||
assert.NilError(t, err)
|
||||
assert.DeepEqual(t, got, tc.want)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Exercise the real startup and subprocess boundary without a daemon or cloud
|
||||
// service, including failures that must never dispatch the requested command.
|
||||
func TestCloudResolution(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("fixture plugins use shell scripts")
|
||||
}
|
||||
|
||||
executable, err := os.Executable()
|
||||
assert.NilError(t, err)
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
args []string
|
||||
response string
|
||||
exit int
|
||||
wantName string
|
||||
wantErr string
|
||||
plugin bool
|
||||
skip bool
|
||||
provider string
|
||||
noProvider bool
|
||||
legacyProvider bool
|
||||
invalidProvider bool
|
||||
wantHelp bool
|
||||
command []string
|
||||
visible bool
|
||||
}{
|
||||
{
|
||||
name: "default target",
|
||||
args: []string{"--cloud"},
|
||||
wantName: "default",
|
||||
},
|
||||
{
|
||||
name: "empty target",
|
||||
args: []string{"--cloud="},
|
||||
wantName: "default",
|
||||
},
|
||||
{
|
||||
name: "named target",
|
||||
args: []string{"--cloud=team"},
|
||||
wantName: "team",
|
||||
},
|
||||
{
|
||||
name: "plugin dispatch",
|
||||
args: []string{"--cloud=team"},
|
||||
wantName: "team",
|
||||
plugin: true,
|
||||
},
|
||||
{
|
||||
name: "nonzero exit",
|
||||
args: []string{"--cloud"},
|
||||
exit: 1,
|
||||
wantErr: "cloud resolver failed",
|
||||
},
|
||||
{
|
||||
name: "malformed JSON",
|
||||
args: []string{"--cloud"},
|
||||
response: "not JSON",
|
||||
wantErr: "invalid cloud resolver response",
|
||||
},
|
||||
{
|
||||
name: "multiple responses",
|
||||
args: []string{"--cloud"},
|
||||
response: `{} {}`,
|
||||
wantErr: "invalid cloud resolver response",
|
||||
},
|
||||
{
|
||||
name: "missing field",
|
||||
args: []string{"--cloud"},
|
||||
response: `{}`,
|
||||
wantErr: "must return a non-default DOCKER_CONTEXT",
|
||||
},
|
||||
{
|
||||
name: "local context",
|
||||
args: []string{"--cloud"},
|
||||
response: `{"DOCKER_CONTEXT":"default"}`,
|
||||
wantErr: "must return a non-default DOCKER_CONTEXT",
|
||||
},
|
||||
{
|
||||
name: "missing context",
|
||||
args: []string{"--cloud"},
|
||||
response: `{"DOCKER_CONTEXT":"missing"}`,
|
||||
wantErr: `loading resolved context "missing"`,
|
||||
},
|
||||
{
|
||||
name: "missing endpoint host",
|
||||
args: []string{"--cloud"},
|
||||
response: `{"DOCKER_CONTEXT":"empty-host"}`,
|
||||
wantErr: "has no Docker endpoint host",
|
||||
},
|
||||
{
|
||||
name: "explicit context conflict",
|
||||
args: []string{"--cloud", "--context=other"},
|
||||
wantErr: "conflicting options",
|
||||
skip: true,
|
||||
},
|
||||
{
|
||||
name: "explicit host conflict",
|
||||
args: []string{"--cloud", "--host=tcp://localhost:2375"},
|
||||
wantErr: "conflicting options",
|
||||
skip: true,
|
||||
},
|
||||
{
|
||||
name: "invalid flag before help",
|
||||
args: []string{"--cloud"},
|
||||
command: []string{"run", "--unknown", "--help"},
|
||||
wantErr: "unknown flag: --unknown",
|
||||
skip: true,
|
||||
},
|
||||
{
|
||||
name: "default provider",
|
||||
args: []string{"--cloud"},
|
||||
noProvider: true,
|
||||
wantName: "default",
|
||||
},
|
||||
{
|
||||
name: "legacy default provider",
|
||||
args: []string{"--cloud"},
|
||||
noProvider: true,
|
||||
legacyProvider: true,
|
||||
wantErr: `plugin "offload" does not support --cloud`,
|
||||
wantHelp: true,
|
||||
skip: true,
|
||||
},
|
||||
{
|
||||
name: "invalid default provider",
|
||||
args: []string{"--cloud"},
|
||||
noProvider: true,
|
||||
invalidProvider: true,
|
||||
wantErr: `invalid plugin "offload": plugin metadata does not define a vendor`,
|
||||
wantHelp: true,
|
||||
skip: true,
|
||||
},
|
||||
{
|
||||
name: "invalid configured provider",
|
||||
args: []string{"--cloud"},
|
||||
invalidProvider: true,
|
||||
wantErr: `invalid plugin "foobar": plugin metadata does not define a vendor`,
|
||||
skip: true,
|
||||
},
|
||||
{
|
||||
name: "legacy configured provider",
|
||||
args: []string{"--cloud"},
|
||||
legacyProvider: true,
|
||||
wantErr: `plugin "foobar" does not support --cloud`,
|
||||
skip: true,
|
||||
},
|
||||
{
|
||||
name: "configured provider unavailable",
|
||||
args: []string{"--cloud"},
|
||||
provider: "missing",
|
||||
wantErr: `plugin "missing" unavailable`,
|
||||
skip: true,
|
||||
},
|
||||
{
|
||||
name: "provider cannot be a path",
|
||||
args: []string{"--cloud"},
|
||||
provider: "../foobar",
|
||||
wantErr: `plugin "../foobar" unavailable`,
|
||||
skip: true,
|
||||
},
|
||||
{
|
||||
name: "no cloud option",
|
||||
noProvider: true,
|
||||
skip: true,
|
||||
},
|
||||
{
|
||||
name: "help",
|
||||
args: []string{"--cloud", "--help"},
|
||||
noProvider: true,
|
||||
skip: true,
|
||||
},
|
||||
{
|
||||
name: "version",
|
||||
args: []string{"--cloud", "--version"},
|
||||
noProvider: true,
|
||||
skip: true,
|
||||
},
|
||||
{
|
||||
name: "help exposes supported provider",
|
||||
command: []string{"--help"},
|
||||
noProvider: true,
|
||||
visible: true,
|
||||
skip: true,
|
||||
},
|
||||
{
|
||||
name: "help hides legacy provider",
|
||||
command: []string{"--help"},
|
||||
noProvider: true,
|
||||
legacyProvider: true,
|
||||
skip: true,
|
||||
},
|
||||
{
|
||||
name: "help hides missing provider",
|
||||
command: []string{"--help"},
|
||||
provider: "missing",
|
||||
skip: true,
|
||||
},
|
||||
{
|
||||
name: "completion exposes supported provider",
|
||||
command: []string{"__complete", "--cl"},
|
||||
noProvider: true,
|
||||
visible: true,
|
||||
skip: true,
|
||||
},
|
||||
{
|
||||
name: "completion hides legacy provider",
|
||||
command: []string{"__complete", "--cl"},
|
||||
noProvider: true,
|
||||
legacyProvider: true,
|
||||
skip: true,
|
||||
},
|
||||
{
|
||||
name: "completion hides missing provider",
|
||||
command: []string{"__complete", "--cl"},
|
||||
provider: "missing",
|
||||
skip: true,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
// Explicit --cloud must override inherited endpoint selection.
|
||||
t.Setenv("DOCKER_CONTEXT", "inherited")
|
||||
t.Setenv("DOCKER_HOST", "tcp://127.0.0.1:1")
|
||||
t.Setenv("DOCKER_CLI_HOOKS", "false")
|
||||
t.Setenv("DOCKER_CONFIG", t.TempDir())
|
||||
|
||||
configDir := t.TempDir()
|
||||
installedProvider := "offload"
|
||||
if !tc.noProvider {
|
||||
installedProvider = "foobar"
|
||||
cfg := configfile.New(filepath.Join(configDir, config.ConfigFileName))
|
||||
provider := cmp.Or(tc.provider, "foobar")
|
||||
cfg.Features = map[string]string{"cloud": provider}
|
||||
assert.NilError(t, cfg.Save())
|
||||
}
|
||||
|
||||
pluginDir := filepath.Join(configDir, "cli-plugins")
|
||||
assert.NilError(t, os.MkdirAll(pluginDir, 0o755))
|
||||
|
||||
response := cmp.Or(tc.response, `{"DOCKER_CONTEXT":"resolved"}`)
|
||||
|
||||
providerMetadata := metadata.Metadata{SchemaVersion: "0.1.0", Vendor: "test"}
|
||||
if tc.invalidProvider {
|
||||
providerMetadata.Vendor = ""
|
||||
}
|
||||
if !tc.legacyProvider {
|
||||
providerMetadata.Features = map[string]any{cloudResolverFeature: true}
|
||||
}
|
||||
pluginMetadata, err := json.Marshal(providerMetadata)
|
||||
assert.NilError(t, err)
|
||||
|
||||
script := fmt.Sprintf(`#!/bin/sh
|
||||
if [ "$1" = docker-cli-plugin-metadata ]; then
|
||||
echo '%s'
|
||||
exit 0
|
||||
fi
|
||||
printf '%%s\n' "$@" >> "$DOCKER_CONFIG/resolver-args"
|
||||
echo provisioning >&2
|
||||
printf '%%s\n' '%s'
|
||||
exit %d
|
||||
`, pluginMetadata, response, tc.exit)
|
||||
assert.NilError(t, os.WriteFile(filepath.Join(pluginDir, "docker-"+installedProvider), []byte(script), 0o755))
|
||||
assert.NilError(t, os.WriteFile(filepath.Join(pluginDir, "docker-cloudtest"), []byte(`#!/bin/sh
|
||||
if [ "$1" = docker-cli-plugin-metadata ]; then
|
||||
echo '{"SchemaVersion":"0.1.0","Vendor":"test"}'
|
||||
exit 0
|
||||
fi
|
||||
printf '%s\n' "$@" > "$CLOUD_TEST_ARGS"
|
||||
`), 0o755))
|
||||
|
||||
dispatchFile := filepath.Join(configDir, "dispatch-args")
|
||||
t.Setenv("CLOUD_TEST_ARGS", dispatchFile)
|
||||
|
||||
contextStore := store.New(filepath.Join(configDir, "contexts"), command.DefaultContextStoreConfig())
|
||||
assert.NilError(t, contextStore.CreateOrUpdate(store.Metadata{
|
||||
Name: "resolved",
|
||||
Endpoints: map[string]any{contextdocker.DockerEndpoint: contextdocker.EndpointMeta{Host: "tcp://127.0.0.1:1"}},
|
||||
}))
|
||||
assert.NilError(t, contextStore.CreateOrUpdate(store.Metadata{
|
||||
Name: "empty-host",
|
||||
Endpoints: map[string]any{contextdocker.DockerEndpoint: contextdocker.EndpointMeta{}},
|
||||
}))
|
||||
|
||||
args := append([]string{"docker", "--config=" + configDir}, tc.args...)
|
||||
switch {
|
||||
case tc.command != nil:
|
||||
args = append(args, tc.command...)
|
||||
case tc.plugin || tc.wantErr != "":
|
||||
args = append(args, "cloudtest", "up", "--cloud=child")
|
||||
default:
|
||||
args = append(args, "context", "show")
|
||||
}
|
||||
|
||||
payload, err := json.Marshal(args)
|
||||
assert.NilError(t, err)
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd := exec.CommandContext(t.Context(), executable, "-test.run=^TestCloudCommandProcess$")
|
||||
cmd.Env = append(os.Environ(), "CLOUD_TEST_COMMAND="+string(payload))
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
err = cmd.Run()
|
||||
if tc.wantErr != "" {
|
||||
assert.Assert(t, err != nil)
|
||||
assert.Check(t, is.Contains(stderr.String(), tc.wantErr))
|
||||
assert.Equal(t, strings.Contains(stderr.String(), cloudPluginHelp), tc.wantHelp, stderr.String())
|
||||
_, statErr := os.Stat(dispatchFile)
|
||||
assert.Assert(t, os.IsNotExist(statErr))
|
||||
assert.Equal(t, stdout.String(), "")
|
||||
} else {
|
||||
assert.NilError(t, err, stderr.String())
|
||||
if tc.command != nil {
|
||||
assert.Equal(t, strings.Contains(stdout.String(), "--cloud"), tc.visible, stdout.String())
|
||||
}
|
||||
if !tc.skip {
|
||||
if tc.plugin {
|
||||
args, err := os.ReadFile(dispatchFile)
|
||||
assert.NilError(t, err)
|
||||
assert.Equal(t, string(args), "--context=resolved\n--config="+configDir+"\ncloudtest\nup\n--cloud=child\n")
|
||||
} else {
|
||||
assert.Equal(t, stdout.String(), "resolved\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
invocation, err := os.ReadFile(filepath.Join(configDir, "resolver-args"))
|
||||
if tc.skip {
|
||||
assert.Assert(t, os.IsNotExist(err))
|
||||
} else {
|
||||
assert.NilError(t, err)
|
||||
assert.Check(t, is.Contains(stderr.String(), "provisioning"))
|
||||
name := cmp.Or(tc.wantName, "default")
|
||||
assert.Equal(t, string(invocation), "--config="+configDir+"\n"+installedProvider+"\n__resolve-context\n--\n"+name+"\n")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCloudResolverInput(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("fixture plugins use shell scripts and pseudo-terminals")
|
||||
}
|
||||
|
||||
executable, err := os.Executable()
|
||||
assert.NilError(t, err)
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
stdinType string
|
||||
terminalErr bool
|
||||
prompt bool
|
||||
}{
|
||||
{name: "interactive", stdinType: "terminal", terminalErr: true, prompt: true},
|
||||
{name: "piped stdin", stdinType: "pipe", terminalErr: true},
|
||||
{name: "redirected stdin", stdinType: "file", terminalErr: true},
|
||||
{name: "redirected stderr", stdinType: "terminal"},
|
||||
{name: "noninteractive", stdinType: "pipe"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Setenv("DOCKER_CLI_HOOKS", "false")
|
||||
configDir := t.TempDir()
|
||||
pluginDir := filepath.Join(configDir, "cli-plugins")
|
||||
assert.NilError(t, os.MkdirAll(pluginDir, 0o755))
|
||||
assert.NilError(t, os.WriteFile(filepath.Join(pluginDir, "docker-offload"), []byte(`#!/bin/sh
|
||||
if [ "$1" = docker-cli-plugin-metadata ]; then
|
||||
echo '{"SchemaVersion":"0.1.0","Vendor":"test","Features":{"cloud-context-resolver":true}}'
|
||||
exit 0
|
||||
fi
|
||||
if [ -t 0 ]; then
|
||||
[ -t 2 ] || exit 1
|
||||
printf 'Continue? ' >&2
|
||||
IFS= read -r reply || exit 1
|
||||
printf '%s\n' "$reply" > "$DOCKER_CONFIG/resolver-input"
|
||||
elif IFS= read -r unexpected; then
|
||||
echo 'resolver consumed command input' >&2
|
||||
exit 1
|
||||
fi
|
||||
echo '{"DOCKER_CONTEXT":"resolved"}'
|
||||
`), 0o755))
|
||||
assert.NilError(t, os.WriteFile(filepath.Join(pluginDir, "docker-cloudtest"), []byte(`#!/bin/sh
|
||||
if [ "$1" = docker-cli-plugin-metadata ]; then
|
||||
echo '{"SchemaVersion":"0.1.0","Vendor":"test"}'
|
||||
exit 0
|
||||
fi
|
||||
IFS= read -r input || exit 1
|
||||
printf '%s\n' "$input"
|
||||
`), 0o755))
|
||||
|
||||
contextStore := store.New(filepath.Join(configDir, "contexts"), command.DefaultContextStoreConfig())
|
||||
assert.NilError(t, contextStore.CreateOrUpdate(store.Metadata{
|
||||
Name: "resolved",
|
||||
Endpoints: map[string]any{contextdocker.DockerEndpoint: contextdocker.EndpointMeta{Host: "tcp://127.0.0.1:1"}},
|
||||
}))
|
||||
|
||||
terminal, tty, err := pty.Open()
|
||||
assert.NilError(t, err)
|
||||
t.Cleanup(func() {
|
||||
_ = tty.Close()
|
||||
_ = terminal.Close()
|
||||
})
|
||||
|
||||
const commandInput = "input for the original command\n"
|
||||
var stdin *os.File
|
||||
switch tc.stdinType {
|
||||
case "terminal":
|
||||
stdin = tty
|
||||
input := commandInput
|
||||
if tc.prompt {
|
||||
input = "yes\n" + input
|
||||
}
|
||||
_, err = terminal.WriteString(input)
|
||||
assert.NilError(t, err)
|
||||
case "pipe":
|
||||
var writer *os.File
|
||||
stdin, writer, err = os.Pipe()
|
||||
assert.NilError(t, err)
|
||||
t.Cleanup(func() { _ = stdin.Close() })
|
||||
t.Cleanup(func() { _ = writer.Close() })
|
||||
_, err = writer.WriteString(commandInput)
|
||||
assert.NilError(t, err)
|
||||
assert.NilError(t, writer.Close())
|
||||
case "file":
|
||||
inputPath := filepath.Join(configDir, "command-input")
|
||||
assert.NilError(t, os.WriteFile(inputPath, []byte(commandInput), 0o600))
|
||||
stdin, err = os.Open(inputPath)
|
||||
assert.NilError(t, err)
|
||||
t.Cleanup(func() { _ = stdin.Close() })
|
||||
}
|
||||
|
||||
payload, err := json.Marshal([]string{"docker", "--config=" + configDir, "--cloud", "cloudtest"})
|
||||
assert.NilError(t, err)
|
||||
ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd := exec.CommandContext(ctx, executable, "-test.run=^TestCloudCommandProcess$")
|
||||
cmd.Env = append(os.Environ(), "CLOUD_TEST_COMMAND="+string(payload))
|
||||
cmd.Stdin = stdin
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
cmd.WaitDelay = time.Second
|
||||
if tc.terminalErr {
|
||||
cmd.Stderr = tty
|
||||
}
|
||||
assert.NilError(t, cmd.Run(), stderr.String())
|
||||
assert.Equal(t, stdout.String(), commandInput)
|
||||
|
||||
reply, err := os.ReadFile(filepath.Join(configDir, "resolver-input"))
|
||||
if tc.prompt {
|
||||
assert.NilError(t, err)
|
||||
assert.Equal(t, string(reply), "yes\n")
|
||||
} else {
|
||||
assert.Assert(t, os.IsNotExist(err))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Run startup in a separate process because it installs process-wide signal
|
||||
// handlers that intentionally outlive an individual command.
|
||||
func TestCloudCommandProcess(t *testing.T) {
|
||||
payload := os.Getenv("CLOUD_TEST_COMMAND")
|
||||
if payload == "" {
|
||||
return
|
||||
}
|
||||
|
||||
assert.NilError(t, json.Unmarshal([]byte(payload), &os.Args))
|
||||
|
||||
err := dockerMain(context.Background())
|
||||
if err != nil {
|
||||
_, _ = fmt.Fprintln(os.Stderr, err)
|
||||
}
|
||||
if err == nil {
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
os.Exit(getExitCode(err))
|
||||
}
|
||||
|
||||
func TestCloudResolutionCancellation(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("fixture plugin uses a shell script")
|
||||
}
|
||||
|
||||
originalConfig := config.Dir()
|
||||
t.Cleanup(func() { config.SetDir(originalConfig) })
|
||||
config.SetDir(t.TempDir())
|
||||
|
||||
cfg := configfile.New(filepath.Join(config.Dir(), config.ConfigFileName))
|
||||
cfg.Features = map[string]string{"cloud": "foobar"}
|
||||
assert.NilError(t, cfg.Save())
|
||||
|
||||
pluginDir := filepath.Join(config.Dir(), "cli-plugins")
|
||||
assert.NilError(t, os.MkdirAll(pluginDir, 0o755))
|
||||
assert.NilError(t, os.WriteFile(filepath.Join(pluginDir, "docker-foobar"), []byte(`#!/bin/sh
|
||||
if [ "$1" = docker-cli-plugin-metadata ]; then
|
||||
echo '{"SchemaVersion":"0.1.0","Vendor":"test","Features":{"cloud-context-resolver":true}}'
|
||||
exit 0
|
||||
fi
|
||||
exec sleep 30
|
||||
`), 0o755))
|
||||
|
||||
dockerCli, err := command.NewDockerCli()
|
||||
assert.NilError(t, err)
|
||||
tcmd := newDockerCommand(dockerCli)
|
||||
tcmd.SetArgs(strings.Fields("--cloud context show"))
|
||||
cmd, _, err := tcmd.HandleGlobalFlags()
|
||||
assert.NilError(t, err)
|
||||
|
||||
ctx, cancel := context.WithTimeout(t.Context(), time.Second)
|
||||
defer cancel()
|
||||
|
||||
_, err = resolveCloudContext(ctx, dockerCli, cmd, "default")
|
||||
assert.ErrorIs(t, err, context.DeadlineExceeded)
|
||||
}
|
||||
+33
-4
@@ -178,6 +178,9 @@ func newDockerCommand(dockerCli *command.DockerCli) *cli.TopLevelCommand {
|
||||
_ = cmd.RegisterFlagCompletionFunc("log-level", completeLogLevels)
|
||||
|
||||
cmd.Flags().BoolP("version", "v", false, "Print version information and quit")
|
||||
cmd.Flags().String("cloud", "", "Use a cloud context, provisioning it if necessary (optionally --cloud=NAME)")
|
||||
cmd.Flags().Lookup("cloud").NoOptDefVal = "default"
|
||||
cmd.Flags().Lookup("cloud").Hidden = true
|
||||
setFlagErrorFunc(dockerCli, cmd)
|
||||
|
||||
setupHelpCommand(dockerCli, cmd, helpCmd)
|
||||
@@ -250,6 +253,7 @@ func setHelpFunc(dockerCli command.Cli, cmd *cobra.Command) {
|
||||
ccmd.Println(err)
|
||||
return
|
||||
}
|
||||
updateCloudFlagVisibility(dockerCli, ccmd.Root())
|
||||
|
||||
if len(args) >= 1 {
|
||||
err := tryRunPluginHelp(dockerCli, ccmd, args)
|
||||
@@ -508,7 +512,18 @@ func runDocker(ctx context.Context, dockerCli *command.DockerCli) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := tcmd.Initialize(command.WithEnableGlobalMeterProvider(), command.WithEnableGlobalTracerProvider()); err != nil {
|
||||
if err := tcmd.Initialize(
|
||||
command.WithEnableGlobalMeterProvider(),
|
||||
command.WithEnableGlobalTracerProvider(),
|
||||
command.WithContextResolver(func(cli *command.DockerCli) (string, error) {
|
||||
var err error
|
||||
os.Args, err = processCloud(ctx, cli, cmd, args, os.Args)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return cmd.Flags().GetString("context")
|
||||
}),
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -538,6 +553,7 @@ func runDocker(ctx context.Context, dockerCli *command.DockerCli) error {
|
||||
if err := pluginmanager.AddPluginCommandStubs(dockerCli, cmd); err != nil {
|
||||
return err
|
||||
}
|
||||
updateCloudFlagVisibility(dockerCli, cmd)
|
||||
}
|
||||
|
||||
var subCommand *cobra.Command
|
||||
@@ -555,11 +571,24 @@ func runDocker(ctx context.Context, dockerCli *command.DockerCli) error {
|
||||
return nil
|
||||
}
|
||||
if !errdefs.IsNotFound(err) {
|
||||
// For plugin not found we fall through to
|
||||
// cmd.Execute() which deals with reporting
|
||||
// "command not found" in a consistent way.
|
||||
return err
|
||||
}
|
||||
if args[0] == "offload" {
|
||||
// Report this before cmd.ExecuteContext(): plugin-specific flags such as
|
||||
// --cpus otherwise fail parsing before the root command's RunE.
|
||||
return errors.New("docker: to use 'docker offload', install the plugin.\nSee https://docs.docker.com/go/cloud-flag/")
|
||||
}
|
||||
// Other missing or invalid plugins fall through to cmd.ExecuteContext(),
|
||||
// which reports the standard unknown-command error.
|
||||
//
|
||||
// Cobra handles "--version" before it checks for unknown commands,
|
||||
// so unset it to make "docker nonexistent --version" fail instead
|
||||
// of printing the version. A global "--version" flag (as in
|
||||
// "docker --version nonexistent") was already parsed, and still
|
||||
// prints the version.
|
||||
if !cmd.Flags().Changed("version") {
|
||||
cmd.Version = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
ARG GO_VERSION=1.26.8
|
||||
ARG GO_VERSION=1.26.10
|
||||
|
||||
# ALPINE_VERSION sets the version of the alpine base image to use, including for the golang image.
|
||||
# It must be a supported tag in the docker.io/library/alpine image repository
|
||||
@@ -10,7 +10,7 @@ ARG ALPINE_VERSION=3.23
|
||||
# BUILDX_VERSION sets the version of buildx to install in the dev container.
|
||||
# It must be a valid tag in the docker.io/docker/buildx-bin image repository
|
||||
# on Docker Hub.
|
||||
ARG BUILDX_VERSION=0.34.1
|
||||
ARG BUILDX_VERSION=0.38.0
|
||||
FROM docker/buildx-bin:${BUILDX_VERSION} AS buildx
|
||||
|
||||
FROM golang:${GO_VERSION}-alpine${ALPINE_VERSION} AS golang
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
ARG GO_VERSION=1.26.8
|
||||
ARG GO_VERSION=1.26.10
|
||||
|
||||
# ALPINE_VERSION sets the version of the alpine base image to use, including for the golang image.
|
||||
# It must be a supported tag in the docker.io/library/alpine image repository
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
ARG GO_VERSION=1.26.8
|
||||
ARG GO_VERSION=1.26.10
|
||||
|
||||
# ALPINE_VERSION sets the version of the alpine base image to use, including for the golang image.
|
||||
# It must be a supported tag in the docker.io/library/alpine image repository
|
||||
|
||||
@@ -70,6 +70,18 @@ With Docker Desktop, the credential store is already installed and configured
|
||||
for you. Unless you want to change the credential store used by Docker Desktop,
|
||||
you can skip the following steps.
|
||||
|
||||
#### Docker Desktop sign-in
|
||||
|
||||
When Docker Desktop is running and you are signed in, commands that pull or
|
||||
push images through the Docker Engine, such as `docker pull`, `docker push`,
|
||||
`docker run`, and `docker service create`, use the Docker Desktop session for
|
||||
Docker Hub (`docker.io`) and Docker Hardened Images (`dhi.io`). The session is
|
||||
used before the credential store, credential helpers, and the configuration
|
||||
file. Credentials set in the `DOCKER_AUTH_CONFIG` environment variable take
|
||||
precedence over the Docker Desktop session. If Docker Desktop is not installed
|
||||
or not running, or you are not signed in, the Docker CLI uses its own
|
||||
credentials.
|
||||
|
||||
#### Configure the credential store
|
||||
|
||||
You need to specify the credential store in `$HOME/.docker/config.json`
|
||||
@@ -180,13 +192,12 @@ code, and authenticate.
|
||||
```console
|
||||
$ docker login
|
||||
|
||||
USING WEB-BASED LOGIN
|
||||
To sign in with credentials on the command line, use 'docker login -u <username>'
|
||||
Using web-based login. To sign in with credentials on the command line, use 'docker login -u <username>'
|
||||
|
||||
Your one-time device confirmation code is: LNFR-PGCJ
|
||||
Press ENTER to open your browser or submit your device code here: https://login.docker.com/activate
|
||||
Open this URL to authenticate: https://login.docker.com/activate?user_code=LNFR-PGCJ
|
||||
|
||||
Waiting for authentication in the browser…
|
||||
Press ENTER to open the URL in your browser, or Ctrl+C to cancel.
|
||||
```
|
||||
|
||||
After entering the code in your browser, you are authenticated to Docker Hub
|
||||
|
||||
@@ -62,6 +62,22 @@ func TestNonexistingHelp(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
// TestNonexistingVersion ensures correct behaviour when invoking a
|
||||
// nonexistent plugin with `--version`.
|
||||
func TestNonexistingVersion(t *testing.T) {
|
||||
run, _, cleanup := prepare(t)
|
||||
defer cleanup()
|
||||
|
||||
for _, flag := range []string{"--version", "-v"} {
|
||||
res := icmd.RunCmd(run("nonexistent", flag))
|
||||
res.Assert(t, icmd.Expected{
|
||||
ExitCode: 1,
|
||||
Out: icmd.None,
|
||||
})
|
||||
golden.Assert(t, res.Stderr(), "docker-nonexistent-err.golden")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunBad ensures correct behaviour when running an existent but invalid plugin
|
||||
func TestRunBad(t *testing.T) {
|
||||
run, _, cleanup := prepare(t)
|
||||
|
||||
@@ -31,6 +31,7 @@ services:
|
||||
dockerfile: engine/Dockerfile
|
||||
args:
|
||||
ENGINE_VERSION: ${ENGINE_VERSION:-29}
|
||||
ENGINE_SOURCE: ${ENGINE_SOURCE:-dind}
|
||||
privileged: true
|
||||
command: ['--insecure-registry=registry:5000', '--insecure-registry=privateregistry:5001', '--experimental']
|
||||
environment:
|
||||
|
||||
@@ -112,7 +112,7 @@ func TestRunWithCgroupNamespace(t *testing.T) {
|
||||
|
||||
func TestRunUmask(t *testing.T) {
|
||||
environment.SkipIfDaemonNotLinux(t)
|
||||
t.Skip("FIXME: un-skip once e2e tests v29.8.0")
|
||||
skip.If(t, versions.LessThan(environment.DaemonAPIVersion(t), "1.56"))
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
|
||||
@@ -30,5 +30,5 @@ func TestOauthLogin(t *testing.T) {
|
||||
assert.NilError(t, err)
|
||||
|
||||
output, _ := io.ReadAll(p)
|
||||
assert.Check(t, strings.Contains(string(output), "USING WEB-BASED LOGIN"), string(output))
|
||||
assert.Check(t, strings.Contains(string(output), "Using web-based login"), string(output))
|
||||
}
|
||||
|
||||
+9
-1
@@ -3,8 +3,16 @@
|
||||
# ENGINE_VERSION is the version of the (docker-in-docker) Docker Engine to
|
||||
# test against.
|
||||
ARG ENGINE_VERSION=29
|
||||
# ENGINE_SOURCE is "dind" or "moby-bin" (moby/moby-bin:master).
|
||||
ARG ENGINE_SOURCE=dind
|
||||
|
||||
FROM docker:${ENGINE_VERSION}-dind
|
||||
FROM docker:${ENGINE_VERSION}-dind AS engine-dind
|
||||
|
||||
# moby/moby-bin only has the daemon binaries.
|
||||
FROM docker:dind AS engine-moby-bin
|
||||
COPY --link --from=moby/moby-bin:master / /usr/local/bin/
|
||||
|
||||
FROM engine-${ENGINE_SOURCE}
|
||||
|
||||
# the openssh-client update is needed for security reasons when using docker:23.0-dind, currently maintained as an lts by mirantis
|
||||
RUN apk --no-cache add openssl openssh-client openssh-server shadow && \
|
||||
|
||||
Vendored
+10
-1
@@ -1,5 +1,14 @@
|
||||
ARG ENGINE_VERSION
|
||||
FROM docker:${ENGINE_VERSION}-dind
|
||||
# ENGINE_SOURCE is "dind" or "moby-bin" (moby/moby-bin:master).
|
||||
ARG ENGINE_SOURCE=dind
|
||||
|
||||
FROM docker:${ENGINE_VERSION}-dind AS engine-dind
|
||||
|
||||
# moby/moby-bin only has the daemon binaries.
|
||||
FROM docker:dind AS engine-moby-bin
|
||||
COPY --link --from=moby/moby-bin:master / /usr/local/bin/
|
||||
|
||||
FROM engine-${ENGINE_SOURCE}
|
||||
|
||||
# Trust the tlsregistry CA so dockerd connects without --insecure-registry.
|
||||
COPY registry/certs/ca.crt /usr/local/share/ca-certificates/tlsregistry-ca.crt
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"github.com/docker/cli/cli/config/credentials"
|
||||
@@ -17,8 +18,6 @@ import (
|
||||
"github.com/docker/cli/internal/registry"
|
||||
"github.com/docker/cli/internal/tui"
|
||||
"github.com/morikuni/aec"
|
||||
"github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/pkg/browser"
|
||||
)
|
||||
|
||||
@@ -83,19 +82,18 @@ var ErrDeviceLoginStartFail = errors.New("failed to start device code flow login
|
||||
// The retrieved tokens are stored in the credentials store (under a separate
|
||||
// key), and the refresh token is concatenated with the client ID.
|
||||
func (m *OAuthManager) LoginDevice(ctx context.Context, w io.Writer) (*types.AuthConfig, error) {
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
|
||||
state, err := m.api.GetDeviceCode(ctx, m.audience)
|
||||
if err != nil {
|
||||
logrus.Debugf("failed to start device code login: %v", err)
|
||||
return nil, ErrDeviceLoginStartFail
|
||||
return nil, fmt.Errorf("%w: %w", ErrDeviceLoginStartFail, err)
|
||||
}
|
||||
|
||||
if state.UserCode == "" {
|
||||
logrus.Debugf("failed to start device code login: missing user code")
|
||||
return nil, ErrDeviceLoginStartFail
|
||||
return nil, fmt.Errorf("%w: missing user code", ErrDeviceLoginStartFail)
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintln(w, aec.Bold.Apply("\nUSING WEB-BASED LOGIN"))
|
||||
|
||||
var out tui.Output
|
||||
switch stream := w.(type) {
|
||||
case *streams.Out:
|
||||
@@ -103,9 +101,10 @@ func (m *OAuthManager) LoginDevice(ctx context.Context, w io.Writer) (*types.Aut
|
||||
default:
|
||||
out = tui.NewOutput(streams.NewOut(w))
|
||||
}
|
||||
out.PrintNote("To sign in with credentials on the command line, use 'docker login -u <username>'\n")
|
||||
_, _ = fmt.Fprintf(w, "\nYour one-time device confirmation code is: "+aec.Bold.Apply("%s\n"), state.UserCode)
|
||||
_, _ = fmt.Fprintf(w, aec.Bold.Apply("Press ENTER")+" to open your browser or submit your device code here: "+aec.Underline.Apply("%s\n"), strings.Split(state.VerificationURI, "?")[0])
|
||||
out.PrintNote("Using web-based login. To sign in with credentials on the command line, use 'docker login -u <username>'\n")
|
||||
_, _ = fmt.Fprintln(w, "Your one-time device confirmation code is:", aec.Bold.Apply(state.UserCode))
|
||||
_, _ = fmt.Fprintln(w, "Open this URL to authenticate:", aec.Underline.Apply(state.VerificationURI))
|
||||
_, _ = fmt.Fprintln(w, "\n"+aec.Bold.Apply("Press ENTER")+" to open the URL in your browser, or "+aec.Bold.Apply("Ctrl+C")+" to cancel.")
|
||||
|
||||
tokenResChan := make(chan api.TokenResponse)
|
||||
waitForTokenErrChan := make(chan error)
|
||||
@@ -120,11 +119,25 @@ func (m *OAuthManager) LoginDevice(ctx context.Context, w io.Writer) (*types.Aut
|
||||
|
||||
go func() {
|
||||
reader := bufio.NewReader(os.Stdin)
|
||||
_, _ = reader.ReadString('\n')
|
||||
_ = m.openBrowser(state.VerificationURI)
|
||||
if _, err := reader.ReadString('\n'); err != nil || ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
err := m.openBrowser(state.VerificationURI)
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
if errors.Is(err, exec.ErrNotFound) {
|
||||
_, _ = fmt.Fprintln(w, "No browser found. Open the URL above manually to continue.")
|
||||
} else {
|
||||
_, _ = fmt.Fprintln(w, "Failed to open browser:", err)
|
||||
_, _ = fmt.Fprintln(w, "Open the URL above manually to continue.")
|
||||
}
|
||||
return
|
||||
}
|
||||
_, _ = fmt.Fprintln(w, "\nOpening browser…")
|
||||
}()
|
||||
|
||||
_, _ = fmt.Fprint(w, "\nWaiting for authentication in the browser…\n")
|
||||
var tokenRes api.TokenResponse
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
|
||||
@@ -51,7 +51,8 @@ const (
|
||||
// IndexServer is used for user auth and image search
|
||||
IndexServer = "https://index.docker.io/v1/"
|
||||
// IndexName is the name of the index
|
||||
IndexName = "docker.io"
|
||||
IndexName = "docker.io"
|
||||
DefaultRegistryHost = "registry-1.docker.io"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -61,7 +62,7 @@ var (
|
||||
// or "ubuntu:latest"), as well as references using the "docker.io" domain
|
||||
// name, which is used as canonical reference for images on Docker Hub, but
|
||||
// does not match the domain-name of Docker Hub's registry.
|
||||
DefaultV2Registry = &url.URL{Scheme: "https", Host: "registry-1.docker.io"}
|
||||
DefaultV2Registry = &url.URL{Scheme: "https", Host: DefaultRegistryHost}
|
||||
|
||||
validHostPortRegex = sync.OnceValue(func() *regexp.Regexp {
|
||||
return regexp.MustCompile(`^` + reference.DomainRegexp.String() + `$`)
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
package registryauth
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"github.com/distribution/reference"
|
||||
"github.com/docker/cli/cli/config/configfile"
|
||||
configtypes "github.com/docker/cli/cli/config/types"
|
||||
"github.com/docker/cli/internal/secretsengine"
|
||||
"github.com/moby/moby/api/pkg/authconfig"
|
||||
registrytypes "github.com/moby/moby/api/types/registry"
|
||||
)
|
||||
|
||||
type configProvider interface {
|
||||
ConfigFile() *configfile.ConfigFile
|
||||
}
|
||||
|
||||
type authProvider interface {
|
||||
RegistryAuthProvider() secretsengine.AuthProvider
|
||||
}
|
||||
|
||||
type envAuthFunc func(registryHostname string) (configtypes.AuthConfig, bool)
|
||||
|
||||
func EncodedAuth(dockerCLI configProvider, image string) (string, error) {
|
||||
var auth secretsengine.AuthProvider = dockerCLI.ConfigFile()
|
||||
if p, ok := dockerCLI.(authProvider); ok {
|
||||
auth = p.RegistryAuthProvider()
|
||||
}
|
||||
return encodedAuth(envAuthConfig, auth, image)
|
||||
}
|
||||
|
||||
func encodedAuth(envAuth envAuthFunc, auth secretsengine.AuthProvider, image string) (string, error) {
|
||||
registryRef, err := reference.ParseNormalizedNamed(image)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
domain := reference.Domain(registryRef)
|
||||
authConfig, ok := envAuth(domain)
|
||||
if !ok {
|
||||
authConfig, err = auth.GetAuthConfig(domain)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
|
||||
return authconfig.Encode(registrytypes.AuthConfig{
|
||||
Username: authConfig.Username,
|
||||
Password: authConfig.Password,
|
||||
ServerAddress: authConfig.ServerAddress,
|
||||
Auth: authConfig.Auth,
|
||||
IdentityToken: authConfig.IdentityToken,
|
||||
RegistryToken: authConfig.RegistryToken,
|
||||
})
|
||||
}
|
||||
|
||||
func envAuthConfig(registryHostname string) (configtypes.AuthConfig, bool) {
|
||||
if os.Getenv(configfile.DockerEnvConfigKey) == "" {
|
||||
return configtypes.AuthConfig{}, false
|
||||
}
|
||||
authConfig, err := configfile.New("").GetAuthConfig(registryHostname)
|
||||
return authConfig, err == nil && authConfig.Username != ""
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package registryauth
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/docker/cli/cli/config/configfile"
|
||||
configtypes "github.com/docker/cli/cli/config/types"
|
||||
"github.com/docker/cli/internal/secretsengine"
|
||||
"github.com/moby/moby/api/pkg/authconfig"
|
||||
registrytypes "github.com/moby/moby/api/types/registry"
|
||||
"gotest.tools/v3/assert"
|
||||
is "gotest.tools/v3/assert/cmp"
|
||||
)
|
||||
|
||||
type recordingAuthProvider struct {
|
||||
mu sync.Mutex
|
||||
hosts []string
|
||||
}
|
||||
|
||||
func (p *recordingAuthProvider) GetAuthConfig(registryHostname string) (configtypes.AuthConfig, error) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
p.hosts = append(p.hosts, registryHostname)
|
||||
return configtypes.AuthConfig{Username: "hubuser", Password: "desktop-token", ServerAddress: registryHostname}, nil
|
||||
}
|
||||
|
||||
type configOnlyCli struct {
|
||||
cfg *configfile.ConfigFile
|
||||
}
|
||||
|
||||
func (c *configOnlyCli) ConfigFile() *configfile.ConfigFile {
|
||||
return c.cfg
|
||||
}
|
||||
|
||||
type fakeCli struct {
|
||||
configOnlyCli
|
||||
auth *recordingAuthProvider
|
||||
}
|
||||
|
||||
func (c *fakeCli) RegistryAuthProvider() secretsengine.AuthProvider {
|
||||
return c.auth
|
||||
}
|
||||
|
||||
func noEnvAuth(string) (configtypes.AuthConfig, bool) {
|
||||
return configtypes.AuthConfig{}, false
|
||||
}
|
||||
|
||||
func loadConfig(t *testing.T) *configfile.ConfigFile {
|
||||
t.Helper()
|
||||
const configFileContent = `{"auths": {
|
||||
"https://index.docker.io/v1/": {"auth": "dXNlcm5hbWU6cGFzc3dvcmQ="},
|
||||
"localhost:5000": {"auth": "dXNlcm5hbWU6cGFzc3dvcmQ="}
|
||||
}
|
||||
}`
|
||||
cfg := &configfile.ConfigFile{}
|
||||
assert.NilError(t, cfg.LoadFromReader(bytes.NewReader([]byte(configFileContent))))
|
||||
return cfg
|
||||
}
|
||||
|
||||
func TestEncodedAuthUsesCLIAuthProvider(t *testing.T) {
|
||||
t.Parallel()
|
||||
dockerCLI := &fakeCli{configOnlyCli: configOnlyCli{cfg: configfile.New("")}, auth: &recordingAuthProvider{}}
|
||||
|
||||
actual, err := EncodedAuth(dockerCLI, "dhi.io/python")
|
||||
assert.NilError(t, err)
|
||||
expected, err := authconfig.Encode(registrytypes.AuthConfig{Username: "hubuser", Password: "desktop-token", ServerAddress: "dhi.io"})
|
||||
assert.NilError(t, err)
|
||||
assert.Check(t, is.Equal(actual, expected))
|
||||
assert.Check(t, is.DeepEqual(dockerCLI.auth.hosts, []string{"dhi.io"}))
|
||||
}
|
||||
|
||||
func TestEncodedAuthWithoutAuthProviderUsesConfigFile(t *testing.T) {
|
||||
t.Parallel()
|
||||
actual, err := EncodedAuth(&configOnlyCli{cfg: loadConfig(t)}, "ubuntu")
|
||||
assert.NilError(t, err)
|
||||
expected, err := authconfig.Encode(registrytypes.AuthConfig{Username: "username", Password: "password", ServerAddress: "https://index.docker.io/v1/"})
|
||||
assert.NilError(t, err)
|
||||
assert.Check(t, is.Equal(actual, expected))
|
||||
}
|
||||
|
||||
func TestEncodedAuthEnvTakesPrecedence(t *testing.T) {
|
||||
t.Parallel()
|
||||
envAuth := func(registryHostname string) (configtypes.AuthConfig, bool) {
|
||||
return configtypes.AuthConfig{Username: "envuser", Password: "envpass", ServerAddress: registryHostname}, true
|
||||
}
|
||||
auth := &recordingAuthProvider{}
|
||||
|
||||
actual, err := encodedAuth(envAuth, auth, "ubuntu")
|
||||
assert.NilError(t, err)
|
||||
expected, err := authconfig.Encode(registrytypes.AuthConfig{Username: "envuser", Password: "envpass", ServerAddress: "docker.io"})
|
||||
assert.NilError(t, err)
|
||||
assert.Check(t, is.Equal(actual, expected))
|
||||
assert.Check(t, is.Len(auth.hosts, 0), "the auth provider must not be asked for a registry in DOCKER_AUTH_CONFIG")
|
||||
}
|
||||
|
||||
func TestEnvAuthConfig(t *testing.T) {
|
||||
t.Setenv(configfile.DockerEnvConfigKey, `{"auths":{"https://index.docker.io/v1/":{"auth":"ZW52dXNlcjplbnZwYXNz"}}}`)
|
||||
|
||||
got, ok := envAuthConfig("docker.io")
|
||||
assert.Check(t, ok)
|
||||
assert.Check(t, is.DeepEqual(got, configtypes.AuthConfig{Username: "envuser", Password: "envpass", ServerAddress: "https://index.docker.io/v1/"}))
|
||||
|
||||
_, ok = envAuthConfig("ghcr.io")
|
||||
assert.Check(t, !ok)
|
||||
}
|
||||
|
||||
func TestEncodedAuthFromConfigFile(t *testing.T) {
|
||||
t.Parallel()
|
||||
cfg := loadConfig(t)
|
||||
|
||||
tests := []struct {
|
||||
image string
|
||||
expected registrytypes.AuthConfig
|
||||
}{
|
||||
{image: "ubuntu", expected: registrytypes.AuthConfig{Username: "username", Password: "password", ServerAddress: "https://index.docker.io/v1/"}},
|
||||
{image: "localhost:5000/ubuntu", expected: registrytypes.AuthConfig{Username: "username", Password: "password", ServerAddress: "localhost:5000"}},
|
||||
{image: "no-auth.example.com/ubuntu", expected: registrytypes.AuthConfig{}},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.image, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
actual, err := encodedAuth(noEnvAuth, cfg, tc.image)
|
||||
assert.NilError(t, err)
|
||||
expected, err := authconfig.Encode(tc.expected)
|
||||
assert.NilError(t, err)
|
||||
assert.Check(t, is.Equal(actual, expected))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
//go:build go1.26
|
||||
|
||||
package secretsengine
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/docker/cli/cli/config/credentials"
|
||||
"github.com/docker/cli/cli/config/types"
|
||||
"github.com/docker/cli/internal/registry"
|
||||
seclient "github.com/docker/secrets-engine/client"
|
||||
"github.com/docker/secrets-engine/client/dockerhub"
|
||||
"github.com/docker/secrets-engine/x/api"
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
const (
|
||||
lookupTimeout = 5 * time.Second
|
||||
sessionExpirySkew = 30 * time.Second
|
||||
sessionTTL = time.Minute
|
||||
retryInterval = time.Minute
|
||||
|
||||
stagingRegistryHost = "registry-1-stage.docker.io"
|
||||
dhiRegistryHost = "dhi.io"
|
||||
)
|
||||
|
||||
var errSessionExpired = errors.New("the session has expired (not signed in to Docker Desktop?)")
|
||||
|
||||
type hubClient interface {
|
||||
GetDefaultSession(ctx context.Context) (dockerhub.UserSession, error)
|
||||
GetDefaultProfile(ctx context.Context) (dockerhub.Profile, error)
|
||||
}
|
||||
|
||||
var _ hubClient = dockerhub.ClientAuth(nil)
|
||||
|
||||
type AuthProvider interface {
|
||||
GetAuthConfig(registryHostname string) (types.AuthConfig, error)
|
||||
}
|
||||
|
||||
type provider struct {
|
||||
fallback AuthProvider
|
||||
hub *session
|
||||
staging *session
|
||||
}
|
||||
|
||||
func NewDesktop(fallback AuthProvider) AuthProvider {
|
||||
return &provider{
|
||||
fallback: fallback,
|
||||
hub: newDesktopSession("Docker Hub"),
|
||||
staging: newDesktopSession("Docker Hub staging", dockerhub.Staging()),
|
||||
}
|
||||
}
|
||||
|
||||
func (p *provider) GetAuthConfig(registryHostname string) (types.AuthConfig, error) {
|
||||
if s, serverAddress := p.sessionFor(registryHostname); s != nil {
|
||||
if auth, ok := s.auth(); ok {
|
||||
auth.ServerAddress = serverAddress
|
||||
return auth, nil
|
||||
}
|
||||
}
|
||||
return p.fallback.GetAuthConfig(registryHostname)
|
||||
}
|
||||
|
||||
func (p *provider) sessionFor(registryHostname string) (*session, string) {
|
||||
switch credentials.ConvertToHostname(registryHostname) {
|
||||
case registry.IndexName, registry.IndexHostname, registry.DefaultRegistryHost:
|
||||
return p.hub, registry.IndexServer
|
||||
case dhiRegistryHost:
|
||||
return p.hub, dhiRegistryHost
|
||||
case stagingRegistryHost:
|
||||
return p.staging, stagingRegistryHost
|
||||
default:
|
||||
return nil, ""
|
||||
}
|
||||
}
|
||||
|
||||
type session struct {
|
||||
name string
|
||||
connect func() (hubClient, error)
|
||||
|
||||
mu sync.Mutex
|
||||
hub hubClient
|
||||
retryAt time.Time
|
||||
cached *types.AuthConfig
|
||||
validUntil time.Time
|
||||
}
|
||||
|
||||
func newDesktopSession(name string, opts ...dockerhub.Option) *session {
|
||||
return &session{
|
||||
name: name,
|
||||
connect: func() (hubClient, error) {
|
||||
return connect(api.DesktopSocketPath(), opts...)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func connect(socketPath string, opts ...dockerhub.Option) (hubClient, error) {
|
||||
if _, err := os.Lstat(socketPath); errors.Is(err, fs.ErrNotExist) {
|
||||
return nil, err
|
||||
}
|
||||
c, err := seclient.New(
|
||||
seclient.WithSocketPath(socketPath),
|
||||
seclient.WithTimeout(lookupTimeout),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return c.HubAuth(opts...), nil
|
||||
}
|
||||
|
||||
func (s *session) auth() (types.AuthConfig, bool) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
if now.Before(s.retryAt) {
|
||||
return types.AuthConfig{}, false
|
||||
}
|
||||
if s.cached != nil && now.Before(s.validUntil) {
|
||||
return *s.cached, true
|
||||
}
|
||||
auth, validUntil, err := s.fetch()
|
||||
if err != nil {
|
||||
s.retryAt = time.Now().Add(retryInterval)
|
||||
s.cached = nil
|
||||
logFallback(s.name, err)
|
||||
return types.AuthConfig{}, false
|
||||
}
|
||||
logrus.Debugf("using the %s session of %q from Docker Desktop", s.name, auth.Username)
|
||||
s.cached, s.validUntil = &auth, validUntil
|
||||
return auth, true
|
||||
}
|
||||
|
||||
func (s *session) fetch() (types.AuthConfig, time.Time, error) {
|
||||
if s.hub == nil {
|
||||
hub, err := s.connect()
|
||||
if err != nil {
|
||||
return types.AuthConfig{}, time.Time{}, err
|
||||
}
|
||||
s.hub = hub
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), lookupTimeout)
|
||||
defer cancel()
|
||||
|
||||
userSession, err := s.hub.GetDefaultSession(ctx)
|
||||
if err != nil {
|
||||
return types.AuthConfig{}, time.Time{}, err
|
||||
}
|
||||
|
||||
username := userSession.Claims.Username
|
||||
if username == "" {
|
||||
profile, err := s.hub.GetDefaultProfile(ctx)
|
||||
if err != nil {
|
||||
return types.AuthConfig{}, time.Time{}, fmt.Errorf("resolving the Docker Hub username: %w", err)
|
||||
}
|
||||
username = profile.Username
|
||||
}
|
||||
if username == "" {
|
||||
return types.AuthConfig{}, time.Time{}, errors.New("no username for the session")
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
validUntil := now.Add(sessionTTL)
|
||||
if exp := userSession.Claims.ExpiresAt; exp != nil {
|
||||
validUntil = exp.Add(-sessionExpirySkew)
|
||||
if !now.Before(validUntil) {
|
||||
return types.AuthConfig{}, time.Time{}, errSessionExpired
|
||||
}
|
||||
}
|
||||
|
||||
return types.AuthConfig{
|
||||
Username: username,
|
||||
Password: userSession.AccessToken,
|
||||
}, validUntil, nil
|
||||
}
|
||||
|
||||
func logFallback(name string, err error) {
|
||||
if isSessionUnavailable(err) {
|
||||
logrus.Debugf("no %s session available from Docker Desktop, using Docker CLI credentials: %v", name, err)
|
||||
return
|
||||
}
|
||||
logrus.Warnf("could not use the %s session from Docker Desktop, using Docker CLI credentials instead: %v", name, err)
|
||||
}
|
||||
|
||||
func isSessionUnavailable(err error) bool {
|
||||
if _, ok := errors.AsType[*seclient.ConnectError](err); ok {
|
||||
return true
|
||||
}
|
||||
return errors.Is(err, fs.ErrNotExist) ||
|
||||
errors.Is(err, dockerhub.ErrNoSession) ||
|
||||
errors.Is(err, errSessionExpired)
|
||||
}
|
||||
@@ -0,0 +1,429 @@
|
||||
//go:build go1.26
|
||||
|
||||
package secretsengine
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"testing/synctest"
|
||||
"time"
|
||||
|
||||
"github.com/docker/cli/cli/config/types"
|
||||
"github.com/docker/cli/internal/registry"
|
||||
seclient "github.com/docker/secrets-engine/client"
|
||||
"github.com/docker/secrets-engine/client/dockerhub"
|
||||
"gotest.tools/v3/assert"
|
||||
)
|
||||
|
||||
type fakeHub struct {
|
||||
session dockerhub.UserSession
|
||||
sessionErr error
|
||||
profile dockerhub.Profile
|
||||
profileErr error
|
||||
sessionCalls atomic.Int32
|
||||
}
|
||||
|
||||
func (f *fakeHub) GetDefaultSession(context.Context) (dockerhub.UserSession, error) {
|
||||
f.sessionCalls.Add(1)
|
||||
return f.session, f.sessionErr
|
||||
}
|
||||
|
||||
func (f *fakeHub) GetDefaultProfile(context.Context) (dockerhub.Profile, error) {
|
||||
return f.profile, f.profileErr
|
||||
}
|
||||
|
||||
func hubSession(username string, expiresAt time.Time) dockerhub.UserSession {
|
||||
s := dockerhub.UserSession{AccessToken: "desktop-oauth-token"}
|
||||
s.Claims.Username = username
|
||||
if !expiresAt.IsZero() {
|
||||
s.Claims.ExpiresAt = &dockerhub.NumericDate{Time: expiresAt}
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func inAnHour() time.Time {
|
||||
return time.Now().Add(time.Hour)
|
||||
}
|
||||
|
||||
type recordingFallback struct {
|
||||
mu sync.Mutex
|
||||
hosts []string
|
||||
}
|
||||
|
||||
func (f *recordingFallback) GetAuthConfig(registryHostname string) (types.AuthConfig, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.hosts = append(f.hosts, registryHostname)
|
||||
return cliCredentials(registryHostname), nil
|
||||
}
|
||||
|
||||
func cliCredentials(registryHostname string) types.AuthConfig {
|
||||
return types.AuthConfig{Username: "cli-user", Password: "cli-password", ServerAddress: registryHostname}
|
||||
}
|
||||
|
||||
func desktopCredentials() types.AuthConfig {
|
||||
return types.AuthConfig{Username: "hubuser", Password: "desktop-oauth-token", ServerAddress: registry.IndexServer}
|
||||
}
|
||||
|
||||
func stagingCredentials() types.AuthConfig {
|
||||
return types.AuthConfig{Username: "stageuser", Password: "staging-oauth-token", ServerAddress: stagingRegistryHost}
|
||||
}
|
||||
|
||||
type testProvider struct {
|
||||
*provider
|
||||
fallback *recordingFallback
|
||||
staging *fakeHub
|
||||
}
|
||||
|
||||
func newTestProvider(hub hubClient) *testProvider {
|
||||
tp := &testProvider{
|
||||
fallback: &recordingFallback{},
|
||||
staging: &fakeHub{sessionErr: dockerhub.ErrNoDefaultProfile},
|
||||
}
|
||||
tp.provider = &provider{
|
||||
fallback: tp.fallback,
|
||||
hub: &session{name: "Docker Hub", hub: hub},
|
||||
staging: &session{name: "Docker Hub staging", hub: tp.staging},
|
||||
}
|
||||
return tp
|
||||
}
|
||||
|
||||
func (tp *testProvider) signInToStaging() {
|
||||
tp.staging.sessionErr = nil
|
||||
tp.staging.session = hubSession("stageuser", inAnHour())
|
||||
tp.staging.session.AccessToken = "staging-oauth-token"
|
||||
}
|
||||
|
||||
func connectError(reason seclient.ConnectReason) error {
|
||||
return &seclient.ConnectError{
|
||||
Reason: reason,
|
||||
SocketPath: "/run/engine.sock",
|
||||
Err: errors.New("dial unix /run/engine.sock"),
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewDesktopUsesSeparateSessions(t *testing.T) {
|
||||
t.Parallel()
|
||||
p := NewDesktop(&recordingFallback{}).(*provider)
|
||||
assert.Assert(t, p.hub != p.staging, "production and staging sessions must be separate")
|
||||
}
|
||||
|
||||
func TestDockerHubUsesDesktopSession(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, host := range []string{"docker.io", "index.docker.io", "registry-1.docker.io", registry.IndexServer} {
|
||||
t.Run(host, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
hub := &fakeHub{session: hubSession("hubuser", inAnHour())}
|
||||
p := newTestProvider(hub)
|
||||
|
||||
got, err := p.GetAuthConfig(host)
|
||||
assert.NilError(t, err)
|
||||
assert.DeepEqual(t, got, desktopCredentials())
|
||||
assert.Equal(t, got.IdentityToken, "", "an IdentityToken is sent as a refresh token")
|
||||
assert.Equal(t, len(p.fallback.hosts), 0)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOtherRegistriesUseFallback(t *testing.T) {
|
||||
t.Parallel()
|
||||
hosts := []string{
|
||||
"ghcr.io", "localhost:5000", "registry.example.com", "",
|
||||
stagingRegistryHost + ".example.com", "example.com/" + stagingRegistryHost,
|
||||
dhiRegistryHost + ".example.com", "example.com/" + dhiRegistryHost, "registry." + dhiRegistryHost,
|
||||
"docker.io.example.com",
|
||||
}
|
||||
for _, host := range hosts {
|
||||
t.Run(host, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
hub := &fakeHub{session: hubSession("hubuser", inAnHour())}
|
||||
p := newTestProvider(hub)
|
||||
p.signInToStaging()
|
||||
|
||||
got, err := p.GetAuthConfig(host)
|
||||
assert.NilError(t, err)
|
||||
assert.DeepEqual(t, got, cliCredentials(host))
|
||||
assert.DeepEqual(t, p.fallback.hosts, []string{host})
|
||||
assert.Equal(t, hub.sessionCalls.Load(), int32(0), "Docker Desktop must only be contacted for Docker Hub")
|
||||
assert.Equal(t, p.staging.sessionCalls.Load(), int32(0), "Docker Desktop must only be contacted for Docker Hub")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDHIUsesDockerHubSession(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, host := range []string{dhiRegistryHost, "https://" + dhiRegistryHost, "https://" + dhiRegistryHost + "/"} {
|
||||
t.Run(host, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
hub := &fakeHub{session: hubSession("hubuser", inAnHour())}
|
||||
p := newTestProvider(hub)
|
||||
p.signInToStaging()
|
||||
|
||||
want := desktopCredentials()
|
||||
want.ServerAddress = dhiRegistryHost
|
||||
got, err := p.GetAuthConfig(host)
|
||||
assert.NilError(t, err)
|
||||
assert.DeepEqual(t, got, want)
|
||||
assert.Equal(t, p.staging.sessionCalls.Load(), int32(0), "the staging session must not be read for DHI")
|
||||
|
||||
got, err = p.GetAuthConfig("docker.io")
|
||||
assert.NilError(t, err)
|
||||
assert.DeepEqual(t, got, desktopCredentials())
|
||||
assert.Equal(t, hub.sessionCalls.Load(), int32(1))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStagingRegistryUsesStagingSession(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, host := range []string{stagingRegistryHost, "https://" + stagingRegistryHost + "/"} {
|
||||
t.Run(host, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
hub := &fakeHub{session: hubSession("hubuser", inAnHour())}
|
||||
p := newTestProvider(hub)
|
||||
p.signInToStaging()
|
||||
|
||||
got, err := p.GetAuthConfig(host)
|
||||
assert.NilError(t, err)
|
||||
assert.DeepEqual(t, got, stagingCredentials())
|
||||
assert.Equal(t, hub.sessionCalls.Load(), int32(0), "the production session must not be read for the staging registry")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnvironmentsDoNotMix(t *testing.T) {
|
||||
t.Parallel()
|
||||
t.Run("staging session is not used for docker.io", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
p := newTestProvider(&fakeHub{sessionErr: dockerhub.ErrNoDefaultProfile})
|
||||
p.signInToStaging()
|
||||
|
||||
got, err := p.GetAuthConfig("docker.io")
|
||||
assert.NilError(t, err)
|
||||
assert.DeepEqual(t, got, cliCredentials("docker.io"))
|
||||
|
||||
got, err = p.GetAuthConfig(stagingRegistryHost)
|
||||
assert.NilError(t, err)
|
||||
assert.DeepEqual(t, got, stagingCredentials())
|
||||
})
|
||||
|
||||
t.Run("production session is not used for the staging registry", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
p := newTestProvider(&fakeHub{session: hubSession("hubuser", inAnHour())})
|
||||
|
||||
got, err := p.GetAuthConfig(stagingRegistryHost)
|
||||
assert.NilError(t, err)
|
||||
assert.DeepEqual(t, got, cliCredentials(stagingRegistryHost))
|
||||
|
||||
got, err = p.GetAuthConfig("docker.io")
|
||||
assert.NilError(t, err)
|
||||
assert.DeepEqual(t, got, desktopCredentials())
|
||||
})
|
||||
}
|
||||
|
||||
func TestUsernameFromProfile(t *testing.T) {
|
||||
t.Parallel()
|
||||
hub := &fakeHub{
|
||||
session: hubSession("", inAnHour()),
|
||||
profile: dockerhub.Profile{Username: "hubuser"},
|
||||
}
|
||||
p := newTestProvider(hub)
|
||||
|
||||
got, err := p.GetAuthConfig("docker.io")
|
||||
assert.NilError(t, err)
|
||||
assert.DeepEqual(t, got, desktopCredentials())
|
||||
}
|
||||
|
||||
func TestFallsBackWithoutUsableSession(t *testing.T) {
|
||||
t.Parallel()
|
||||
tests := []struct {
|
||||
name string
|
||||
hub func() *fakeHub
|
||||
}{
|
||||
{name: "secrets engine not running", hub: func() *fakeHub { return &fakeHub{sessionErr: connectError(seclient.ReasonNotRunning)} }},
|
||||
{name: "secrets engine socket not accessible", hub: func() *fakeHub { return &fakeHub{sessionErr: connectError(seclient.ReasonPermissionDenied)} }},
|
||||
{name: "nobody signed in", hub: func() *fakeHub { return &fakeHub{sessionErr: dockerhub.ErrNoDefaultProfile} }},
|
||||
{name: "access denied", hub: func() *fakeHub { return &fakeHub{sessionErr: seclient.ErrAccessDenied} }},
|
||||
{name: "no username", hub: func() *fakeHub { return &fakeHub{session: hubSession("", inAnHour())} }},
|
||||
{name: "profile lookup fails", hub: func() *fakeHub {
|
||||
return &fakeHub{session: hubSession("", inAnHour()), profileErr: errors.New("boom")}
|
||||
}},
|
||||
{name: "expired", hub: func() *fakeHub { return &fakeHub{session: hubSession("hubuser", time.Now().Add(-time.Minute))} }},
|
||||
{name: "expires within the skew", hub: func() *fakeHub {
|
||||
return &fakeHub{session: hubSession("hubuser", time.Now().Add(sessionExpirySkew/2))}
|
||||
}},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
hub := tc.hub()
|
||||
p := newTestProvider(hub)
|
||||
|
||||
for range 2 {
|
||||
got, err := p.GetAuthConfig("docker.io")
|
||||
assert.NilError(t, err)
|
||||
assert.DeepEqual(t, got, cliCredentials("docker.io"))
|
||||
}
|
||||
assert.Equal(t, hub.sessionCalls.Load(), int32(1), "Desktop must not be asked again within retryInterval")
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDesktopFallsBackWhenClientCannotBeCreated(t *testing.T) {
|
||||
t.Parallel()
|
||||
var connects atomic.Int32
|
||||
s := &session{
|
||||
name: "Docker Hub",
|
||||
connect: func() (hubClient, error) {
|
||||
connects.Add(1)
|
||||
return nil, errors.New("no socket path")
|
||||
},
|
||||
}
|
||||
p := &provider{fallback: &recordingFallback{}, hub: s}
|
||||
|
||||
for range 2 {
|
||||
got, err := p.GetAuthConfig("docker.io")
|
||||
assert.NilError(t, err)
|
||||
assert.DeepEqual(t, got, cliCredentials("docker.io"))
|
||||
}
|
||||
assert.Equal(t, connects.Load(), int32(1))
|
||||
}
|
||||
|
||||
func TestRetriesAfterInterval(t *testing.T) {
|
||||
t.Parallel()
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
hub := &fakeHub{sessionErr: dockerhub.ErrNoDefaultProfile}
|
||||
p := newTestProvider(hub)
|
||||
|
||||
got, err := p.GetAuthConfig("docker.io")
|
||||
assert.NilError(t, err)
|
||||
assert.DeepEqual(t, got, cliCredentials("docker.io"))
|
||||
|
||||
hub.sessionErr = nil
|
||||
hub.session = hubSession("hubuser", inAnHour())
|
||||
|
||||
time.Sleep(retryInterval - time.Second)
|
||||
got, err = p.GetAuthConfig("docker.io")
|
||||
assert.NilError(t, err)
|
||||
assert.DeepEqual(t, got, cliCredentials("docker.io"))
|
||||
assert.Equal(t, hub.sessionCalls.Load(), int32(1))
|
||||
|
||||
time.Sleep(time.Second)
|
||||
got, err = p.GetAuthConfig("docker.io")
|
||||
assert.NilError(t, err)
|
||||
assert.DeepEqual(t, got, desktopCredentials())
|
||||
assert.Equal(t, hub.sessionCalls.Load(), int32(2))
|
||||
})
|
||||
}
|
||||
|
||||
func TestReusesSessionUntilExpiry(t *testing.T) {
|
||||
t.Parallel()
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
expiresAt := time.Now().Add(10 * time.Minute)
|
||||
hub := &fakeHub{session: hubSession("hubuser", expiresAt)}
|
||||
p := newTestProvider(hub)
|
||||
|
||||
got, err := p.GetAuthConfig("docker.io")
|
||||
assert.NilError(t, err)
|
||||
assert.DeepEqual(t, got, desktopCredentials())
|
||||
time.Sleep(5 * time.Minute)
|
||||
got, err = p.GetAuthConfig("docker.io")
|
||||
assert.NilError(t, err)
|
||||
assert.DeepEqual(t, got, desktopCredentials())
|
||||
assert.Equal(t, hub.sessionCalls.Load(), int32(1))
|
||||
|
||||
hub.session = hubSession("hubuser", expiresAt.Add(time.Hour))
|
||||
hub.session.AccessToken = "refreshed-token"
|
||||
time.Sleep(5*time.Minute - sessionExpirySkew)
|
||||
|
||||
got, err = p.GetAuthConfig("docker.io")
|
||||
assert.NilError(t, err)
|
||||
assert.Equal(t, got.Password, "refreshed-token")
|
||||
assert.Equal(t, hub.sessionCalls.Load(), int32(2))
|
||||
})
|
||||
}
|
||||
|
||||
func TestRefetchesSessionWithoutExpiry(t *testing.T) {
|
||||
t.Parallel()
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
hub := &fakeHub{session: hubSession("hubuser", time.Time{})}
|
||||
p := newTestProvider(hub)
|
||||
|
||||
got, err := p.GetAuthConfig("docker.io")
|
||||
assert.NilError(t, err)
|
||||
assert.DeepEqual(t, got, desktopCredentials())
|
||||
time.Sleep(sessionTTL - time.Second)
|
||||
got, err = p.GetAuthConfig("docker.io")
|
||||
assert.NilError(t, err)
|
||||
assert.DeepEqual(t, got, desktopCredentials())
|
||||
assert.Equal(t, hub.sessionCalls.Load(), int32(1))
|
||||
|
||||
time.Sleep(time.Second)
|
||||
got, err = p.GetAuthConfig("docker.io")
|
||||
assert.NilError(t, err)
|
||||
assert.DeepEqual(t, got, desktopCredentials())
|
||||
assert.Equal(t, hub.sessionCalls.Load(), int32(2))
|
||||
})
|
||||
}
|
||||
|
||||
func TestConcurrentLookupsShareOneFetch(t *testing.T) {
|
||||
t.Parallel()
|
||||
hub := &fakeHub{session: hubSession("hubuser", inAnHour())}
|
||||
p := newTestProvider(hub)
|
||||
|
||||
const lookups = 20
|
||||
results := make([]types.AuthConfig, lookups)
|
||||
var wg sync.WaitGroup
|
||||
for i := range lookups {
|
||||
wg.Go(func() {
|
||||
auth, err := p.GetAuthConfig("docker.io")
|
||||
assert.Check(t, err)
|
||||
results[i] = auth
|
||||
})
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
for _, auth := range results {
|
||||
assert.DeepEqual(t, auth, desktopCredentials())
|
||||
}
|
||||
assert.Equal(t, hub.sessionCalls.Load(), int32(1))
|
||||
}
|
||||
|
||||
func TestConnectSkipsMissingSocket(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := connect(filepath.Join(t.TempDir(), "engine.sock"))
|
||||
assert.Assert(t, errors.Is(err, fs.ErrNotExist), err)
|
||||
assert.Assert(t, isSessionUnavailable(err))
|
||||
}
|
||||
|
||||
func TestIsSessionUnavailable(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, err := range []error{
|
||||
connectError(seclient.ReasonNotRunning),
|
||||
fmt.Errorf("get session: %w", connectError(seclient.ReasonNotRunning)),
|
||||
connectError(seclient.ReasonPermissionDenied),
|
||||
connectError(seclient.ReasonTimeout),
|
||||
connectError(seclient.ReasonUnknown),
|
||||
dockerhub.ErrNoSession,
|
||||
dockerhub.ErrNoDefaultProfile,
|
||||
errSessionExpired,
|
||||
fs.ErrNotExist,
|
||||
} {
|
||||
assert.Assert(t, isSessionUnavailable(err), err)
|
||||
}
|
||||
for _, err := range []error{
|
||||
seclient.ErrAccessDenied,
|
||||
errors.New("no username for the session"),
|
||||
context.DeadlineExceeded,
|
||||
} {
|
||||
assert.Assert(t, !isSessionUnavailable(err), err)
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
manifeststore "github.com/docker/cli/cli/manifest/store"
|
||||
"github.com/docker/cli/cli/streams"
|
||||
"github.com/docker/cli/internal/registryclient"
|
||||
"github.com/docker/cli/internal/secretsengine"
|
||||
"github.com/moby/moby/client"
|
||||
)
|
||||
|
||||
@@ -176,6 +177,10 @@ func (c *FakeCli) SetRegistryClient(registryClient registryclient.RegistryClient
|
||||
c.registryClient = registryClient
|
||||
}
|
||||
|
||||
func (c *FakeCli) RegistryAuthProvider() secretsengine.AuthProvider {
|
||||
return c.configfile
|
||||
}
|
||||
|
||||
// BuildKitEnabled on the fake cli
|
||||
func (*FakeCli) BuildKitEnabled() (bool, error) {
|
||||
return true, nil
|
||||
|
||||
+14
-6
@@ -83,16 +83,24 @@ set as the **URL**, the repository is cloned locally and then sent as the contex
|
||||
Add a line to /etc/hosts. The format is hostname=ip, or hostname:ip.
|
||||
The **--add-host** option can be set multiple times.
|
||||
|
||||
**--build-arg** *variable*
|
||||
name and value of a **buildarg**.
|
||||
**--build-arg** *name*[=*value*]
|
||||
Set a build-time variable. The *name* is the variable name, as declared
|
||||
by an `ARG` instruction in the Dockerfile. If *value* is omitted, the
|
||||
value of the variable *name* from the local environment is used; if it is
|
||||
not set there either, the default from the `ARG` instruction (if any) is
|
||||
used.
|
||||
|
||||
Build-time variables are available as environment variables to commands
|
||||
run by the Dockerfile's `RUN` instructions, and can be used for variable
|
||||
expansion in other Dockerfile instructions. An environment variable defined
|
||||
by an `ENV` instruction always overrides an `ARG` of the same name. This
|
||||
mechanism is not meant for passing secret values.
|
||||
|
||||
For example, if you want to pass a value for `http_proxy`, use
|
||||
`--build-arg=http_proxy="http://some.proxy.url"`
|
||||
|
||||
Users pass these values at build-time. Docker uses the `buildargs` as the
|
||||
environment context for command(s) run via the Dockerfile's `RUN` instruction
|
||||
or for variable expansion in other Dockerfile instructions. This is not meant
|
||||
for passing secret values. [Read more about the buildargs instruction](https://docs.docker.com/engine/reference/builder/#arg)
|
||||
See also https://docs.docker.com/reference/dockerfile/#arg and
|
||||
https://docs.docker.com/reference/cli/docker/buildx/build/#build-arg.
|
||||
|
||||
**--cache-from** ""
|
||||
Set image that will be used as a build cache source.
|
||||
|
||||
@@ -42,6 +42,11 @@ setup() {
|
||||
sh e2e/testdata/registry/certs/gen-certs.sh
|
||||
fi
|
||||
|
||||
# ENGINE_VERSION=master tests against moby/moby-bin:master.
|
||||
if [ "${ENGINE_VERSION:-}" = "master" ]; then
|
||||
export ENGINE_SOURCE=moby-bin
|
||||
fi
|
||||
|
||||
COMPOSE_PROJECT_NAME=$project COMPOSE_FILE=$file docker compose up --build -d >&2
|
||||
|
||||
# Ensure supporting services exist before running tests. If one fails to start,
|
||||
|
||||
+23
-20
@@ -4,7 +4,7 @@ module github.com/docker/cli
|
||||
// There is no 'go.mod' file, as that would imply opting in for all the rules
|
||||
// around SemVer, which this repo cannot abide by as it uses CalVer.
|
||||
|
||||
go 1.26
|
||||
go 1.26.10
|
||||
|
||||
tool (
|
||||
github.com/cpuguy83/go-md2man/v2 // for scripts/docs/generate-man.sh
|
||||
@@ -15,26 +15,28 @@ require (
|
||||
dario.cat/mergo v1.0.2
|
||||
github.com/containerd/errdefs v1.0.0
|
||||
github.com/containerd/errdefs/pkg v0.3.0
|
||||
github.com/containerd/log v0.1.0
|
||||
github.com/containerd/log v0.2.0
|
||||
github.com/containerd/platforms v1.0.0-rc.5
|
||||
github.com/creack/pty v1.1.24
|
||||
github.com/distribution/reference v0.6.0
|
||||
github.com/docker/cli-docs-tool v0.11.0
|
||||
github.com/docker/distribution v2.8.3+incompatible
|
||||
github.com/docker/docker-credential-helpers v0.9.9
|
||||
github.com/docker/go-connections v0.8.1
|
||||
github.com/docker/go-connections v0.8.2
|
||||
github.com/docker/go-units v0.5.0
|
||||
github.com/fvbommel/sortorder v1.2.0
|
||||
github.com/docker/secrets-engine/client v0.2.2
|
||||
github.com/docker/secrets-engine/x v0.8.5
|
||||
github.com/fvbommel/sortorder v1.2.1
|
||||
github.com/go-jose/go-jose/v4 v4.1.5
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0
|
||||
github.com/gogo/protobuf v1.3.2
|
||||
github.com/google/go-cmp v0.7.0
|
||||
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/mattn/go-runewidth v0.0.28
|
||||
github.com/mattn/go-runewidth v0.0.31
|
||||
github.com/moby/go-archive v0.3.3
|
||||
github.com/moby/moby/api v1.56.0
|
||||
github.com/moby/moby/client v0.6.0
|
||||
github.com/moby/moby/api v1.56.1
|
||||
github.com/moby/moby/client v0.6.2
|
||||
github.com/moby/patternmatcher v0.6.1
|
||||
github.com/moby/swarmkit/v2 v2.1.2
|
||||
github.com/moby/sys/atomicwriter v0.1.0
|
||||
@@ -61,21 +63,22 @@ require (
|
||||
go.opentelemetry.io/otel/sdk/metric v1.46.0
|
||||
go.opentelemetry.io/otel/trace v1.46.0
|
||||
go.yaml.in/yaml/v3 v3.0.5
|
||||
golang.org/x/sync v0.22.0
|
||||
golang.org/x/sys v0.47.0
|
||||
golang.org/x/term v0.45.0
|
||||
golang.org/x/text v0.41.0
|
||||
golang.org/x/sync v0.23.0
|
||||
golang.org/x/sys v0.48.0
|
||||
golang.org/x/term v0.46.0
|
||||
golang.org/x/text v0.42.0
|
||||
gotest.tools/v3 v3.5.2
|
||||
tags.cncf.io/container-device-interface v1.1.0
|
||||
tags.cncf.io/container-device-interface v1.1.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect
|
||||
github.com/Microsoft/go-winio v0.6.2 // indirect
|
||||
connectrpc.com/connect v1.19.1 // indirect
|
||||
github.com/Azure/go-ansiterm v0.0.0-20261006220739-8c912ac31dd4 // indirect
|
||||
github.com/Microsoft/go-winio v0.6.3 // indirect
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/clipperhouse/uax29/v2 v2.2.0 // indirect
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect
|
||||
github.com/docker/go-events v0.1.0 // indirect
|
||||
github.com/docker/go-metrics v0.1.0 // indirect
|
||||
@@ -87,10 +90,10 @@ require (
|
||||
github.com/gorilla/mux v1.8.1 // indirect
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/klauspost/compress v1.19.2 // indirect
|
||||
github.com/klauspost/compress v1.20.1 // indirect
|
||||
github.com/moby/docker-image-spec v1.3.1 // indirect
|
||||
github.com/moby/sys/user v0.4.1 // indirect
|
||||
github.com/moby/sys/userns v0.2.0 // indirect
|
||||
github.com/moby/sys/userns v0.2.1 // indirect
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||
github.com/prometheus/client_golang v1.22.0 // indirect
|
||||
github.com/prometheus/client_model v0.6.1 // indirect
|
||||
@@ -101,9 +104,9 @@ require (
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.46.0 // indirect
|
||||
go.opentelemetry.io/proto/otlp v1.11.0 // indirect
|
||||
golang.org/x/mod v0.40.0 // indirect
|
||||
golang.org/x/net v0.58.0 // indirect
|
||||
golang.org/x/time v0.15.0 // indirect
|
||||
golang.org/x/mod v0.41.0 // indirect
|
||||
golang.org/x/net v0.60.0 // indirect
|
||||
golang.org/x/time v0.16.0 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260825221802-da73d73af1c5 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260825221802-da73d73af1c5 // indirect
|
||||
google.golang.org/grpc v1.83.2 // indirect
|
||||
|
||||
+44
-39
@@ -1,25 +1,27 @@
|
||||
connectrpc.com/connect v1.19.1 h1:R5M57z05+90EfEvCY1b7hBxDVOUl45PrtXtAV2fOC14=
|
||||
connectrpc.com/connect v1.19.1/go.mod h1:tN20fjdGlewnSFeZxLKb0xwIZ6ozc3OQs2hTXy4du9w=
|
||||
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
|
||||
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
|
||||
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk=
|
||||
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8=
|
||||
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg=
|
||||
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
|
||||
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||
github.com/Azure/go-ansiterm v0.0.0-20261006220739-8c912ac31dd4 h1:m2xcjRABtG288vnlV3U/PKh/HnPURxZp50RKNUwBxM8=
|
||||
github.com/Azure/go-ansiterm v0.0.0-20261006220739-8c912ac31dd4/go.mod h1:MTUE7ZuZO89FAtAwozGg6AWD83pcdefnEq5gF82MB8s=
|
||||
github.com/Microsoft/go-winio v0.6.3 h1:M/52tsoyTfwaxajLnXZm7PqkbMGRH8iSWP+8fcg9MQ4=
|
||||
github.com/Microsoft/go-winio v0.6.3/go.mod h1:ma0QpxizD4fyyzJ1C7MIblymTD2Nxz5Mho/3CepwIZg=
|
||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
|
||||
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/clipperhouse/uax29/v2 v2.2.0 h1:ChwIKnQN3kcZteTXMgb1wztSgaU+ZemkgWdohwgs8tY=
|
||||
github.com/clipperhouse/uax29/v2 v2.2.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
|
||||
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
|
||||
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
|
||||
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
|
||||
github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
|
||||
github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
|
||||
github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
|
||||
github.com/containerd/log v0.2.0 h1:BewD/umNgVnoczglOpX8eRMyEy5t5iPlu5AIpnWDONc=
|
||||
github.com/containerd/log v0.2.0/go.mod h1:/M7L7CXKcPTfNC74XzaK+5H5KbO5+4lJVpuVI6vRLoM=
|
||||
github.com/containerd/platforms v1.0.0-rc.5 h1:vXd569rDrz8LeMXzAnBsy6LADV5YtsD8oyaRarxdmSU=
|
||||
github.com/containerd/platforms v1.0.0-rc.5/go.mod h1:lKlMXyLybmBedS/JJm11uDofzI8L2v0J2ZbYvNsbq1A=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
@@ -37,8 +39,8 @@ github.com/docker/distribution v2.8.3+incompatible h1:AtKxIZ36LoNK51+Z6RpzLpddBi
|
||||
github.com/docker/distribution v2.8.3+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w=
|
||||
github.com/docker/docker-credential-helpers v0.9.9 h1:BkydjIgZ46JnDbqyM2p2fc63KMw6y+KHL3Em/2AGJ7w=
|
||||
github.com/docker/docker-credential-helpers v0.9.9/go.mod h1:v1S+hepowrQXITkEfw6o4+BMbGot02wiKpzWhGUZK6c=
|
||||
github.com/docker/go-connections v0.8.1 h1:JibmG5hULs5qXSr/cp/w3Pw5fZuStt4MOHMUExb29/M=
|
||||
github.com/docker/go-connections v0.8.1/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q=
|
||||
github.com/docker/go-connections v0.8.2 h1:9d9DEokx5W7qlr0shkFXIxEA7+TTD+llwzcykSlxMNc=
|
||||
github.com/docker/go-connections v0.8.2/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q=
|
||||
github.com/docker/go-events v0.1.0 h1:J8VX4H7Ta8mmBhv/K/24x1kosKOrEEnYmGjcdFHTOW0=
|
||||
github.com/docker/go-events v0.1.0/go.mod h1:jwVwMgySJX4PiukIIRrind1wPGU8QhPa8aTEPkreUj4=
|
||||
github.com/docker/go-metrics v0.1.0 h1:r76KPNpstz+IvQKSWpYegSkkyzex0V3A1ZGVx6bhGlY=
|
||||
@@ -47,10 +49,14 @@ github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4
|
||||
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
|
||||
github.com/docker/libtrust v0.0.0-20160708172513-aabc10ec26b7 h1:UhxFibDNY/bfvqU5CAUmr9zpesgbU6SWc8/B4mflAE4=
|
||||
github.com/docker/libtrust v0.0.0-20160708172513-aabc10ec26b7/go.mod h1:cyGadeNEkKy96OOhEzfZl+yxihPEzKnqJwvfuSUqbZE=
|
||||
github.com/docker/secrets-engine/client v0.2.2 h1:aPvmlzsxNA+C+xgp2F+WiiDbCSAXHC591HscFFOvPOE=
|
||||
github.com/docker/secrets-engine/client v0.2.2/go.mod h1:c3l/N+fA0kiy9S8BE3kKrO5hOZu4+SLYZK9Jx+jc2cw=
|
||||
github.com/docker/secrets-engine/x v0.8.5 h1:P7D/YESMjpX18S4ratBgoeJkAFIxgsPxe093uFmgiGc=
|
||||
github.com/docker/secrets-engine/x v0.8.5/go.mod h1:jQ5CXCYCPn8Sw96TS8Ufq3oyIGH2GzUtVXIdwa2Q0VU=
|
||||
github.com/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeORc=
|
||||
github.com/felixge/httpsnoop v1.1.0/go.mod h1:Zqxgdd+1Rkcz8euOqdr7lqgCRJztwr5hp9vDSi5UZCE=
|
||||
github.com/fvbommel/sortorder v1.2.0 h1:TRIiRiGX+djh3Yf4FVxmWmAcYfIr5dH0NbzJWOSAWZk=
|
||||
github.com/fvbommel/sortorder v1.2.0/go.mod h1:LbhO04ijZIeUuvz9B9BkI/qYrpZZEn1gWhxv4QjUKVs=
|
||||
github.com/fvbommel/sortorder v1.2.1 h1:ASc85RbthwLEqzuiCk7WQQiopfyGWQiEYZVJ3Qa4A+U=
|
||||
github.com/fvbommel/sortorder v1.2.1/go.mod h1:LbhO04ijZIeUuvz9B9BkI/qYrpZZEn1gWhxv4QjUKVs=
|
||||
github.com/go-jose/go-jose/v4 v4.1.5 h1:RjgjO2LOtWOJKUC5wpwY9LR3B3vwVAz6JS2YHfYU6eA=
|
||||
github.com/go-jose/go-jose/v4 v4.1.5/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
|
||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
@@ -78,20 +84,20 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
|
||||
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||
github.com/klauspost/compress v1.19.2 h1:hMRETovs/pu/dVWN7zIT1PGG8t509MwT6bO7XSi26R8=
|
||||
github.com/klauspost/compress v1.19.2/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||
github.com/klauspost/compress v1.20.1 h1:T7kKElXUMXrUJ2E9QhQhxFtcK5rPyLdsGZvdbLMPdiQ=
|
||||
github.com/klauspost/compress v1.20.1/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
|
||||
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||
github.com/mattn/go-runewidth v0.0.28 h1:rPyg2ybwEKPebvpzVWe1gKBkH8EQFkxO4Y0hjBeLaBU=
|
||||
github.com/mattn/go-runewidth v0.0.28/go.mod h1:3qAiGCV4Koz/yuveO58qUefmUTRm8r0IGEXZ9jeHp/8=
|
||||
github.com/mattn/go-runewidth v0.0.31 h1:1oCV4ReTlosDBc6rYRw5cL8S/4y8iv5oRl4SJ5IBgwo=
|
||||
github.com/mattn/go-runewidth v0.0.31/go.mod h1:3qAiGCV4Koz/yuveO58qUefmUTRm8r0IGEXZ9jeHp/8=
|
||||
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
|
||||
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
|
||||
github.com/moby/go-archive v0.3.3 h1:OxxR9paxsluYi+zDUEXTTaIxtkK3viymW+Ka7vRhhME=
|
||||
github.com/moby/go-archive v0.3.3/go.mod h1:Npdv43fFqlhZW7Xo8fbm3ZMYFvAGNviUPqX21VERbcE=
|
||||
github.com/moby/moby/api v1.56.0 h1:GQzua3NA599ASSIICx0iFgiJeO9YkdDARvQsm23ZZuQ=
|
||||
github.com/moby/moby/api v1.56.0/go.mod h1:sZ+THbVWkjOmBPPfbnzdD/G1LuIexWhqlSHHPTDQ1Uk=
|
||||
github.com/moby/moby/client v0.6.0 h1:AJjEB21QPbXSXjDsZorFBoDZPhMrfbpaPLgSMAW9Bgs=
|
||||
github.com/moby/moby/client v0.6.0/go.mod h1:OCo00wNRyA3m4lmJ228W3JbyCN4ZNNYjpOXiJydBdcQ=
|
||||
github.com/moby/moby/api v1.56.1 h1:PpWkvVPB7Fr/No8w+TfyJ/I6rWZ2YPZhT3JorIg+06c=
|
||||
github.com/moby/moby/api v1.56.1/go.mod h1:sZ+THbVWkjOmBPPfbnzdD/G1LuIexWhqlSHHPTDQ1Uk=
|
||||
github.com/moby/moby/client v0.6.2 h1:ekSPlMccrZGcw6Scb8T9D39qE0mB+XupU/yS0zNw9N4=
|
||||
github.com/moby/moby/client v0.6.2/go.mod h1:DGcnO7qmPJrqobx/ggQgQ/AUk+2wgVdQv7uxB0/mEsc=
|
||||
github.com/moby/patternmatcher v0.6.1 h1:qlhtafmr6kgMIJjKJMDmMWq7WLkKIo23hsrpR3x084U=
|
||||
github.com/moby/patternmatcher v0.6.1/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc=
|
||||
github.com/moby/swarmkit/v2 v2.1.2 h1:1WDZAI6HVYNKdCG4zlXnTAPyLsLwuhRGWlHoOUf5Z6I=
|
||||
@@ -112,8 +118,8 @@ github.com/moby/sys/symlink v0.3.0 h1:GZX89mEZ9u53f97npBy4Rc3vJKj7JBDj/PN2I22GrN
|
||||
github.com/moby/sys/symlink v0.3.0/go.mod h1:3eNdhduHmYPcgsJtZXW1W4XUJdZGBIkttZ8xKqPUJq0=
|
||||
github.com/moby/sys/user v0.4.1 h1:RgjRlaDKi/Xmyrz4t8lyzXT6v2ooFeO/7xtchmhVWE0=
|
||||
github.com/moby/sys/user v0.4.1/go.mod h1:E9QsW5WRe1kUAf7kW8hXKwu1uhsZEAdPLYHYSDudF4Y=
|
||||
github.com/moby/sys/userns v0.2.0 h1:nEtDtp7NCV/6dutSklNe8FrENPwFdc4mXnZqC/JWgXM=
|
||||
github.com/moby/sys/userns v0.2.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28=
|
||||
github.com/moby/sys/userns v0.2.1 h1:4OvdM7BcPkASbuouHsbW3aeMJSFlYDldBRnXVZhaRk8=
|
||||
github.com/moby/sys/userns v0.2.1/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28=
|
||||
github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ=
|
||||
github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc=
|
||||
github.com/morikuni/aec v1.1.0 h1:vBBl0pUnvi/Je71dsRrhMBtreIqNMYErSAbEeb8jrXQ=
|
||||
@@ -187,34 +193,33 @@ golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8U
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.40.0 h1:hUv+3cXcdRHz08UmSiOob7sadHig73uo5bkXxQ/tvUs=
|
||||
golang.org/x/mod v0.40.0/go.mod h1:0/weTWkPWGBikyTWAX3dkjVztMmBA5hM0DH6BElSupE=
|
||||
golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c=
|
||||
golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||
golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
|
||||
golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
|
||||
golang.org/x/net v0.60.0 h1:79p50tfZlm0J9YfoDsSi639qSXNGVwEzOPLCxM2FsYU=
|
||||
golang.org/x/net v0.60.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
|
||||
golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
|
||||
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
|
||||
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
|
||||
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
||||
golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE=
|
||||
golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
|
||||
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
|
||||
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
||||
golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
|
||||
golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
|
||||
golang.org/x/time v0.16.0 h1:vMb6ptszcQMkcwiRTAuNNU50gom6++Q/6gY2hDM6VDE=
|
||||
golang.org/x/time v0.16.0/go.mod h1:rVKOqvZeKvrDKTQiAHJ7wmwP0RzleSphoEA9RcdLA0s=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||
@@ -238,5 +243,5 @@ gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
|
||||
gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=
|
||||
pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk=
|
||||
pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04=
|
||||
tags.cncf.io/container-device-interface v1.1.0 h1:RnxNhxF1JOu6CJUVpetTYvrXHdxw9j9jFYgZpI+anSY=
|
||||
tags.cncf.io/container-device-interface v1.1.0/go.mod h1:76Oj0Yqp9FwTx/pySDc8Bxjpg+VqXfDb50cKAXVJ34Q=
|
||||
tags.cncf.io/container-device-interface v1.1.1 h1:YPwQz4xg8PlQ0yT/baR0BtLpTQROe4l6M1yuRgAu1vc=
|
||||
tags.cncf.io/container-device-interface v1.1.1/go.mod h1:S1PSJWYPD4Iom0/39mvr/VVFCfG0Yt14j20d0OYrY2M=
|
||||
|
||||
Generated
+5
@@ -0,0 +1,5 @@
|
||||
/.tmp/
|
||||
*.pprof
|
||||
*.svg
|
||||
cover.out
|
||||
connect.test
|
||||
+130
@@ -0,0 +1,130 @@
|
||||
linters-settings:
|
||||
errcheck:
|
||||
check-type-assertions: true
|
||||
exhaustruct:
|
||||
include:
|
||||
# No zero values for param structs.
|
||||
- 'connectrpc\.com/connect\..*[pP]arams'
|
||||
forbidigo:
|
||||
forbid:
|
||||
- '^fmt\.Print'
|
||||
- '^log\.'
|
||||
- '^print$'
|
||||
- '^println$'
|
||||
- '^panic$'
|
||||
godox:
|
||||
# TODO, OPT, etc. comments are fine to commit. Use FIXME comments for
|
||||
# temporary hacks, and use godox to prevent committing them.
|
||||
keywords: [FIXME]
|
||||
importas:
|
||||
no-unaliased: true
|
||||
alias:
|
||||
- pkg: connectrpc.com/connect/internal/gen/connect/ping/v1
|
||||
alias: pingv1
|
||||
varnamelen:
|
||||
ignore-decls:
|
||||
- T any
|
||||
- i int
|
||||
- wg sync.WaitGroup
|
||||
linters:
|
||||
enable-all: true
|
||||
disable:
|
||||
- cyclop # covered by gocyclo
|
||||
- depguard # unnecessary for small libraries
|
||||
- funlen # rely on code review to limit function length
|
||||
- gocognit # dubious "cognitive overhead" quantification
|
||||
- gofumpt # prefer standard gofmt
|
||||
- goimports # rely on gci instead
|
||||
- inamedparam # convention is not followed
|
||||
- ireturn # "accept interfaces, return structs" isn't ironclad
|
||||
- lll # don't want hard limits for line length
|
||||
- maintidx # covered by gocyclo
|
||||
- mnd # status codes are clearer than constants
|
||||
- nlreturn # generous whitespace violates house style
|
||||
- nonamedreturns # named returns are fine; it's *bare* returns that are bad
|
||||
- protogetter # too many false positives
|
||||
- tenv # replaced by usetesting
|
||||
- testpackage # internal tests are fine
|
||||
- wrapcheck # don't _always_ need to wrap errors
|
||||
- wsl # generous whitespace violates house style
|
||||
issues:
|
||||
exclude-dirs-use-default: false
|
||||
|
||||
exclude:
|
||||
# Don't ban use of fmt.Errorf to create new errors, but the remaining
|
||||
# checks from err113 are useful.
|
||||
- "do not define dynamic errors, use wrapped static errors instead: .*"
|
||||
|
||||
exclude-rules:
|
||||
# If future reflect.Kinds are nil-able, we'll find out when a test fails.
|
||||
- linters: [exhaustive]
|
||||
path: internal/assert/assert.go
|
||||
# We need our duplex HTTP call to have access to the context.
|
||||
- linters: [containedctx]
|
||||
path: duplex_http_call.go
|
||||
# We need to init a global in-mem HTTP server for testable examples.
|
||||
- linters: [gochecknoinits, gochecknoglobals]
|
||||
path: example_init_test.go
|
||||
# We purposefully do an ineffectual assignment for an example.
|
||||
- linters: [ineffassign]
|
||||
path: client_example_test.go
|
||||
# The generated file is effectively a global receiver.
|
||||
- linters: [varnamelen]
|
||||
path: cmd/protoc-gen-connect-go
|
||||
text: "parameter name 'g' is too short"
|
||||
# Thorough error logging and timeout config make this example unreadably long.
|
||||
- linters: [errcheck, gosec]
|
||||
path: error_writer_example_test.go
|
||||
# It should be crystal clear that Connect uses plain *http.Clients.
|
||||
- linters: [revive, stylecheck]
|
||||
path: client_example_test.go
|
||||
# Don't complain about timeout management or lack of output assertions in examples.
|
||||
- linters: [gosec, testableexamples]
|
||||
path: handler_example_test.go
|
||||
# No output assertions needed for these examples.
|
||||
- linters: [testableexamples]
|
||||
path: error_writer_example_test.go
|
||||
- linters: [testableexamples]
|
||||
path: error_not_modified_example_test.go
|
||||
- linters: [testableexamples]
|
||||
path: error_example_test.go
|
||||
# In examples, it's okay to use http.ListenAndServe.
|
||||
- linters: [gosec]
|
||||
path: error_not_modified_example_test.go
|
||||
# There are many instances where we want to keep unused parameters
|
||||
# as a matter of style or convention, for example when a context.Context
|
||||
# is the first parameter, we choose to just globally ignore this.
|
||||
- linters: [revive]
|
||||
text: "^unused-parameter: "
|
||||
# We want to return explicit nils in protocol_grpc.go
|
||||
- linters: [revive]
|
||||
text: "^if-return: "
|
||||
path: protocol_grpc.go
|
||||
# We want to return explicit nils in protocol_connect.go
|
||||
- linters: [revive]
|
||||
text: "^if-return: "
|
||||
path: protocol_connect.go
|
||||
# We want to return explicit nils in error_writer.go
|
||||
- linters: [revive]
|
||||
text: "^if-return: "
|
||||
path: error_writer.go
|
||||
# We want to set http.Server's logger
|
||||
- linters: [forbidigo]
|
||||
path: internal/memhttp
|
||||
text: "use of `log.(New|Logger|Lshortfile)` forbidden by pattern .*"
|
||||
# We want to show examples with http.Get
|
||||
- linters: [noctx]
|
||||
path: internal/memhttp/memhttp_test.go
|
||||
# Allow fmt.Sprintf for cmd/protoc-gen-connect-go for consistency
|
||||
- linters: [perfsprint]
|
||||
path: cmd/protoc-gen-connect-go/main.go
|
||||
# Allow non-canonical headers in tests
|
||||
- linters: [canonicalheader]
|
||||
path: '.*_test.go'
|
||||
# Allow Code pointer receiver for UnmarshalText method
|
||||
- linters: [recvcheck]
|
||||
path: code.go
|
||||
# Avoid false positives for int overflow in tests
|
||||
- linters: [gosec]
|
||||
text: "^G115: integer overflow conversion"
|
||||
path: ".*_test.go"
|
||||
+201
@@ -0,0 +1,201 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright 2021-2025 The Connect Authors
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
Maintainers
|
||||
===========
|
||||
|
||||
## Current
|
||||
* [Peter Edge](https://github.com/bufdev), [Buf](https://buf.build)
|
||||
* [Josh Humphries](https://github.com/jhump), [Buf](https://buf.build)
|
||||
* [Matt Robenolt](https://github.com/mattrobenolt), [PlanetScale](https://planetscale.com)
|
||||
* [Edward McFarlane](https://github.com/emcfarlane), [Buf](https://buf.build)
|
||||
|
||||
## Former
|
||||
* [Akshay Shah](https://github.com/akshayjshah)
|
||||
* [Alex McKinney](https://github.com/amckinney)
|
||||
+121
@@ -0,0 +1,121 @@
|
||||
# See https://tech.davis-hansson.com/p/make/
|
||||
SHELL := bash
|
||||
.DELETE_ON_ERROR:
|
||||
.SHELLFLAGS := -eu -o pipefail -c
|
||||
.DEFAULT_GOAL := all
|
||||
MAKEFLAGS += --warn-undefined-variables
|
||||
MAKEFLAGS += --no-builtin-rules
|
||||
MAKEFLAGS += --no-print-directory
|
||||
BIN := .tmp/bin
|
||||
export PATH := $(abspath $(BIN)):$(PATH)
|
||||
export GOBIN := $(abspath $(BIN))
|
||||
COPYRIGHT_YEARS := 2021-2025
|
||||
LICENSE_IGNORE := --ignore /testdata/
|
||||
BUF_VERSION := 1.50.1
|
||||
|
||||
.PHONY: help
|
||||
help: ## Describe useful make targets
|
||||
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "%-30s %s\n", $$1, $$2}'
|
||||
|
||||
.PHONY: all
|
||||
all: ## Build, test, and lint (default)
|
||||
$(MAKE) test
|
||||
$(MAKE) lint
|
||||
|
||||
.PHONY: clean
|
||||
clean: ## Delete intermediate build artifacts
|
||||
@# -X only removes untracked files, -d recurses into directories, -f actually removes files/dirs
|
||||
git clean -Xdf
|
||||
|
||||
.PHONY: test
|
||||
test: shorttest slowtest
|
||||
|
||||
.PHONY: shorttest
|
||||
shorttest: build ## Run unit tests
|
||||
go test -vet=off -race -cover -short ./...
|
||||
|
||||
.PHONY: slowtest
|
||||
# Runs all tests, including known long/slow ones. The
|
||||
# race detector is not used for a few reasons:
|
||||
# 1. Race coverage of the short tests should be
|
||||
# adequate to catch race conditions.
|
||||
# 2. It slows tests down, which is not good if we
|
||||
# know these are already slow tests.
|
||||
# 3. Some of the slow tests can't repro issues and
|
||||
# find regressions as reliably with the race
|
||||
# detector enabled.
|
||||
slowtest: build
|
||||
go test ./...
|
||||
|
||||
.PHONY: runconformance
|
||||
runconformance: build ## Run conformance test suite
|
||||
cd internal/conformance && ./runconformance.sh
|
||||
|
||||
.PHONY: bench
|
||||
bench: BENCH ?= .*
|
||||
bench: build ## Run benchmarks for root package
|
||||
go test -vet=off -run '^$$' -bench '$(BENCH)' -benchmem -cpuprofile cpu.pprof -memprofile mem.pprof .
|
||||
|
||||
.PHONY: build
|
||||
build: generate ## Build all packages
|
||||
go build ./...
|
||||
|
||||
.PHONY: install
|
||||
install: ## Install all binaries
|
||||
go install ./...
|
||||
|
||||
.PHONY: lint
|
||||
lint: $(BIN)/golangci-lint $(BIN)/buf ## Lint Go and protobuf
|
||||
go vet ./...
|
||||
golangci-lint run --modules-download-mode=readonly --timeout=3m0s
|
||||
buf lint
|
||||
buf format -d --exit-code
|
||||
|
||||
.PHONY: lintfix
|
||||
lintfix: $(BIN)/golangci-lint $(BIN)/buf ## Automatically fix some lint errors
|
||||
golangci-lint run --fix --modules-download-mode=readonly --timeout=3m0s
|
||||
buf format -w
|
||||
|
||||
.PHONY: generate
|
||||
generate: $(BIN)/buf $(BIN)/protoc-gen-go $(BIN)/protoc-gen-connect-go $(BIN)/license-header ## Regenerate code and licenses
|
||||
go mod tidy
|
||||
cd ./internal/conformance && go mod tidy
|
||||
buf generate
|
||||
cd ./cmd/protoc-gen-connect-go/internal && \
|
||||
find ./testdata -maxdepth 1 -type d \( ! -name testdata \) | xargs -n 1 -I % bash -c "cd '%' && buf generate"
|
||||
license-header \
|
||||
--license-type apache \
|
||||
--copyright-holder "The Connect Authors" \
|
||||
--year-range "$(COPYRIGHT_YEARS)" $(LICENSE_IGNORE)
|
||||
|
||||
.PHONY: upgrade
|
||||
upgrade: ## Upgrade dependencies
|
||||
go get -u -t ./... && go mod tidy -v
|
||||
|
||||
.PHONY: checkgenerate
|
||||
checkgenerate:
|
||||
@# Used in CI to verify that `make generate` doesn't produce a diff.
|
||||
test -z "$$(git status --porcelain | tee /dev/stderr)"
|
||||
|
||||
.PHONY: $(BIN)/protoc-gen-connect-go
|
||||
$(BIN)/protoc-gen-connect-go:
|
||||
@mkdir -p $(@D)
|
||||
go build -o $(@) ./cmd/protoc-gen-connect-go
|
||||
|
||||
$(BIN)/buf: Makefile
|
||||
@mkdir -p $(@D)
|
||||
go install github.com/bufbuild/buf/cmd/buf@v${BUF_VERSION}
|
||||
|
||||
$(BIN)/license-header: Makefile
|
||||
@mkdir -p $(@D)
|
||||
go install github.com/bufbuild/buf/private/pkg/licenseheader/cmd/license-header@v${BUF_VERSION}
|
||||
|
||||
$(BIN)/golangci-lint: Makefile
|
||||
@mkdir -p $(@D)
|
||||
go install github.com/golangci/golangci-lint/cmd/golangci-lint@v1.64.7
|
||||
|
||||
$(BIN)/protoc-gen-go: Makefile go.mod
|
||||
@mkdir -p $(@D)
|
||||
@# The version of protoc-gen-go is determined by the version in go.mod
|
||||
go install google.golang.org/protobuf/cmd/protoc-gen-go
|
||||
|
||||
+184
@@ -0,0 +1,184 @@
|
||||
Connect
|
||||
=======
|
||||
|
||||
[](https://github.com/connectrpc/connect-go/actions/workflows/ci.yaml)
|
||||
[](https://goreportcard.com/report/connectrpc.com/connect)
|
||||
[](https://pkg.go.dev/connectrpc.com/connect)
|
||||
[][slack]
|
||||
[](https://www.bestpractices.dev/projects/8972)
|
||||
|
||||
Connect is a slim library for building browser and gRPC-compatible HTTP APIs.
|
||||
You write a short [Protocol Buffer][protobuf] schema and implement your
|
||||
application logic, and Connect generates code to handle marshaling, routing,
|
||||
compression, and content type negotiation. It also generates an idiomatic,
|
||||
type-safe client. Handlers and clients support three protocols: gRPC, gRPC-Web,
|
||||
and Connect's own protocol.
|
||||
|
||||
The [Connect protocol][protocol] is a simple protocol that works over HTTP/1.1
|
||||
or HTTP/2. It takes the best portions of gRPC and gRPC-Web, including
|
||||
streaming, and packages them into a protocol that works equally well in
|
||||
browsers, monoliths, and microservices. Calling a Connect API is as easy as
|
||||
using `curl`. Try it with our live demo:
|
||||
|
||||
```
|
||||
curl \
|
||||
--header "Content-Type: application/json" \
|
||||
--data '{"sentence": "I feel happy."}' \
|
||||
https://demo.connectrpc.com/connectrpc.eliza.v1.ElizaService/Say
|
||||
```
|
||||
|
||||
Handlers and clients also support the gRPC and gRPC-Web protocols, including
|
||||
streaming, headers, trailers, and error details. gRPC-compatible [server
|
||||
reflection][grpcreflect] and [health checks][grpchealth] are available as
|
||||
standalone packages. Instead of cURL, we could call our API with a gRPC client:
|
||||
|
||||
```
|
||||
go install github.com/bufbuild/buf/cmd/buf@latest
|
||||
buf curl --protocol grpc \
|
||||
--data '{"sentence": "I feel happy."}' \
|
||||
https://demo.connectrpc.com/connectrpc.eliza.v1.ElizaService/Say
|
||||
```
|
||||
|
||||
Under the hood, Connect is just [Protocol Buffers][protobuf] and the standard
|
||||
library: no custom HTTP implementation, no new name resolution or load
|
||||
balancing APIs, and no surprises. Everything you already know about `net/http`
|
||||
still applies, and any package that works with an `http.Server`, `http.Client`,
|
||||
or `http.Handler` also works with Connect.
|
||||
|
||||
For more on Connect, see the [announcement blog post][blog], the documentation
|
||||
on [connectrpc.com][docs] (especially the [Getting Started] guide for Go), the
|
||||
[demo service][examples-go], or the [protocol specification][protocol].
|
||||
|
||||
## A small example
|
||||
|
||||
Curious what all this looks like in practice? From a [Protobuf
|
||||
schema](internal/proto/connect/ping/v1/ping.proto), we generate [a small RPC
|
||||
package](internal/gen/simple/connect/ping/v1/pingv1connect/ping.connect.go). Using that
|
||||
package, we can build a server. This example is available at [internal/example](internal/example):
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"connectrpc.com/connect"
|
||||
pingv1 "connectrpc.com/connect/internal/gen/connect/ping/v1"
|
||||
"connectrpc.com/connect/internal/gen/simple/connect/ping/v1/pingv1connect"
|
||||
"connectrpc.com/validate"
|
||||
)
|
||||
|
||||
type PingServer struct {
|
||||
pingv1connect.UnimplementedPingServiceHandler // returns errors from all methods
|
||||
}
|
||||
|
||||
func (ps *PingServer) Ping(ctx context.Context, req *pingv1.PingRequest) (*pingv1.PingResponse, error) {
|
||||
return &pingv1.PingResponse{
|
||||
Number: req.Number,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func main() {
|
||||
mux := http.NewServeMux()
|
||||
// The generated constructors return a path and a plain net/http
|
||||
// handler.
|
||||
mux.Handle(
|
||||
pingv1connect.NewPingServiceHandler(
|
||||
&PingServer{},
|
||||
// Validation via Protovalidate is almost always recommended
|
||||
connect.WithInterceptors(validate.NewInterceptor()),
|
||||
),
|
||||
)
|
||||
p := new(http.Protocols)
|
||||
p.SetHTTP1(true)
|
||||
// For gRPC clients, it's convenient to support HTTP/2 without TLS.
|
||||
p.SetUnencryptedHTTP2(true)
|
||||
s := &http.Server{
|
||||
Addr: "localhost:8080",
|
||||
Handler: mux,
|
||||
Protocols: p,
|
||||
}
|
||||
if err := s.ListenAndServe(); err != nil {
|
||||
log.Fatalf("listen failed: %v", err)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
With that server running, you can make requests with any gRPC or Connect
|
||||
client. To write a client using Connect:
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
pingv1 "connectrpc.com/connect/internal/gen/connect/ping/v1"
|
||||
"connectrpc.com/connect/internal/gen/simple/connect/ping/v1/pingv1connect"
|
||||
)
|
||||
|
||||
func main() {
|
||||
client := pingv1connect.NewPingServiceClient(
|
||||
http.DefaultClient,
|
||||
"http://localhost:8080/",
|
||||
)
|
||||
req := &pingv1.PingRequest{Number: 42}
|
||||
res, err := client.Ping(context.Background(), req)
|
||||
if err != nil {
|
||||
log.Fatalln(err)
|
||||
}
|
||||
log.Println(res)
|
||||
}
|
||||
```
|
||||
|
||||
Of course, `http.ListenAndServe` and `http.DefaultClient` aren't fit for
|
||||
production use! See Connect's [deployment docs][docs-deployment] for a guide to
|
||||
configuring timeouts, connection pools, observability, and h2c.
|
||||
|
||||
## Ecosystem
|
||||
|
||||
* [grpchealth]: gRPC-compatible health checks for connect-go
|
||||
* [grpcreflect]: gRPC-compatible server reflection for connect-go
|
||||
* [validate]: [Protovalidate][protovalidate] interceptor for connect-go
|
||||
* [examples-go]: service powering [demo.connectrpc.com](https://demo.connectrpc.com), including bidi streaming
|
||||
* [connect-es]: Type-safe APIs with Protobuf and TypeScript
|
||||
* [Buf Studio]: web UI for ad-hoc RPCs
|
||||
* [conformance]: Connect, gRPC, and gRPC-Web interoperability tests
|
||||
|
||||
## Status: Stable
|
||||
|
||||
This module is stable. It supports:
|
||||
|
||||
* The two most recent major releases of Go (the same versions of Go that continue
|
||||
to [receive security patches][go-support-policy]).
|
||||
* [APIv2] of Protocol Buffers in Go (`google.golang.org/protobuf`).
|
||||
|
||||
Within those parameters, `connect` follows semantic versioning. We will
|
||||
_not_ make breaking changes in the 1.x series of releases.
|
||||
|
||||
## Legal
|
||||
|
||||
Offered under the [Apache 2 license][license].
|
||||
|
||||
[APIv2]: https://blog.golang.org/protobuf-apiv2
|
||||
[Buf Studio]: https://buf.build/studio
|
||||
[Getting Started]: https://connectrpc.com/docs/go/getting-started
|
||||
[blog]: https://buf.build/blog/connect-a-better-grpc
|
||||
[conformance]: https://github.com/connectrpc/conformance
|
||||
[grpchealth]: https://github.com/connectrpc/grpchealth-go
|
||||
[grpcreflect]: https://github.com/connectrpc/grpcreflect-go
|
||||
[connect-es]: https://github.com/connectrpc/connect-es
|
||||
[examples-go]: https://github.com/connectrpc/examples-go
|
||||
[docs-deployment]: https://connectrpc.com/docs/go/deployment
|
||||
[docs]: https://connectrpc.com
|
||||
[go-support-policy]: https://golang.org/doc/devel/release#policy
|
||||
[license]: https://github.com/connectrpc/connect-go/blob/main/LICENSE
|
||||
[protobuf]: https://developers.google.com/protocol-buffers
|
||||
[protocol]: https://connectrpc.com/docs/protocol
|
||||
[slack]: https://buf.build/links/slack
|
||||
[validate]: https://github.com/connectrpc/validate-go
|
||||
[protovalidate]: https://protovalidate.com
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
# Releasing connect-go
|
||||
|
||||
This document outlines how to create a release of connect-go.
|
||||
|
||||
1. Clone the repo, ensuring you have the latest main.
|
||||
|
||||
2. On a new branch, open [connect.go](connect.go) and change the `Version` constant to an appropriate [semantic version](https://semver.org/). To select the correct version, look at the version number of the [latest release] and the changes that are included in this new release.
|
||||
* If there are only bug fixes and no new features, remove the `-dev` suffix, set MINOR number to be equal to the [latest release], and set the PATCH number to be 1 more than the PATCH number of the [latest release].
|
||||
* If there are features being released, remove the `-dev` suffix, set the MINOR number to be 1 more than the MINOR number of the [latest release], and set the PATCH number to `0`. In the common case, the diff here will just be to remove the `-dev` suffix.
|
||||
|
||||
```patch
|
||||
-const Version = "1.14.0-dev"
|
||||
+const Version = "1.14.0"
|
||||
```
|
||||
|
||||
3. Check for any changes in [cmd/protoc-gen-connect-go/main.go](cmd/protoc-gen-connect-go/main.go) that require a version restriction. A constant `IsAtLeastVersionX_Y_Z` should be defined in [connect.go](connect.go) if generated code has begun to use a new API. Make sure the generated code references this constant. If a new constant has been added since the last release, ensure that the name of the constant matches the version being released ([Example PR #496](https://github.com/connectrpc/connect-go/pull/496)).
|
||||
|
||||
4. Open a PR titled "Prepare for vX.Y.Z" ([Example PR #661](https://github.com/connectrpc/connect-go/pull/661)) and a description tagging all current maintainers. Once it's reviewed and CI passes, merge it.
|
||||
|
||||
*Make sure no new commits are merged until the release is complete.*
|
||||
|
||||
5. Review all commits in the new release and for each PR check an appropriate label is used and edit the title to be meaninful to end users. This will help auto-generated release notes match the final notes as closely as possible.
|
||||
|
||||
6. Using the Github UI, create a new release.
|
||||
- Under “Choose a tag”, type in “vX.Y.Z” to create a new tag for the release upon publish.
|
||||
- Target the main branch.
|
||||
- Title the Release “vX.Y.Z”.
|
||||
- Click “set as latest release”.
|
||||
- Set the last version as the “Previous tag”.
|
||||
- Click “Generate release notes” to autogenerate release notes.
|
||||
- Edit the release notes. A summary and other sub categories may be added if required but should, in most cases, be left as ### Enhancements and ### Bugfixes. Feel free to collect multiple small changes to docs or Github config into one line, but try to tag every contributor. Make especially sure to credit new external contributors!
|
||||
|
||||
7. Publish the release.
|
||||
|
||||
8. On a new branch, open [connect.go](connect.go) and change the `Version` to increment the minor tag and append the `-dev` suffix. Use the next minor release - we never anticipate bugs and patch releases.
|
||||
|
||||
```patch
|
||||
-const Version = "1.14.0"
|
||||
+const Version = "1.15.0-dev"
|
||||
```
|
||||
|
||||
9. Open a PR titled "Back to development" ([Example PR #662](https://github.com/connectrpc/connect-go/pull/662)). Once it's reviewed and CI passes, merge it.
|
||||
|
||||
[latest release]: https://github.com/connectrpc/connect-go/releases/latest
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
Security Policy
|
||||
===============
|
||||
|
||||
This project follows the [Connect security policy and reporting
|
||||
process](https://connectrpc.com/docs/governance/security).
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
version: v2
|
||||
managed:
|
||||
enabled: true
|
||||
override:
|
||||
- file_option: go_package_prefix
|
||||
value: connectrpc.com/connect/internal/gen
|
||||
plugins:
|
||||
- local: protoc-gen-go
|
||||
out: internal/gen
|
||||
opt: paths=source_relative
|
||||
- local: protoc-gen-connect-go
|
||||
out: internal/gen/generics
|
||||
opt: paths=source_relative
|
||||
- local: protoc-gen-connect-go
|
||||
out: internal/gen/simple
|
||||
opt:
|
||||
- paths=source_relative
|
||||
- simple
|
||||
clean: true
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
version: v2
|
||||
modules:
|
||||
- path: internal/proto
|
||||
lint:
|
||||
use:
|
||||
- STANDARD
|
||||
ignore:
|
||||
- internal/proto/connectext/grpc/health/v1/health.proto
|
||||
- internal/proto/connectext/grpc/reflection/v1alpha/reflection.proto
|
||||
- internal/proto/connectext/grpc/status/v1/status.proto
|
||||
disallow_comment_ignores: true
|
||||
breaking:
|
||||
use:
|
||||
- WIRE_JSON
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
// Copyright 2021-2025 The Connect Authors
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package connect
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"sync"
|
||||
)
|
||||
|
||||
const (
|
||||
initialBufferSize = 512
|
||||
maxRecycleBufferSize = 8 * 1024 * 1024 // if >8MiB, don't hold onto a buffer
|
||||
)
|
||||
|
||||
type bufferPool struct {
|
||||
sync.Pool
|
||||
}
|
||||
|
||||
func newBufferPool() *bufferPool {
|
||||
return &bufferPool{
|
||||
Pool: sync.Pool{
|
||||
New: func() any {
|
||||
return bytes.NewBuffer(make([]byte, 0, initialBufferSize))
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (b *bufferPool) Get() *bytes.Buffer {
|
||||
if buf, ok := b.Pool.Get().(*bytes.Buffer); ok {
|
||||
return buf
|
||||
}
|
||||
return bytes.NewBuffer(make([]byte, 0, initialBufferSize))
|
||||
}
|
||||
|
||||
func (b *bufferPool) Put(buffer *bytes.Buffer) {
|
||||
if buffer.Cap() > maxRecycleBufferSize {
|
||||
return
|
||||
}
|
||||
buffer.Reset()
|
||||
b.Pool.Put(buffer)
|
||||
}
|
||||
+392
@@ -0,0 +1,392 @@
|
||||
// Copyright 2021-2025 The Connect Authors
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package connect
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Client is a reusable, concurrency-safe client for a single procedure.
|
||||
// Depending on the procedure's type, use the CallUnary, CallClientStream,
|
||||
// CallServerStream, or CallBidiStream method.
|
||||
//
|
||||
// By default, clients use the Connect protocol with the binary Protobuf Codec,
|
||||
// ask for gzipped responses, and send uncompressed requests. To use the gRPC
|
||||
// or gRPC-Web protocols, use the [WithGRPC] or [WithGRPCWeb] options.
|
||||
type Client[Req, Res any] struct {
|
||||
config *clientConfig
|
||||
callUnary func(context.Context, *Request[Req]) (*Response[Res], error)
|
||||
protocolClient protocolClient
|
||||
err error
|
||||
}
|
||||
|
||||
// NewClient constructs a new Client.
|
||||
func NewClient[Req, Res any](httpClient HTTPClient, url string, options ...ClientOption) *Client[Req, Res] {
|
||||
client := &Client[Req, Res]{}
|
||||
config, err := newClientConfig(url, options)
|
||||
if err != nil {
|
||||
client.err = err
|
||||
return client
|
||||
}
|
||||
client.config = config
|
||||
protocolClient, protocolErr := client.config.Protocol.NewClient(
|
||||
&protocolClientParams{
|
||||
CompressionName: config.RequestCompressionName,
|
||||
CompressionPools: newReadOnlyCompressionPools(
|
||||
config.CompressionPools,
|
||||
config.CompressionNames,
|
||||
),
|
||||
Codec: config.Codec,
|
||||
Protobuf: config.protobuf(),
|
||||
CompressMinBytes: config.CompressMinBytes,
|
||||
HTTPClient: httpClient,
|
||||
URL: config.URL,
|
||||
BufferPool: config.BufferPool,
|
||||
ReadMaxBytes: config.ReadMaxBytes,
|
||||
SendMaxBytes: config.SendMaxBytes,
|
||||
EnableGet: config.EnableGet,
|
||||
GetURLMaxBytes: config.GetURLMaxBytes,
|
||||
GetUseFallback: config.GetUseFallback,
|
||||
},
|
||||
)
|
||||
if protocolErr != nil {
|
||||
client.err = protocolErr
|
||||
return client
|
||||
}
|
||||
client.protocolClient = protocolClient
|
||||
// Rather than applying unary interceptors along the hot path, we can do it
|
||||
// once at client creation.
|
||||
unarySpec := config.newSpec(StreamTypeUnary)
|
||||
unaryFunc := UnaryFunc(func(ctx context.Context, request AnyRequest) (AnyResponse, error) {
|
||||
conn := client.protocolClient.NewConn(ctx, unarySpec, request.Header())
|
||||
conn.onRequestSend(func(r *http.Request) {
|
||||
request.setRequestMethod(r.Method)
|
||||
callInfo, ok := clientCallInfoForContext(ctx)
|
||||
if ok {
|
||||
callInfo.method = r.Method
|
||||
callInfo.responseSource = conn
|
||||
}
|
||||
})
|
||||
// Send always returns an io.EOF unless the error is from the client-side.
|
||||
// We want the user to continue to call Receive in those cases to get the
|
||||
// full error from the server-side.
|
||||
if err := conn.Send(request.Any()); err != nil && !errors.Is(err, io.EOF) {
|
||||
_ = conn.CloseRequest()
|
||||
_ = conn.CloseResponse()
|
||||
return nil, err
|
||||
}
|
||||
if err := conn.CloseRequest(); err != nil {
|
||||
_ = conn.CloseResponse()
|
||||
return nil, err
|
||||
}
|
||||
response, err := receiveUnaryResponse[Res](conn, config.Initializer)
|
||||
if err != nil {
|
||||
_ = conn.CloseResponse()
|
||||
return nil, err
|
||||
}
|
||||
return response, conn.CloseResponse()
|
||||
})
|
||||
if interceptor := config.Interceptor; interceptor != nil {
|
||||
// interceptor is the full chain of all interceptors provided
|
||||
unaryFunc = interceptor.WrapUnary(unaryFunc)
|
||||
}
|
||||
client.callUnary = func(ctx context.Context, request *Request[Req]) (*Response[Res], error) {
|
||||
// To make the specification, peer, and RPC headers visible to the full
|
||||
// interceptor chain (as though they were supplied by the caller), we'll
|
||||
// add them here.
|
||||
request.spec = unarySpec
|
||||
request.peer = client.protocolClient.Peer()
|
||||
protocolClient.WriteRequestHeader(StreamTypeUnary, request.Header())
|
||||
|
||||
// Also set them in the context if there's a call info present
|
||||
callInfo, callInfoOk := clientCallInfoForContext(ctx)
|
||||
if callInfoOk {
|
||||
callInfo.peer = request.Peer()
|
||||
callInfo.spec = request.Spec()
|
||||
// A client could have set request headers in the call info OR the request wrapper
|
||||
// So if a callInfo exists in context, merge any headers from there into the request wrapper
|
||||
// so that all headers are sent in the request
|
||||
mergeHeaders(request.Header(), callInfo.requestHeader)
|
||||
|
||||
// Copy the call info into a sentinel value. This is so we can compare
|
||||
// the sentinel value against the call info in context. If they're different,
|
||||
// we can stop the request. This protects against changing the context in interceptors.
|
||||
ctx = context.WithValue(ctx, sentinelContextKey{}, callInfo)
|
||||
}
|
||||
|
||||
response, err := unaryFunc(ctx, request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
typed, ok := response.(*Response[Res])
|
||||
if !ok {
|
||||
return nil, errorf(CodeInternal, "unexpected client response type %T", response)
|
||||
}
|
||||
return typed, nil
|
||||
}
|
||||
return client
|
||||
}
|
||||
|
||||
// CallUnary calls a request-response procedure.
|
||||
func (c *Client[Req, Res]) CallUnary(ctx context.Context, request *Request[Req]) (*Response[Res], error) {
|
||||
if c.err != nil {
|
||||
return nil, c.err
|
||||
}
|
||||
return c.callUnary(ctx, request)
|
||||
}
|
||||
|
||||
// CallClientStream calls a client streaming procedure.
|
||||
//
|
||||
// Request headers can be sent via the [ClientStreamForClient.RequestHeader] method on the stream. Note that the
|
||||
// request headers are not sent automatically when this method is invoked and instead require an explicit call to
|
||||
// [ClientStreamForClient.Send].
|
||||
func (c *Client[Req, Res]) CallClientStream(ctx context.Context) *ClientStreamForClient[Req, Res] {
|
||||
if c.err != nil {
|
||||
return &ClientStreamForClient[Req, Res]{err: c.err}
|
||||
}
|
||||
return &ClientStreamForClient[Req, Res]{
|
||||
conn: c.newConn(ctx, StreamTypeClient, nil),
|
||||
initializer: c.config.Initializer,
|
||||
}
|
||||
}
|
||||
|
||||
// CallClientStreamSimple calls a client streaming procedure.
|
||||
//
|
||||
// Request headers should be set in a [CallInfo] object inside the context using [NewClientContext]. These headers are
|
||||
// transmitted when this method is called and do not require an explicit call to [ClientStreamForClientSimple.Send].
|
||||
//
|
||||
// In addition, when calling [ClientStreamForClientSimple.CloseAndReceive] on the returned stream, the returned response
|
||||
// is the response type defined for the stream and _not_ a Connect [Response] wrapper type. As a result, any response
|
||||
// headers and trailers should be read from the [CallInfo] object in context.
|
||||
func (c *Client[Req, Res]) CallClientStreamSimple(ctx context.Context) (*ClientStreamForClientSimple[Req, Res], error) {
|
||||
if c.err != nil {
|
||||
return &ClientStreamForClientSimple[Req, Res]{
|
||||
stream: &ClientStreamForClient[Req, Res]{err: c.err},
|
||||
}, c.err
|
||||
}
|
||||
|
||||
stream := &ClientStreamForClientSimple[Req, Res]{
|
||||
stream: &ClientStreamForClient[Req, Res]{
|
||||
conn: c.newConn(ctx, StreamTypeClient, nil),
|
||||
initializer: c.config.Initializer,
|
||||
},
|
||||
}
|
||||
if err := stream.Send(nil); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return stream, nil
|
||||
}
|
||||
|
||||
// CallServerStream calls a server streaming procedure.
|
||||
func (c *Client[Req, Res]) CallServerStream(ctx context.Context, request *Request[Req]) (*ServerStreamForClient[Res], error) {
|
||||
if c.err != nil {
|
||||
return nil, c.err
|
||||
}
|
||||
conn := c.newConn(ctx, StreamTypeServer, func(r *http.Request) {
|
||||
request.method = r.Method
|
||||
})
|
||||
request.peer = conn.Peer()
|
||||
request.spec = conn.Spec()
|
||||
|
||||
mergeHeaders(conn.RequestHeader(), request.header)
|
||||
|
||||
// Send always returns an io.EOF unless the error is from the client-side.
|
||||
// We want the user to continue to call Receive in those cases to get the
|
||||
// full error from the server-side.
|
||||
if err := conn.Send(request.Msg); err != nil && !errors.Is(err, io.EOF) {
|
||||
_ = conn.CloseRequest()
|
||||
_ = conn.CloseResponse()
|
||||
return nil, err
|
||||
}
|
||||
if err := conn.CloseRequest(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &ServerStreamForClient[Res]{
|
||||
conn: conn,
|
||||
initializer: c.config.Initializer,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// CallBidiStream calls a bidirectional streaming procedure.
|
||||
//
|
||||
// Request headers can be sent via the [BidiStreamForClient.RequestHeader] method. Note that the
|
||||
// request headers are not sent automatically when this method is invoked and instead require an explicit call to
|
||||
// [BidiStreamForClient.Send].
|
||||
func (c *Client[Req, Res]) CallBidiStream(ctx context.Context) *BidiStreamForClient[Req, Res] {
|
||||
if c.err != nil {
|
||||
return &BidiStreamForClient[Req, Res]{err: c.err}
|
||||
}
|
||||
return &BidiStreamForClient[Req, Res]{
|
||||
conn: c.newConn(ctx, StreamTypeBidi, nil),
|
||||
initializer: c.config.Initializer,
|
||||
}
|
||||
}
|
||||
|
||||
// CallBidiStreamSimple calls a bidirectional streaming procedure.
|
||||
//
|
||||
// Request headers should be set in a [CallInfo] object inside the context using [NewClientContext]. These headers
|
||||
// are transmitted when this method is called and do not require an explicit call to [BidiStreamForClient.Send].
|
||||
//
|
||||
// Likewise, response headers and trailers should be read from the [CallInfo] object in context.
|
||||
func (c *Client[Req, Res]) CallBidiStreamSimple(ctx context.Context) (*BidiStreamForClientSimple[Req, Res], error) {
|
||||
if c.err != nil {
|
||||
return &BidiStreamForClientSimple[Req, Res]{
|
||||
stream: &BidiStreamForClient[Req, Res]{err: c.err},
|
||||
}, c.err
|
||||
}
|
||||
|
||||
stream := &BidiStreamForClientSimple[Req, Res]{
|
||||
stream: &BidiStreamForClient[Req, Res]{
|
||||
conn: c.newConn(ctx, StreamTypeBidi, nil),
|
||||
initializer: c.config.Initializer,
|
||||
},
|
||||
}
|
||||
|
||||
if err := stream.Send(nil); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return stream, nil
|
||||
}
|
||||
|
||||
func (c *Client[Req, Res]) newConn(ctx context.Context, streamType StreamType, onRequestSend func(r *http.Request)) StreamingClientConn {
|
||||
callInfo, callInfoOk := clientCallInfoForContext(ctx)
|
||||
// Set values in the context if there's a call info present
|
||||
if callInfoOk {
|
||||
// Copy the call info into a sentinel value. This is so we can compare
|
||||
// the sentinel value against the call info in context. If they're different,
|
||||
// we can stop the request. This protects against changing the context in interceptors.
|
||||
ctx = context.WithValue(ctx, sentinelContextKey{}, callInfo)
|
||||
}
|
||||
newConn := func(ctx context.Context, spec Spec) StreamingClientConn {
|
||||
header := make(http.Header, 8) // arbitrary power of two, prevent immediate resizing
|
||||
c.protocolClient.WriteRequestHeader(streamType, header)
|
||||
conn := c.protocolClient.NewConn(ctx, spec, header)
|
||||
conn.onRequestSend(onRequestSend)
|
||||
return conn
|
||||
}
|
||||
if interceptor := c.config.Interceptor; interceptor != nil {
|
||||
newConn = interceptor.WrapStreamingClient(newConn)
|
||||
}
|
||||
conn := newConn(ctx, c.config.newSpec(streamType))
|
||||
|
||||
// Set values in the context if there's a call info present
|
||||
if callInfoOk {
|
||||
callInfo.peer = conn.Peer()
|
||||
callInfo.spec = conn.Spec()
|
||||
callInfo.responseSource = conn
|
||||
|
||||
// Merge any callInfo request headers first, then do the request,
|
||||
// so that context headers show first in the list of headers.
|
||||
mergeHeaders(conn.RequestHeader(), callInfo.RequestHeader())
|
||||
}
|
||||
|
||||
return conn
|
||||
}
|
||||
|
||||
type clientConfig struct {
|
||||
URL *url.URL
|
||||
Protocol protocol
|
||||
Procedure string
|
||||
Schema any
|
||||
Initializer maybeInitializer
|
||||
CompressMinBytes int
|
||||
Interceptor Interceptor
|
||||
CompressionPools map[string]*compressionPool
|
||||
CompressionNames []string
|
||||
Codec Codec
|
||||
RequestCompressionName string
|
||||
BufferPool *bufferPool
|
||||
ReadMaxBytes int
|
||||
SendMaxBytes int
|
||||
EnableGet bool
|
||||
GetURLMaxBytes int
|
||||
GetUseFallback bool
|
||||
IdempotencyLevel IdempotencyLevel
|
||||
}
|
||||
|
||||
func newClientConfig(rawURL string, options []ClientOption) (*clientConfig, *Error) {
|
||||
url, err := parseRequestURL(rawURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
protoPath := extractProtoPath(url.Path)
|
||||
config := clientConfig{
|
||||
URL: url,
|
||||
Protocol: &protocolConnect{},
|
||||
Procedure: protoPath,
|
||||
CompressionPools: make(map[string]*compressionPool),
|
||||
BufferPool: newBufferPool(),
|
||||
}
|
||||
withProtoBinaryCodec().applyToClient(&config)
|
||||
withGzip().applyToClient(&config)
|
||||
for _, opt := range options {
|
||||
opt.applyToClient(&config)
|
||||
}
|
||||
if err := config.validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &config, nil
|
||||
}
|
||||
|
||||
func (c *clientConfig) validate() *Error {
|
||||
if c.Codec == nil || c.Codec.Name() == "" {
|
||||
return errorf(CodeUnknown, "no codec configured")
|
||||
}
|
||||
if c.RequestCompressionName != "" && c.RequestCompressionName != compressionIdentity {
|
||||
if _, ok := c.CompressionPools[c.RequestCompressionName]; !ok {
|
||||
return errorf(CodeUnknown, "unknown compression %q", c.RequestCompressionName)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *clientConfig) protobuf() Codec {
|
||||
if c.Codec.Name() == codecNameProto {
|
||||
return c.Codec
|
||||
}
|
||||
return &protoBinaryCodec{}
|
||||
}
|
||||
|
||||
func (c *clientConfig) newSpec(t StreamType) Spec {
|
||||
return Spec{
|
||||
StreamType: t,
|
||||
Procedure: c.Procedure,
|
||||
Schema: c.Schema,
|
||||
IsClient: true,
|
||||
IdempotencyLevel: c.IdempotencyLevel,
|
||||
}
|
||||
}
|
||||
|
||||
func parseRequestURL(rawURL string) (*url.URL, *Error) {
|
||||
url, err := url.ParseRequestURI(rawURL)
|
||||
if err == nil {
|
||||
return url, nil
|
||||
}
|
||||
if !strings.Contains(rawURL, "://") {
|
||||
// URL doesn't have a scheme, so the user is likely accustomed to
|
||||
// grpc-go's APIs.
|
||||
err = fmt.Errorf(
|
||||
"URL %q missing scheme: use http:// or https:// (unlike grpc-go)",
|
||||
rawURL,
|
||||
)
|
||||
}
|
||||
return nil, NewError(CodeUnavailable, err)
|
||||
}
|
||||
+441
@@ -0,0 +1,441 @@
|
||||
// Copyright 2021-2025 The Connect Authors
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package connect
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
var (
|
||||
// errNoStreamInitialized signals that a no stream has been initialized when
|
||||
// attempting to access stream-related methods.
|
||||
errNoStreamInitialized = errors.New("no stream initialized")
|
||||
)
|
||||
|
||||
// ClientStreamForClient is the client's view of a client streaming RPC.
|
||||
//
|
||||
// It's returned from [Client].CallClientStream, but doesn't currently have an
|
||||
// exported constructor function.
|
||||
//
|
||||
// When using this stream, request headers should be set via the [ClientStreamForClient.RequestHeader] method.
|
||||
type ClientStreamForClient[Req, Res any] struct {
|
||||
conn StreamingClientConn
|
||||
initializer maybeInitializer
|
||||
// Error from client construction. If non-nil, return for all calls.
|
||||
err error
|
||||
}
|
||||
|
||||
// Spec returns the specification for the RPC.
|
||||
func (c *ClientStreamForClient[_, _]) Spec() Spec {
|
||||
return c.conn.Spec()
|
||||
}
|
||||
|
||||
// Peer describes the server for the RPC.
|
||||
func (c *ClientStreamForClient[_, _]) Peer() Peer {
|
||||
return c.conn.Peer()
|
||||
}
|
||||
|
||||
// RequestHeader returns the request headers. Headers are sent to the server with the
|
||||
// first call to Send.
|
||||
//
|
||||
// Headers beginning with "Connect-" and "Grpc-" are reserved for use by the
|
||||
// Connect and gRPC protocols. Applications shouldn't write them.
|
||||
func (c *ClientStreamForClient[Req, Res]) RequestHeader() http.Header {
|
||||
if c.err != nil {
|
||||
return http.Header{}
|
||||
}
|
||||
return c.conn.RequestHeader()
|
||||
}
|
||||
|
||||
// Send a message to the server. The first call to Send also sends the request
|
||||
// headers.
|
||||
//
|
||||
// If the server returns an error, Send returns an error that wraps [io.EOF].
|
||||
// Clients should check for case using the standard library's [errors.Is] and
|
||||
// unmarshal the error using CloseAndReceive.
|
||||
func (c *ClientStreamForClient[Req, Res]) Send(request *Req) error {
|
||||
if c.err != nil {
|
||||
return c.err
|
||||
}
|
||||
if request == nil {
|
||||
return c.conn.Send(nil)
|
||||
}
|
||||
return c.conn.Send(request)
|
||||
}
|
||||
|
||||
// CloseAndReceive closes the send side of the stream and waits for the
|
||||
// response.
|
||||
func (c *ClientStreamForClient[Req, Res]) CloseAndReceive() (*Response[Res], error) {
|
||||
if c.err != nil {
|
||||
return nil, c.err
|
||||
}
|
||||
if err := c.conn.CloseRequest(); err != nil {
|
||||
_ = c.conn.CloseResponse()
|
||||
return nil, err
|
||||
}
|
||||
response, err := receiveUnaryResponse[Res](c.conn, c.initializer)
|
||||
if err != nil {
|
||||
_ = c.conn.CloseResponse()
|
||||
return nil, err
|
||||
}
|
||||
return response, c.conn.CloseResponse()
|
||||
}
|
||||
|
||||
// Conn exposes the underlying StreamingClientConn. This may be useful if
|
||||
// you'd prefer to wrap the connection in a different high-level API.
|
||||
func (c *ClientStreamForClient[Req, Res]) Conn() (StreamingClientConn, error) {
|
||||
return c.conn, c.err
|
||||
}
|
||||
|
||||
// ClientStreamForClientSimple is the client's view of a client streaming RPC.
|
||||
//
|
||||
// It's returned from [Client.CallClientStreamSimple], but doesn't currently have an
|
||||
// exported constructor function.
|
||||
//
|
||||
// Usage of this stream requires that request headers be set in a [CallInfo] object in context via [NewClientContext].
|
||||
// In addition, the response returned by [ClientStreamForClientSimple.CloseAndReceive] is the response type defined for
|
||||
// the stream and _not_ a Connect [Response] wrapper type. As a result, response headers/trailers should be read from
|
||||
// the [CallInfo] object in context.
|
||||
type ClientStreamForClientSimple[Req, Res any] struct {
|
||||
stream *ClientStreamForClient[Req, Res]
|
||||
}
|
||||
|
||||
// Spec returns the specification for the RPC.
|
||||
func (c *ClientStreamForClientSimple[_, _]) Spec() Spec {
|
||||
if c.stream == nil {
|
||||
return Spec{}
|
||||
}
|
||||
return c.stream.Spec()
|
||||
}
|
||||
|
||||
// Peer describes the server for the RPC.
|
||||
func (c *ClientStreamForClientSimple[_, _]) Peer() Peer {
|
||||
if c.stream == nil {
|
||||
return Peer{}
|
||||
}
|
||||
return c.stream.Peer()
|
||||
}
|
||||
|
||||
// Send a message to the server. The first call to Send also sends the request
|
||||
// headers.
|
||||
//
|
||||
// If the server returns an error, Send returns an error that wraps [io.EOF].
|
||||
// Clients should check for case using the standard library's [errors.Is] and
|
||||
// unmarshal the error using CloseAndReceive.
|
||||
func (c *ClientStreamForClientSimple[Req, Res]) Send(request *Req) error {
|
||||
if c.stream == nil {
|
||||
return errNoStreamInitialized
|
||||
}
|
||||
return c.stream.Send(request)
|
||||
}
|
||||
|
||||
// CloseAndReceive closes the send side of the stream and waits for the
|
||||
// response.
|
||||
func (c *ClientStreamForClientSimple[Req, Res]) CloseAndReceive() (*Res, error) {
|
||||
if c.stream == nil {
|
||||
return nil, errNoStreamInitialized
|
||||
}
|
||||
res, err := c.stream.CloseAndReceive()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return res.Msg, nil
|
||||
}
|
||||
|
||||
// ServerStreamForClient is the client's view of a server streaming RPC.
|
||||
//
|
||||
// It's returned from [Client].CallServerStream, but doesn't currently have an
|
||||
// exported constructor function.
|
||||
type ServerStreamForClient[Res any] struct {
|
||||
conn StreamingClientConn
|
||||
initializer maybeInitializer
|
||||
msg *Res
|
||||
// Error from client construction. If non-nil, return for all calls.
|
||||
constructErr error
|
||||
// Error from conn.Receive().
|
||||
receiveErr error
|
||||
}
|
||||
|
||||
// Receive advances the stream to the next message, which will then be
|
||||
// available through the Msg method. It returns false when the stream stops,
|
||||
// either by reaching the end or by encountering an unexpected error. After
|
||||
// Receive returns false, the Err method will return any unexpected error
|
||||
// encountered.
|
||||
func (s *ServerStreamForClient[Res]) Receive() bool {
|
||||
if s.constructErr != nil || s.receiveErr != nil {
|
||||
return false
|
||||
}
|
||||
s.msg = new(Res)
|
||||
if err := s.initializer.maybe(s.conn.Spec(), s.msg); err != nil {
|
||||
s.receiveErr = err
|
||||
return false
|
||||
}
|
||||
s.receiveErr = s.conn.Receive(s.msg)
|
||||
return s.receiveErr == nil
|
||||
}
|
||||
|
||||
// Msg returns the most recent message unmarshaled by a call to Receive.
|
||||
func (s *ServerStreamForClient[Res]) Msg() *Res {
|
||||
if s.msg == nil {
|
||||
s.msg = new(Res)
|
||||
}
|
||||
return s.msg
|
||||
}
|
||||
|
||||
// Err returns the first non-EOF error that was encountered by Receive.
|
||||
func (s *ServerStreamForClient[Res]) Err() error {
|
||||
if s.constructErr != nil {
|
||||
return s.constructErr
|
||||
}
|
||||
if s.receiveErr != nil && !errors.Is(s.receiveErr, io.EOF) {
|
||||
return s.receiveErr
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ResponseHeader returns the headers received from the server. It blocks until
|
||||
// the first call to Receive returns.
|
||||
func (s *ServerStreamForClient[Res]) ResponseHeader() http.Header {
|
||||
if s.constructErr != nil {
|
||||
return http.Header{}
|
||||
}
|
||||
return s.conn.ResponseHeader()
|
||||
}
|
||||
|
||||
// ResponseTrailer returns the trailers received from the server. Trailers
|
||||
// aren't fully populated until Receive() returns an error wrapping io.EOF.
|
||||
func (s *ServerStreamForClient[Res]) ResponseTrailer() http.Header {
|
||||
if s.constructErr != nil {
|
||||
return http.Header{}
|
||||
}
|
||||
return s.conn.ResponseTrailer()
|
||||
}
|
||||
|
||||
// Close the receive side of the stream.
|
||||
//
|
||||
// Close is non-blocking. To gracefully close the stream and allow for
|
||||
// connection resuse ensure all messages have been received before calling
|
||||
// Close. All messages are received when Receive returns false.
|
||||
func (s *ServerStreamForClient[Res]) Close() error {
|
||||
if s.constructErr != nil {
|
||||
return s.constructErr
|
||||
}
|
||||
return s.conn.CloseResponse()
|
||||
}
|
||||
|
||||
// Conn exposes the underlying StreamingClientConn. This may be useful if
|
||||
// you'd prefer to wrap the connection in a different high-level API.
|
||||
func (s *ServerStreamForClient[Res]) Conn() (StreamingClientConn, error) {
|
||||
return s.conn, s.constructErr
|
||||
}
|
||||
|
||||
// BidiStreamForClient is the client's view of a bidirectional streaming RPC.
|
||||
//
|
||||
// It's returned from [Client].CallBidiStream, but doesn't currently have an
|
||||
// exported constructor function.
|
||||
type BidiStreamForClient[Req, Res any] struct {
|
||||
conn StreamingClientConn
|
||||
initializer maybeInitializer
|
||||
// Error from client construction. If non-nil, return for all calls.
|
||||
err error
|
||||
}
|
||||
|
||||
// Spec returns the specification for the RPC.
|
||||
func (b *BidiStreamForClient[_, _]) Spec() Spec {
|
||||
return b.conn.Spec()
|
||||
}
|
||||
|
||||
// Peer describes the server for the RPC.
|
||||
func (b *BidiStreamForClient[_, _]) Peer() Peer {
|
||||
return b.conn.Peer()
|
||||
}
|
||||
|
||||
// RequestHeader returns the request headers. Headers are sent with the first
|
||||
// call to Send.
|
||||
//
|
||||
// Headers beginning with "Connect-" and "Grpc-" are reserved for use by the
|
||||
// Connect and gRPC protocols. Applications shouldn't write them.
|
||||
func (b *BidiStreamForClient[Req, Res]) RequestHeader() http.Header {
|
||||
if b.err != nil {
|
||||
return http.Header{}
|
||||
}
|
||||
return b.conn.RequestHeader()
|
||||
}
|
||||
|
||||
// Send a message to the server. The first call to Send also sends the request
|
||||
// headers. To send just the request headers, without a body, call Send with a
|
||||
// nil pointer.
|
||||
//
|
||||
// If the server returns an error, Send returns an error that wraps [io.EOF].
|
||||
// Clients should check for EOF using the standard library's [errors.Is] and
|
||||
// call Receive to retrieve the error.
|
||||
func (b *BidiStreamForClient[Req, Res]) Send(msg *Req) error {
|
||||
if b.err != nil {
|
||||
return b.err
|
||||
}
|
||||
if msg == nil {
|
||||
return b.conn.Send(nil)
|
||||
}
|
||||
return b.conn.Send(msg)
|
||||
}
|
||||
|
||||
// CloseRequest closes the send side of the stream.
|
||||
func (b *BidiStreamForClient[Req, Res]) CloseRequest() error {
|
||||
if b.err != nil {
|
||||
return b.err
|
||||
}
|
||||
return b.conn.CloseRequest()
|
||||
}
|
||||
|
||||
// Receive a message. When the server is done sending messages and no other
|
||||
// errors have occurred, Receive will return an error that wraps [io.EOF].
|
||||
func (b *BidiStreamForClient[Req, Res]) Receive() (*Res, error) {
|
||||
if b.err != nil {
|
||||
return nil, b.err
|
||||
}
|
||||
var msg Res
|
||||
if err := b.initializer.maybe(b.conn.Spec(), &msg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := b.conn.Receive(&msg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &msg, nil
|
||||
}
|
||||
|
||||
// CloseResponse closes the receive side of the stream.
|
||||
//
|
||||
// CloseResponse is non-blocking. To gracefully close the stream and allow for
|
||||
// connection resuse ensure all messages have been received before calling
|
||||
// CloseResponse. All messages are received when Receive returns an error
|
||||
// wrapping [io.EOF].
|
||||
func (b *BidiStreamForClient[Req, Res]) CloseResponse() error {
|
||||
if b.err != nil {
|
||||
return b.err
|
||||
}
|
||||
return b.conn.CloseResponse()
|
||||
}
|
||||
|
||||
// ResponseHeader returns the headers received from the server. It blocks until
|
||||
// the first call to Receive returns.
|
||||
func (b *BidiStreamForClient[Req, Res]) ResponseHeader() http.Header {
|
||||
if b.err != nil {
|
||||
return http.Header{}
|
||||
}
|
||||
return b.conn.ResponseHeader()
|
||||
}
|
||||
|
||||
// ResponseTrailer returns the trailers received from the server. Trailers
|
||||
// aren't fully populated until Receive() returns an error wrapping [io.EOF].
|
||||
func (b *BidiStreamForClient[Req, Res]) ResponseTrailer() http.Header {
|
||||
if b.err != nil {
|
||||
return http.Header{}
|
||||
}
|
||||
return b.conn.ResponseTrailer()
|
||||
}
|
||||
|
||||
// Conn exposes the underlying StreamingClientConn. This may be useful if
|
||||
// you'd prefer to wrap the connection in a different high-level API.
|
||||
func (b *BidiStreamForClient[Req, Res]) Conn() (StreamingClientConn, error) {
|
||||
return b.conn, b.err
|
||||
}
|
||||
|
||||
// BidiStreamForClientSimple is the client's view of a bidirectional streaming RPC.
|
||||
//
|
||||
// It's returned from [Client].CallBidiStream, but doesn't currently have an
|
||||
// exported constructor function.
|
||||
type BidiStreamForClientSimple[Req, Res any] struct {
|
||||
stream *BidiStreamForClient[Req, Res]
|
||||
}
|
||||
|
||||
// Spec returns the specification for the RPC.
|
||||
func (b *BidiStreamForClientSimple[_, _]) Spec() Spec {
|
||||
if b.stream == nil {
|
||||
return Spec{}
|
||||
}
|
||||
return b.stream.Spec()
|
||||
}
|
||||
|
||||
// Peer describes the server for the RPC.
|
||||
func (b *BidiStreamForClientSimple[_, _]) Peer() Peer {
|
||||
if b.stream == nil {
|
||||
return Peer{}
|
||||
}
|
||||
return b.stream.Peer()
|
||||
}
|
||||
|
||||
// Send a message to the server. The first call to Send also sends the request
|
||||
// headers. To send just the request headers, without a body, call Send with a
|
||||
// nil pointer.
|
||||
//
|
||||
// If the server returns an error, Send returns an error that wraps [io.EOF].
|
||||
// Clients should check for EOF using the standard library's [errors.Is] and
|
||||
// call Receive to retrieve the error.
|
||||
func (b *BidiStreamForClientSimple[Req, Res]) Send(msg *Req) error {
|
||||
if b.stream == nil {
|
||||
return errNoStreamInitialized
|
||||
}
|
||||
return b.stream.Send(msg)
|
||||
}
|
||||
|
||||
// CloseRequest closes the send side of the stream.
|
||||
func (b *BidiStreamForClientSimple[Req, Res]) CloseRequest() error {
|
||||
if b.stream == nil {
|
||||
return errNoStreamInitialized
|
||||
}
|
||||
return b.stream.CloseRequest()
|
||||
}
|
||||
|
||||
// Receive a message. When the server is done sending messages and no other
|
||||
// errors have occurred, Receive will return an error that wraps [io.EOF].
|
||||
func (b *BidiStreamForClientSimple[Req, Res]) Receive() (*Res, error) {
|
||||
if b.stream == nil {
|
||||
return nil, errNoStreamInitialized
|
||||
}
|
||||
return b.stream.Receive()
|
||||
}
|
||||
|
||||
// CloseResponse closes the receive side of the stream.
|
||||
//
|
||||
// CloseResponse is non-blocking. To gracefully close the stream and allow for
|
||||
// connection resuse ensure all messages have been received before calling
|
||||
// CloseResponse. All messages are received when Receive returns an error
|
||||
// wrapping [io.EOF].
|
||||
func (b *BidiStreamForClientSimple[Req, Res]) CloseResponse() error {
|
||||
if b.stream == nil {
|
||||
return errNoStreamInitialized
|
||||
}
|
||||
return b.stream.CloseResponse()
|
||||
}
|
||||
|
||||
// ResponseHeader returns the headers received from the server. It blocks until
|
||||
// the first call to Receive returns.
|
||||
func (b *BidiStreamForClientSimple[Req, Res]) ResponseHeader() http.Header {
|
||||
if b.stream == nil {
|
||||
return make(http.Header)
|
||||
}
|
||||
return b.stream.ResponseHeader()
|
||||
}
|
||||
|
||||
// ResponseTrailer returns the trailers received from the server. Trailers
|
||||
// aren't fully populated until Receive() returns an error wrapping [io.EOF].
|
||||
func (b *BidiStreamForClientSimple[Req, Res]) ResponseTrailer() http.Header {
|
||||
if b.stream == nil {
|
||||
return make(http.Header)
|
||||
}
|
||||
return b.stream.ResponseTrailer()
|
||||
}
|
||||
+226
@@ -0,0 +1,226 @@
|
||||
// Copyright 2021-2025 The Connect Authors
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package connect
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// A Code is one of the Connect protocol's error codes. There are no user-defined
|
||||
// codes, so only the codes enumerated below are valid. In both name and
|
||||
// semantics, these codes match the gRPC status codes.
|
||||
//
|
||||
// The descriptions below are optimized for brevity rather than completeness.
|
||||
// See the [Connect protocol specification] for detailed descriptions of each
|
||||
// code and example usage.
|
||||
//
|
||||
// [Connect protocol specification]: https://connectrpc.com/docs/protocol
|
||||
type Code uint32
|
||||
|
||||
const (
|
||||
// The zero code in gRPC is OK, which indicates that the operation was a
|
||||
// success. We don't define a constant for it because it overlaps awkwardly
|
||||
// with Go's error semantics: what does it mean to have a non-nil error with
|
||||
// an OK status? (Also, the Connect protocol doesn't use a code for
|
||||
// successes.)
|
||||
|
||||
// CodeCanceled indicates that the operation was canceled, typically by the
|
||||
// caller.
|
||||
CodeCanceled Code = 1
|
||||
|
||||
// CodeUnknown indicates that the operation failed for an unknown reason.
|
||||
CodeUnknown Code = 2
|
||||
|
||||
// CodeInvalidArgument indicates that client supplied an invalid argument.
|
||||
CodeInvalidArgument Code = 3
|
||||
|
||||
// CodeDeadlineExceeded indicates that deadline expired before the operation
|
||||
// could complete.
|
||||
CodeDeadlineExceeded Code = 4
|
||||
|
||||
// CodeNotFound indicates that some requested entity (for example, a file or
|
||||
// directory) was not found.
|
||||
CodeNotFound Code = 5
|
||||
|
||||
// CodeAlreadyExists indicates that client attempted to create an entity (for
|
||||
// example, a file or directory) that already exists.
|
||||
CodeAlreadyExists Code = 6
|
||||
|
||||
// CodePermissionDenied indicates that the caller doesn't have permission to
|
||||
// execute the specified operation.
|
||||
CodePermissionDenied Code = 7
|
||||
|
||||
// CodeResourceExhausted indicates that some resource has been exhausted. For
|
||||
// example, a per-user quota may be exhausted or the entire file system may
|
||||
// be full.
|
||||
CodeResourceExhausted Code = 8
|
||||
|
||||
// CodeFailedPrecondition indicates that the system is not in a state
|
||||
// required for the operation's execution.
|
||||
CodeFailedPrecondition Code = 9
|
||||
|
||||
// CodeAborted indicates that operation was aborted by the system, usually
|
||||
// because of a concurrency issue such as a sequencer check failure or
|
||||
// transaction abort.
|
||||
CodeAborted Code = 10
|
||||
|
||||
// CodeOutOfRange indicates that the operation was attempted past the valid
|
||||
// range (for example, seeking past end-of-file).
|
||||
CodeOutOfRange Code = 11
|
||||
|
||||
// CodeUnimplemented indicates that the operation isn't implemented,
|
||||
// supported, or enabled in this service.
|
||||
CodeUnimplemented Code = 12
|
||||
|
||||
// CodeInternal indicates that some invariants expected by the underlying
|
||||
// system have been broken. This code is reserved for serious errors.
|
||||
CodeInternal Code = 13
|
||||
|
||||
// CodeUnavailable indicates that the service is currently unavailable. This
|
||||
// is usually temporary, so clients can back off and retry idempotent
|
||||
// operations.
|
||||
CodeUnavailable Code = 14
|
||||
|
||||
// CodeDataLoss indicates that the operation has resulted in unrecoverable
|
||||
// data loss or corruption.
|
||||
CodeDataLoss Code = 15
|
||||
|
||||
// CodeUnauthenticated indicates that the request does not have valid
|
||||
// authentication credentials for the operation.
|
||||
CodeUnauthenticated Code = 16
|
||||
|
||||
minCode = CodeCanceled
|
||||
maxCode = CodeUnauthenticated
|
||||
)
|
||||
|
||||
func (c Code) String() string {
|
||||
switch c {
|
||||
case CodeCanceled:
|
||||
return "canceled"
|
||||
case CodeUnknown:
|
||||
return "unknown"
|
||||
case CodeInvalidArgument:
|
||||
return "invalid_argument"
|
||||
case CodeDeadlineExceeded:
|
||||
return "deadline_exceeded"
|
||||
case CodeNotFound:
|
||||
return "not_found"
|
||||
case CodeAlreadyExists:
|
||||
return "already_exists"
|
||||
case CodePermissionDenied:
|
||||
return "permission_denied"
|
||||
case CodeResourceExhausted:
|
||||
return "resource_exhausted"
|
||||
case CodeFailedPrecondition:
|
||||
return "failed_precondition"
|
||||
case CodeAborted:
|
||||
return "aborted"
|
||||
case CodeOutOfRange:
|
||||
return "out_of_range"
|
||||
case CodeUnimplemented:
|
||||
return "unimplemented"
|
||||
case CodeInternal:
|
||||
return "internal"
|
||||
case CodeUnavailable:
|
||||
return "unavailable"
|
||||
case CodeDataLoss:
|
||||
return "data_loss"
|
||||
case CodeUnauthenticated:
|
||||
return "unauthenticated"
|
||||
}
|
||||
return fmt.Sprintf("code_%d", c)
|
||||
}
|
||||
|
||||
// MarshalText implements [encoding.TextMarshaler].
|
||||
func (c Code) MarshalText() ([]byte, error) {
|
||||
return []byte(c.String()), nil
|
||||
}
|
||||
|
||||
// UnmarshalText implements [encoding.TextUnmarshaler].
|
||||
func (c *Code) UnmarshalText(data []byte) error {
|
||||
dataStr := string(data)
|
||||
switch dataStr {
|
||||
case "canceled":
|
||||
*c = CodeCanceled
|
||||
return nil
|
||||
case "unknown":
|
||||
*c = CodeUnknown
|
||||
return nil
|
||||
case "invalid_argument":
|
||||
*c = CodeInvalidArgument
|
||||
return nil
|
||||
case "deadline_exceeded":
|
||||
*c = CodeDeadlineExceeded
|
||||
return nil
|
||||
case "not_found":
|
||||
*c = CodeNotFound
|
||||
return nil
|
||||
case "already_exists":
|
||||
*c = CodeAlreadyExists
|
||||
return nil
|
||||
case "permission_denied":
|
||||
*c = CodePermissionDenied
|
||||
return nil
|
||||
case "resource_exhausted":
|
||||
*c = CodeResourceExhausted
|
||||
return nil
|
||||
case "failed_precondition":
|
||||
*c = CodeFailedPrecondition
|
||||
return nil
|
||||
case "aborted":
|
||||
*c = CodeAborted
|
||||
return nil
|
||||
case "out_of_range":
|
||||
*c = CodeOutOfRange
|
||||
return nil
|
||||
case "unimplemented":
|
||||
*c = CodeUnimplemented
|
||||
return nil
|
||||
case "internal":
|
||||
*c = CodeInternal
|
||||
return nil
|
||||
case "unavailable":
|
||||
*c = CodeUnavailable
|
||||
return nil
|
||||
case "data_loss":
|
||||
*c = CodeDataLoss
|
||||
return nil
|
||||
case "unauthenticated":
|
||||
*c = CodeUnauthenticated
|
||||
return nil
|
||||
}
|
||||
// Ensure that non-canonical codes round-trip through MarshalText and
|
||||
// UnmarshalText.
|
||||
if after, ok := strings.CutPrefix(dataStr, "code_"); ok {
|
||||
dataStr = after
|
||||
code, err := strconv.ParseUint(dataStr, 10 /* base */, 32 /* bitsize */)
|
||||
if err == nil && (code < uint64(minCode) || code > uint64(maxCode)) {
|
||||
*c = Code(code)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("invalid code %q", dataStr)
|
||||
}
|
||||
|
||||
// CodeOf returns the error's status code if it is or wraps an [*Error] and
|
||||
// [CodeUnknown] otherwise.
|
||||
func CodeOf(err error) Code {
|
||||
if connectErr, ok := asError(err); ok {
|
||||
return connectErr.Code()
|
||||
}
|
||||
return CodeUnknown
|
||||
}
|
||||
+259
@@ -0,0 +1,259 @@
|
||||
// Copyright 2021-2025 The Connect Authors
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package connect
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"google.golang.org/protobuf/encoding/protojson"
|
||||
"google.golang.org/protobuf/proto"
|
||||
"google.golang.org/protobuf/runtime/protoiface"
|
||||
)
|
||||
|
||||
const (
|
||||
codecNameProto = "proto"
|
||||
codecNameJSON = "json"
|
||||
codecNameJSONCharsetUTF8 = codecNameJSON + "; charset=utf-8"
|
||||
)
|
||||
|
||||
// Codec marshals structs (typically generated from a schema) to and from bytes.
|
||||
type Codec interface {
|
||||
// Name returns the name of the Codec.
|
||||
//
|
||||
// This may be used as part of the Content-Type within HTTP. For example,
|
||||
// with gRPC this is the content subtype, so "application/grpc+proto" will
|
||||
// map to the Codec with name "proto".
|
||||
//
|
||||
// Names must not be empty.
|
||||
Name() string
|
||||
// Marshal marshals the given message.
|
||||
//
|
||||
// Marshal may expect a specific type of message, and will error if this type
|
||||
// is not given.
|
||||
Marshal(any) ([]byte, error)
|
||||
// Unmarshal unmarshals the given message.
|
||||
//
|
||||
// Unmarshal may expect a specific type of message, and will error if this
|
||||
// type is not given.
|
||||
Unmarshal([]byte, any) error
|
||||
}
|
||||
|
||||
// marshalAppender is an extension to Codec for appending to a byte slice.
|
||||
type marshalAppender interface {
|
||||
Codec
|
||||
|
||||
// MarshalAppend marshals the given message and appends it to the given
|
||||
// byte slice.
|
||||
//
|
||||
// MarshalAppend may expect a specific type of message, and will error if
|
||||
// this type is not given.
|
||||
MarshalAppend([]byte, any) ([]byte, error)
|
||||
}
|
||||
|
||||
// stableCodec is an extension to Codec for serializing with stable output.
|
||||
type stableCodec interface {
|
||||
Codec
|
||||
|
||||
// MarshalStable marshals the given message with stable field ordering.
|
||||
//
|
||||
// MarshalStable should return the same output for a given input. Although
|
||||
// it is not guaranteed to be canonicalized, the marshalling routine for
|
||||
// MarshalStable will opt for the most normalized output available for a
|
||||
// given serialization.
|
||||
//
|
||||
// For practical reasons, it is possible for MarshalStable to return two
|
||||
// different results for two inputs considered to be "equal" in their own
|
||||
// domain, and it may change in the future with codec updates, but for
|
||||
// any given concrete value and any given version, it should return the
|
||||
// same output.
|
||||
MarshalStable(any) ([]byte, error)
|
||||
|
||||
// IsBinary returns true if the marshalled data is binary for this codec.
|
||||
//
|
||||
// If this function returns false, the data returned from Marshal and
|
||||
// MarshalStable are considered valid text and may be used in contexts
|
||||
// where text is expected.
|
||||
IsBinary() bool
|
||||
}
|
||||
|
||||
type protoBinaryCodec struct{}
|
||||
|
||||
var _ Codec = (*protoBinaryCodec)(nil)
|
||||
|
||||
func (c *protoBinaryCodec) Name() string { return codecNameProto }
|
||||
|
||||
func (c *protoBinaryCodec) Marshal(message any) ([]byte, error) {
|
||||
protoMessage, ok := message.(proto.Message)
|
||||
if !ok {
|
||||
return nil, errNotProto(message)
|
||||
}
|
||||
return proto.Marshal(protoMessage)
|
||||
}
|
||||
|
||||
func (c *protoBinaryCodec) MarshalAppend(dst []byte, message any) ([]byte, error) {
|
||||
protoMessage, ok := message.(proto.Message)
|
||||
if !ok {
|
||||
return nil, errNotProto(message)
|
||||
}
|
||||
return proto.MarshalOptions{}.MarshalAppend(dst, protoMessage)
|
||||
}
|
||||
|
||||
func (c *protoBinaryCodec) Unmarshal(data []byte, message any) error {
|
||||
protoMessage, ok := message.(proto.Message)
|
||||
if !ok {
|
||||
return errNotProto(message)
|
||||
}
|
||||
err := proto.Unmarshal(data, protoMessage)
|
||||
if err != nil {
|
||||
return fmt.Errorf("unmarshal into %T: %w", message, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *protoBinaryCodec) MarshalStable(message any) ([]byte, error) {
|
||||
protoMessage, ok := message.(proto.Message)
|
||||
if !ok {
|
||||
return nil, errNotProto(message)
|
||||
}
|
||||
// protobuf does not offer a canonical output today, so this format is not
|
||||
// guaranteed to match deterministic output from other protobuf libraries.
|
||||
// In addition, unknown fields may cause inconsistent output for otherwise
|
||||
// equal messages.
|
||||
// https://github.com/golang/protobuf/issues/1121
|
||||
options := proto.MarshalOptions{Deterministic: true}
|
||||
return options.Marshal(protoMessage)
|
||||
}
|
||||
|
||||
func (c *protoBinaryCodec) IsBinary() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
type protoJSONCodec struct {
|
||||
name string
|
||||
}
|
||||
|
||||
var _ Codec = (*protoJSONCodec)(nil)
|
||||
|
||||
func (c *protoJSONCodec) Name() string { return c.name }
|
||||
|
||||
func (c *protoJSONCodec) Marshal(message any) ([]byte, error) {
|
||||
protoMessage, ok := message.(proto.Message)
|
||||
if !ok {
|
||||
return nil, errNotProto(message)
|
||||
}
|
||||
return protojson.MarshalOptions{}.Marshal(protoMessage)
|
||||
}
|
||||
|
||||
func (c *protoJSONCodec) MarshalAppend(dst []byte, message any) ([]byte, error) {
|
||||
protoMessage, ok := message.(proto.Message)
|
||||
if !ok {
|
||||
return nil, errNotProto(message)
|
||||
}
|
||||
return protojson.MarshalOptions{}.MarshalAppend(dst, protoMessage)
|
||||
}
|
||||
|
||||
func (c *protoJSONCodec) Unmarshal(binary []byte, message any) error {
|
||||
protoMessage, ok := message.(proto.Message)
|
||||
if !ok {
|
||||
return errNotProto(message)
|
||||
}
|
||||
if len(binary) == 0 {
|
||||
return errors.New("zero-length payload is not a valid JSON object")
|
||||
}
|
||||
// Discard unknown fields so clients and servers aren't forced to always use
|
||||
// exactly the same version of the schema.
|
||||
options := protojson.UnmarshalOptions{DiscardUnknown: true}
|
||||
err := options.Unmarshal(binary, protoMessage)
|
||||
if err != nil {
|
||||
return fmt.Errorf("unmarshal into %T: %w", message, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *protoJSONCodec) MarshalStable(message any) ([]byte, error) {
|
||||
// protojson does not offer a "deterministic" field ordering, but fields
|
||||
// are still ordered consistently by their index. However, protojson can
|
||||
// output inconsistent whitespace for some reason, therefore it is
|
||||
// suggested to use a formatter to ensure consistent formatting.
|
||||
// https://github.com/golang/protobuf/issues/1373
|
||||
messageJSON, err := c.Marshal(message)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
compactedJSON := bytes.NewBuffer(messageJSON[:0])
|
||||
if err = json.Compact(compactedJSON, messageJSON); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return compactedJSON.Bytes(), nil
|
||||
}
|
||||
|
||||
func (c *protoJSONCodec) IsBinary() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// readOnlyCodecs is a read-only interface to a map of named codecs.
|
||||
type readOnlyCodecs interface {
|
||||
// Get gets the Codec with the given name.
|
||||
Get(string) Codec
|
||||
// Protobuf gets the user-supplied protobuf codec, falling back to the default
|
||||
// implementation if necessary.
|
||||
//
|
||||
// This is helpful in the gRPC protocol, where the wire protocol requires
|
||||
// marshaling protobuf structs to binary even if the RPC procedures were
|
||||
// generated from a different IDL.
|
||||
Protobuf() Codec
|
||||
// Names returns a copy of the registered codec names. The returned slice is
|
||||
// safe for the caller to mutate.
|
||||
Names() []string
|
||||
}
|
||||
|
||||
func newReadOnlyCodecs(nameToCodec map[string]Codec) readOnlyCodecs {
|
||||
return &codecMap{
|
||||
nameToCodec: nameToCodec,
|
||||
}
|
||||
}
|
||||
|
||||
type codecMap struct {
|
||||
nameToCodec map[string]Codec
|
||||
}
|
||||
|
||||
func (m *codecMap) Get(name string) Codec {
|
||||
return m.nameToCodec[name]
|
||||
}
|
||||
|
||||
func (m *codecMap) Protobuf() Codec {
|
||||
if pb, ok := m.nameToCodec[codecNameProto]; ok {
|
||||
return pb
|
||||
}
|
||||
return &protoBinaryCodec{}
|
||||
}
|
||||
|
||||
func (m *codecMap) Names() []string {
|
||||
names := make([]string, 0, len(m.nameToCodec))
|
||||
for name := range m.nameToCodec {
|
||||
names = append(names, name)
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
func errNotProto(message any) error {
|
||||
if _, ok := message.(protoiface.MessageV1); ok {
|
||||
return fmt.Errorf("%T uses github.com/golang/protobuf, but connect-go only supports google.golang.org/protobuf: see https://go.dev/blog/protobuf-apiv2", message)
|
||||
}
|
||||
return fmt.Errorf("%T doesn't implement proto.Message", message)
|
||||
}
|
||||
+224
@@ -0,0 +1,224 @@
|
||||
// Copyright 2021-2025 The Connect Authors
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package connect
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"io"
|
||||
"math"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
const (
|
||||
compressionGzip = "gzip"
|
||||
compressionIdentity = "identity"
|
||||
)
|
||||
|
||||
// A Decompressor is a reusable wrapper that decompresses an underlying data
|
||||
// source. The standard library's [*gzip.Reader] implements Decompressor.
|
||||
type Decompressor interface {
|
||||
io.Reader
|
||||
|
||||
// Close closes the Decompressor, but not the underlying data source. It may
|
||||
// return an error if the Decompressor wasn't read to EOF.
|
||||
Close() error
|
||||
|
||||
// Reset discards the Decompressor's internal state, if any, and prepares it
|
||||
// to read from a new source of compressed data.
|
||||
Reset(io.Reader) error
|
||||
}
|
||||
|
||||
// A Compressor is a reusable wrapper that compresses data written to an
|
||||
// underlying sink. The standard library's [*gzip.Writer] implements Compressor.
|
||||
type Compressor interface {
|
||||
io.Writer
|
||||
|
||||
// Close flushes any buffered data to the underlying sink, then closes the
|
||||
// Compressor. It must not close the underlying sink.
|
||||
Close() error
|
||||
|
||||
// Reset discards the Compressor's internal state, if any, and prepares it to
|
||||
// write compressed data to a new sink.
|
||||
Reset(io.Writer)
|
||||
}
|
||||
|
||||
type compressionPool struct {
|
||||
decompressors sync.Pool
|
||||
compressors sync.Pool
|
||||
}
|
||||
|
||||
func newCompressionPool(
|
||||
newDecompressor func() Decompressor,
|
||||
newCompressor func() Compressor,
|
||||
) *compressionPool {
|
||||
if newDecompressor == nil && newCompressor == nil {
|
||||
return nil
|
||||
}
|
||||
return &compressionPool{
|
||||
decompressors: sync.Pool{
|
||||
New: func() any { return newDecompressor() },
|
||||
},
|
||||
compressors: sync.Pool{
|
||||
New: func() any { return newCompressor() },
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (c *compressionPool) Decompress(dst *bytes.Buffer, src *bytes.Buffer, readMaxBytes int64) *Error {
|
||||
decompressor, err := c.getDecompressor(src)
|
||||
if err != nil {
|
||||
return errorf(CodeInvalidArgument, "get decompressor: %w", err)
|
||||
}
|
||||
reader := io.Reader(decompressor)
|
||||
if readMaxBytes > 0 && readMaxBytes < math.MaxInt64 {
|
||||
reader = io.LimitReader(decompressor, readMaxBytes+1)
|
||||
}
|
||||
bytesRead, err := dst.ReadFrom(reader)
|
||||
if err != nil {
|
||||
_ = c.putDecompressor(decompressor)
|
||||
err = wrapIfContextError(err)
|
||||
if connectErr, ok := asError(err); ok {
|
||||
return connectErr
|
||||
}
|
||||
return errorf(CodeInvalidArgument, "decompress: %w", err)
|
||||
}
|
||||
if readMaxBytes > 0 && bytesRead > readMaxBytes {
|
||||
discardedBytes, err := io.Copy(io.Discard, decompressor)
|
||||
_ = c.putDecompressor(decompressor)
|
||||
if err != nil {
|
||||
return errorf(CodeResourceExhausted, "message is larger than configured max %d - unable to determine message size: %w", readMaxBytes, err)
|
||||
}
|
||||
return errorf(CodeResourceExhausted, "message size %d is larger than configured max %d", bytesRead+discardedBytes, readMaxBytes)
|
||||
}
|
||||
if err := c.putDecompressor(decompressor); err != nil {
|
||||
return errorf(CodeUnknown, "recycle decompressor: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *compressionPool) Compress(dst *bytes.Buffer, src *bytes.Buffer) *Error {
|
||||
compressor, err := c.getCompressor(dst)
|
||||
if err != nil {
|
||||
return errorf(CodeUnknown, "get compressor: %w", err)
|
||||
}
|
||||
if _, err := src.WriteTo(compressor); err != nil {
|
||||
_ = c.putCompressor(compressor)
|
||||
err = wrapIfContextError(err)
|
||||
if connectErr, ok := asError(err); ok {
|
||||
return connectErr
|
||||
}
|
||||
return errorf(CodeInternal, "compress: %w", err)
|
||||
}
|
||||
if err := c.putCompressor(compressor); err != nil {
|
||||
return errorf(CodeInternal, "recycle compressor: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *compressionPool) getDecompressor(reader io.Reader) (Decompressor, error) {
|
||||
decompressor, ok := c.decompressors.Get().(Decompressor)
|
||||
if !ok {
|
||||
return nil, errors.New("expected Decompressor, got incorrect type from pool")
|
||||
}
|
||||
return decompressor, decompressor.Reset(reader)
|
||||
}
|
||||
|
||||
func (c *compressionPool) putDecompressor(decompressor Decompressor) error {
|
||||
if err := decompressor.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
// While it's in the pool, we don't want the decompressor to retain a
|
||||
// reference to the underlying reader. However, most decompressors attempt to
|
||||
// read some header data from the new data source when Reset; since we don't
|
||||
// know the compression format, we can't provide a valid header. Since we
|
||||
// also reset the decompressor when it's pulled out of the pool, we can
|
||||
// ignore errors here.
|
||||
_ = decompressor.Reset(http.NoBody)
|
||||
c.decompressors.Put(decompressor)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *compressionPool) getCompressor(writer io.Writer) (Compressor, error) {
|
||||
compressor, ok := c.compressors.Get().(Compressor)
|
||||
if !ok {
|
||||
return nil, errors.New("expected Compressor, got incorrect type from pool")
|
||||
}
|
||||
compressor.Reset(writer)
|
||||
return compressor, nil
|
||||
}
|
||||
|
||||
func (c *compressionPool) putCompressor(compressor Compressor) error {
|
||||
if err := compressor.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
compressor.Reset(io.Discard) // don't keep references
|
||||
c.compressors.Put(compressor)
|
||||
return nil
|
||||
}
|
||||
|
||||
// readOnlyCompressionPools is a read-only interface to a map of named
|
||||
// compressionPools.
|
||||
type readOnlyCompressionPools interface {
|
||||
Get(string) *compressionPool
|
||||
Contains(string) bool
|
||||
// Wordy, but clarifies how this is different from readOnlyCodecs.Names().
|
||||
CommaSeparatedNames() string
|
||||
}
|
||||
|
||||
func newReadOnlyCompressionPools(
|
||||
nameToPool map[string]*compressionPool,
|
||||
reversedNames []string,
|
||||
) readOnlyCompressionPools {
|
||||
// Client and handler configs keep compression names in registration order,
|
||||
// but we want the last registered to be the most preferred.
|
||||
names := make([]string, 0, len(reversedNames))
|
||||
seen := make(map[string]struct{}, len(reversedNames))
|
||||
for i := len(reversedNames) - 1; i >= 0; i-- {
|
||||
name := reversedNames[i]
|
||||
if _, ok := seen[name]; ok {
|
||||
continue
|
||||
}
|
||||
seen[name] = struct{}{}
|
||||
names = append(names, name)
|
||||
}
|
||||
return &namedCompressionPools{
|
||||
nameToPool: nameToPool,
|
||||
commaSeparatedNames: strings.Join(names, ","),
|
||||
}
|
||||
}
|
||||
|
||||
type namedCompressionPools struct {
|
||||
nameToPool map[string]*compressionPool
|
||||
commaSeparatedNames string
|
||||
}
|
||||
|
||||
func (m *namedCompressionPools) Get(name string) *compressionPool {
|
||||
if name == "" || name == compressionIdentity {
|
||||
return nil
|
||||
}
|
||||
return m.nameToPool[name]
|
||||
}
|
||||
|
||||
func (m *namedCompressionPools) Contains(name string) bool {
|
||||
_, ok := m.nameToPool[name]
|
||||
return ok
|
||||
}
|
||||
|
||||
func (m *namedCompressionPools) CommaSeparatedNames() string {
|
||||
return m.commaSeparatedNames
|
||||
}
|
||||
+482
@@ -0,0 +1,482 @@
|
||||
// Copyright 2021-2025 The Connect Authors
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
// Package connect is a slim RPC framework built on Protocol Buffers and
|
||||
// [net/http]. In addition to supporting its own protocol, Connect handlers and
|
||||
// clients are wire-compatible with gRPC and gRPC-Web, including streaming.
|
||||
//
|
||||
// This documentation is intended to explain each type and function in
|
||||
// isolation. Walkthroughs, FAQs, and other narrative docs are available on the
|
||||
// [Connect website], and there's a working [demonstration service] on Github.
|
||||
//
|
||||
// [Connect website]: https://connectrpc.com
|
||||
// [demonstration service]: https://github.com/connectrpc/examples-go
|
||||
package connect
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
)
|
||||
|
||||
// Version is the semantic version of the connect module.
|
||||
const Version = "1.19.1"
|
||||
|
||||
// These constants are used in compile-time handshakes with connect's generated
|
||||
// code.
|
||||
const (
|
||||
IsAtLeastVersion0_0_1 = true
|
||||
IsAtLeastVersion0_1_0 = true
|
||||
IsAtLeastVersion1_7_0 = true
|
||||
IsAtLeastVersion1_13_0 = true
|
||||
)
|
||||
|
||||
// StreamType describes whether the client, server, neither, or both is
|
||||
// streaming.
|
||||
type StreamType uint8
|
||||
|
||||
const (
|
||||
StreamTypeUnary StreamType = 0b00
|
||||
StreamTypeClient StreamType = 0b01
|
||||
StreamTypeServer StreamType = 0b10
|
||||
StreamTypeBidi = StreamTypeClient | StreamTypeServer
|
||||
)
|
||||
|
||||
func (s StreamType) String() string {
|
||||
switch s {
|
||||
case StreamTypeUnary:
|
||||
return "unary"
|
||||
case StreamTypeClient:
|
||||
return "client"
|
||||
case StreamTypeServer:
|
||||
return "server"
|
||||
case StreamTypeBidi:
|
||||
return "bidi"
|
||||
}
|
||||
return fmt.Sprintf("stream_%d", s)
|
||||
}
|
||||
|
||||
// StreamingHandlerConn is the server's view of a bidirectional message
|
||||
// exchange. Interceptors for streaming RPCs may wrap StreamingHandlerConns.
|
||||
//
|
||||
// Like the standard library's [http.ResponseWriter], StreamingHandlerConns write
|
||||
// response headers to the network with the first call to Send. Any subsequent
|
||||
// mutations are effectively no-ops. Handlers may mutate response trailers at
|
||||
// any time before returning. When the client has finished sending data,
|
||||
// Receive returns an error wrapping [io.EOF]. Handlers should check for this
|
||||
// using the standard library's [errors.Is].
|
||||
//
|
||||
// Headers and trailers beginning with "Connect-" and "Grpc-" are reserved for
|
||||
// use by the gRPC and Connect protocols: applications may read them but
|
||||
// shouldn't write them.
|
||||
//
|
||||
// StreamingHandlerConn implementations provided by this module guarantee that
|
||||
// all returned errors can be cast to [*Error] using the standard library's
|
||||
// [errors.As].
|
||||
//
|
||||
// StreamingHandlerConn implementations do not need to be safe for concurrent use.
|
||||
type StreamingHandlerConn interface {
|
||||
Spec() Spec
|
||||
Peer() Peer
|
||||
|
||||
Receive(any) error
|
||||
RequestHeader() http.Header
|
||||
|
||||
Send(any) error
|
||||
ResponseHeader() http.Header
|
||||
ResponseTrailer() http.Header
|
||||
}
|
||||
|
||||
// StreamingClientConn is the client's view of a bidirectional message exchange.
|
||||
// Interceptors for streaming RPCs may wrap StreamingClientConns.
|
||||
//
|
||||
// StreamingClientConns write request headers to the network with the first
|
||||
// call to Send. Any subsequent mutations are effectively no-ops. When the
|
||||
// server is done sending data, the StreamingClientConn's Receive method
|
||||
// returns an error wrapping [io.EOF]. Clients should check for this using the
|
||||
// standard library's [errors.Is]. If the server encounters an error during
|
||||
// processing, subsequent calls to the StreamingClientConn's Send method will
|
||||
// return an error wrapping [io.EOF]; clients may then call Receive to unmarshal
|
||||
// the error.
|
||||
//
|
||||
// Headers and trailers beginning with "Connect-" and "Grpc-" are reserved for
|
||||
// use by the gRPC and Connect protocols: applications may read them but
|
||||
// shouldn't write them.
|
||||
//
|
||||
// StreamingClientConn implementations provided by this module guarantee that
|
||||
// all returned errors can be cast to [*Error] using the standard library's
|
||||
// [errors.As].
|
||||
//
|
||||
// In order to support bidirectional streaming RPCs, all StreamingClientConn
|
||||
// implementations must support limited concurrent use. See the comments on
|
||||
// each group of methods for details.
|
||||
type StreamingClientConn interface {
|
||||
// Spec and Peer must be safe to call concurrently with all other methods.
|
||||
Spec() Spec
|
||||
Peer() Peer
|
||||
|
||||
// Send, RequestHeader, and CloseRequest may race with each other, but must
|
||||
// be safe to call concurrently with all other methods.
|
||||
Send(any) error
|
||||
RequestHeader() http.Header
|
||||
CloseRequest() error
|
||||
|
||||
// Receive, ResponseHeader, ResponseTrailer, and CloseResponse may race with
|
||||
// each other, but must be safe to call concurrently with all other methods.
|
||||
Receive(any) error
|
||||
ResponseHeader() http.Header
|
||||
ResponseTrailer() http.Header
|
||||
CloseResponse() error
|
||||
}
|
||||
|
||||
// Request is a wrapper around a generated request message. It provides
|
||||
// access to metadata like headers and the RPC specification, as well as
|
||||
// strongly-typed access to the message itself.
|
||||
type Request[T any] struct {
|
||||
Msg *T
|
||||
|
||||
spec Spec
|
||||
peer Peer
|
||||
header http.Header
|
||||
method string
|
||||
}
|
||||
|
||||
// NewRequest wraps a generated request message.
|
||||
func NewRequest[T any](message *T) *Request[T] {
|
||||
return &Request[T]{
|
||||
Msg: message,
|
||||
// Initialized lazily so we don't allocate unnecessarily.
|
||||
header: nil,
|
||||
}
|
||||
}
|
||||
|
||||
// Any returns the concrete request message as an empty interface, so that
|
||||
// *Request implements the [AnyRequest] interface.
|
||||
func (r *Request[_]) Any() any {
|
||||
return r.Msg
|
||||
}
|
||||
|
||||
// Spec returns a description of this RPC.
|
||||
func (r *Request[_]) Spec() Spec {
|
||||
return r.spec
|
||||
}
|
||||
|
||||
// Peer describes the other party for this RPC.
|
||||
func (r *Request[_]) Peer() Peer {
|
||||
return r.peer
|
||||
}
|
||||
|
||||
// Header returns the HTTP headers for this request. Headers beginning with
|
||||
// "Connect-" and "Grpc-" are reserved for use by the Connect and gRPC
|
||||
// protocols: applications may read them but shouldn't write them.
|
||||
func (r *Request[_]) Header() http.Header {
|
||||
if r.header == nil {
|
||||
r.header = make(http.Header)
|
||||
}
|
||||
return r.header
|
||||
}
|
||||
|
||||
// HTTPMethod returns the HTTP method for this request. This is nearly always
|
||||
// POST, but side-effect-free unary RPCs could be made via a GET.
|
||||
//
|
||||
// On a newly created request, via NewRequest, this will return the empty
|
||||
// string until the actual request is actually sent and the HTTP method
|
||||
// determined. This means that client interceptor functions will see the
|
||||
// empty string until *after* they delegate to the handler they wrapped. It
|
||||
// is even possible for this to return the empty string after such delegation,
|
||||
// if the request was never actually sent to the server (and thus no
|
||||
// determination ever made about the HTTP method).
|
||||
func (r *Request[_]) HTTPMethod() string {
|
||||
return r.method
|
||||
}
|
||||
|
||||
// internalOnly implements AnyRequest.
|
||||
func (r *Request[_]) internalOnly() {}
|
||||
|
||||
// setRequestMethod sets the request method to the given value.
|
||||
func (r *Request[_]) setRequestMethod(method string) {
|
||||
r.method = method
|
||||
}
|
||||
|
||||
// AnyRequest is the common method set of every [Request], regardless of type
|
||||
// parameter. It's used in unary interceptors.
|
||||
//
|
||||
// Headers and trailers beginning with "Connect-" and "Grpc-" are reserved for
|
||||
// use by the gRPC and Connect protocols: applications may read them but
|
||||
// shouldn't write them.
|
||||
//
|
||||
// To preserve our ability to add methods to this interface without breaking
|
||||
// backward compatibility, only types defined in this package can implement
|
||||
// AnyRequest.
|
||||
type AnyRequest interface {
|
||||
Any() any
|
||||
Spec() Spec
|
||||
Peer() Peer
|
||||
Header() http.Header
|
||||
HTTPMethod() string
|
||||
|
||||
internalOnly()
|
||||
setRequestMethod(string)
|
||||
}
|
||||
|
||||
// Response is a wrapper around a generated response message. It provides
|
||||
// access to metadata like headers and trailers, as well as strongly-typed
|
||||
// access to the message itself.
|
||||
type Response[T any] struct {
|
||||
Msg *T
|
||||
|
||||
header http.Header
|
||||
trailer http.Header
|
||||
}
|
||||
|
||||
// NewResponse wraps a generated response message.
|
||||
func NewResponse[T any](message *T) *Response[T] {
|
||||
return &Response[T]{
|
||||
Msg: message,
|
||||
// Initialized lazily so we don't allocate unnecessarily.
|
||||
header: nil,
|
||||
trailer: nil,
|
||||
}
|
||||
}
|
||||
|
||||
// Any returns the concrete response message as an empty interface, so that
|
||||
// *Response implements the [AnyResponse] interface.
|
||||
func (r *Response[_]) Any() any {
|
||||
return r.Msg
|
||||
}
|
||||
|
||||
// Header returns the HTTP headers for this response. Headers beginning with
|
||||
// "Connect-" and "Grpc-" are reserved for use by the Connect and gRPC
|
||||
// protocols: applications may read them but shouldn't write them.
|
||||
func (r *Response[_]) Header() http.Header {
|
||||
if r.header == nil {
|
||||
r.header = make(http.Header)
|
||||
}
|
||||
return r.header
|
||||
}
|
||||
|
||||
// Trailer returns the trailers for this response. Depending on the underlying
|
||||
// RPC protocol, trailers may be sent as HTTP trailers or a protocol-specific
|
||||
// block of in-body metadata.
|
||||
//
|
||||
// Trailers beginning with "Connect-" and "Grpc-" are reserved for use by the
|
||||
// Connect and gRPC protocols: applications may read them but shouldn't write
|
||||
// them.
|
||||
func (r *Response[_]) Trailer() http.Header {
|
||||
if r.trailer == nil {
|
||||
r.trailer = make(http.Header)
|
||||
}
|
||||
return r.trailer
|
||||
}
|
||||
|
||||
// internalOnly implements AnyResponse.
|
||||
func (r *Response[_]) internalOnly() {}
|
||||
|
||||
// AnyResponse is the common method set of every [Response], regardless of type
|
||||
// parameter. It's used in unary interceptors.
|
||||
//
|
||||
// Headers and trailers beginning with "Connect-" and "Grpc-" are reserved for
|
||||
// use by the gRPC and Connect protocols: applications may read them but
|
||||
// shouldn't write them.
|
||||
//
|
||||
// To preserve our ability to add methods to this interface without breaking
|
||||
// backward compatibility, only types defined in this package can implement
|
||||
// AnyResponse.
|
||||
type AnyResponse interface {
|
||||
Any() any
|
||||
Header() http.Header
|
||||
Trailer() http.Header
|
||||
|
||||
internalOnly()
|
||||
}
|
||||
|
||||
// HTTPClient is the interface connect expects HTTP clients to implement. The
|
||||
// standard library's *http.Client implements HTTPClient.
|
||||
type HTTPClient interface {
|
||||
Do(*http.Request) (*http.Response, error)
|
||||
}
|
||||
|
||||
// Spec is a description of a client call or a handler invocation.
|
||||
//
|
||||
// If you're using Protobuf, protoc-gen-connect-go generates a constant for the
|
||||
// fully-qualified Procedure corresponding to each RPC in your schema.
|
||||
type Spec struct {
|
||||
StreamType StreamType
|
||||
Schema any // for protobuf RPCs, a protoreflect.MethodDescriptor
|
||||
Procedure string // for example, "/acme.foo.v1.FooService/Bar"
|
||||
IsClient bool // otherwise we're in a handler
|
||||
IdempotencyLevel IdempotencyLevel
|
||||
}
|
||||
|
||||
// Peer describes the other party to an RPC.
|
||||
//
|
||||
// When accessed client-side, Addr contains the host or host:port from the
|
||||
// server's URL. When accessed server-side, Addr contains the client's address
|
||||
// in IP:port format.
|
||||
//
|
||||
// On both the client and the server, Protocol is the RPC protocol in use.
|
||||
// Currently, it's either [ProtocolConnect], [ProtocolGRPC], or
|
||||
// [ProtocolGRPCWeb], but additional protocols may be added in the future.
|
||||
//
|
||||
// Query contains the query parameters for the request. For the server, this
|
||||
// will reflect the actual query parameters sent. For the client, it is unset.
|
||||
type Peer struct {
|
||||
Addr string
|
||||
Protocol string
|
||||
Query url.Values // server-only
|
||||
}
|
||||
|
||||
func newPeerForURL(url *url.URL, protocol string) Peer {
|
||||
return Peer{
|
||||
Addr: url.Host,
|
||||
Protocol: protocol,
|
||||
}
|
||||
}
|
||||
|
||||
// handlerConnCloser extends StreamingHandlerConn with a method for handlers to
|
||||
// terminate the message exchange (and optionally send an error to the client).
|
||||
type handlerConnCloser interface {
|
||||
StreamingHandlerConn
|
||||
|
||||
Close(error) error
|
||||
}
|
||||
|
||||
// receiveConn represents the shared methods of both StreamingClientConn and StreamingHandlerConn
|
||||
// that the below helper functions use for implementing the rules around a "unary" stream, that
|
||||
// is expected to have exactly one message (or zero messages followed by a non-EOF error).
|
||||
type receiveConn interface {
|
||||
Spec() Spec
|
||||
Receive(any) error
|
||||
}
|
||||
|
||||
// hasHTTPMethod is implemented by streaming connections that support HTTP methods other than
|
||||
// POST.
|
||||
type hasHTTPMethod interface {
|
||||
getHTTPMethod() string
|
||||
}
|
||||
|
||||
// errStreamingClientConn is a sentinel error implementation of StreamingClientConn.
|
||||
type errStreamingClientConn struct {
|
||||
err error
|
||||
}
|
||||
|
||||
func (c *errStreamingClientConn) Receive(msg any) error {
|
||||
return c.err
|
||||
}
|
||||
|
||||
func (c *errStreamingClientConn) Spec() Spec {
|
||||
return Spec{}
|
||||
}
|
||||
|
||||
func (c *errStreamingClientConn) Peer() Peer {
|
||||
return Peer{}
|
||||
}
|
||||
|
||||
func (c *errStreamingClientConn) Send(msg any) error {
|
||||
return c.err
|
||||
}
|
||||
|
||||
func (c *errStreamingClientConn) CloseRequest() error {
|
||||
return c.err
|
||||
}
|
||||
|
||||
func (c *errStreamingClientConn) CloseResponse() error {
|
||||
return c.err
|
||||
}
|
||||
|
||||
func (c *errStreamingClientConn) RequestHeader() http.Header {
|
||||
return make(http.Header)
|
||||
}
|
||||
|
||||
func (c *errStreamingClientConn) ResponseHeader() http.Header {
|
||||
return make(http.Header)
|
||||
}
|
||||
|
||||
func (c *errStreamingClientConn) ResponseTrailer() http.Header {
|
||||
return make(http.Header)
|
||||
}
|
||||
|
||||
// receiveUnaryResponse unmarshals a message from a StreamingClientConn, then
|
||||
// envelopes the message and attaches headers and trailers. It attempts to
|
||||
// consume the response stream and isn't appropriate when receiving multiple
|
||||
// messages.
|
||||
func receiveUnaryResponse[T any](conn StreamingClientConn, initializer maybeInitializer) (*Response[T], error) {
|
||||
msg, err := receiveUnaryMessage[T](conn, initializer, "response")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Response[T]{
|
||||
Msg: msg,
|
||||
header: conn.ResponseHeader(),
|
||||
trailer: conn.ResponseTrailer(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// receiveUnaryRequest unmarshals a message from a StreamingClientConn, then
|
||||
// envelopes the message and attaches headers and other request properties. It
|
||||
// attempts to consume the request stream and isn't appropriate when receiving
|
||||
// multiple messages.
|
||||
func receiveUnaryRequest[T any](conn StreamingHandlerConn, initializer maybeInitializer) (*Request[T], error) {
|
||||
msg, err := receiveUnaryMessage[T](conn, initializer, "request")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
method := http.MethodPost
|
||||
if hasRequestMethod, ok := conn.(hasHTTPMethod); ok {
|
||||
method = hasRequestMethod.getHTTPMethod()
|
||||
}
|
||||
return &Request[T]{
|
||||
Msg: msg,
|
||||
spec: conn.Spec(),
|
||||
peer: conn.Peer(),
|
||||
header: conn.RequestHeader(),
|
||||
method: method,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func receiveUnaryMessage[T any](conn receiveConn, initializer maybeInitializer, what string) (*T, error) {
|
||||
var msg T
|
||||
if err := initializer.maybe(conn.Spec(), &msg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Possibly counter-intuitive, but the gRPC specs about error codes state that both clients
|
||||
// and servers should return "unimplemented" when they encounter a cardinality violation: where
|
||||
// the number of messages in the stream is wrong. Search for "cardinality violation" in the
|
||||
// following docs:
|
||||
// https://grpc.github.io/grpc/core/md_doc_statuscodes.html
|
||||
if err := conn.Receive(&msg); err != nil {
|
||||
if errors.Is(err, io.EOF) {
|
||||
err = NewError(CodeUnimplemented, fmt.Errorf("unary %s has zero messages", what))
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
// In a well-formed stream, the one message must be the only content in the body.
|
||||
// To verify that it is well-formed, try to read another message from the stream.
|
||||
// TODO: optimize this second receive: ideally do it w/out allocation, w/out
|
||||
// fully reading next message (if one is present), and w/out trying to
|
||||
// actually unmarshal the bytes)
|
||||
var msg2 T
|
||||
if err := initializer.maybe(conn.Spec(), &msg2); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := conn.Receive(&msg2); !errors.Is(err, io.EOF) {
|
||||
if err == nil {
|
||||
err = NewError(CodeUnimplemented, fmt.Errorf("unary %s has multiple messages", what))
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &msg, nil
|
||||
}
|
||||
+242
@@ -0,0 +1,242 @@
|
||||
// Copyright 2021-2025 The Connect Authors
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package connect
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// CallInfo represents information relevant to an RPC call.
|
||||
type CallInfo interface {
|
||||
// Spec returns a description of this call.
|
||||
Spec() Spec
|
||||
// Peer describes the other party for this call.
|
||||
Peer() Peer
|
||||
// RequestHeader returns the HTTP headers for this request. Headers beginning with
|
||||
// "Connect-" and "Grpc-" are reserved for use by the Connect and gRPC
|
||||
// protocols: applications may read them but shouldn't write them.
|
||||
RequestHeader() http.Header
|
||||
// ResponseHeader returns the HTTP headers for this response. Headers beginning with
|
||||
// "Connect-" and "Grpc-" are reserved for use by the Connect and gRPC
|
||||
// protocols: applications may read them but shouldn't write them.
|
||||
// On the client side, this method returns nil before
|
||||
// the call is actually made. After the call is made, for streaming operations,
|
||||
// this method will block for the server to actually return response headers.
|
||||
ResponseHeader() http.Header
|
||||
// ResponseTrailer returns the trailers for this response. Depending on the underlying
|
||||
// RPC protocol, trailers may be sent as HTTP trailers or a protocol-specific
|
||||
// block of in-body metadata.
|
||||
//
|
||||
// Trailers beginning with "Connect-" and "Grpc-" are reserved for use by the
|
||||
// Connect and gRPC protocols: applications may read them but shouldn't write
|
||||
// them.
|
||||
//
|
||||
// On the client side, this method returns nil before the call is actually made.
|
||||
// After the call is made, for streaming operations, this method will block
|
||||
// for the server to actually return response trailers.
|
||||
ResponseTrailer() http.Header
|
||||
// HTTPMethod returns the HTTP method for this request. This is nearly always
|
||||
// POST, but side-effect-free unary RPCs could be made via a GET.
|
||||
//
|
||||
// On a newly created request, via NewRequest, this will return the empty
|
||||
// string until the actual request is actually sent and the HTTP method
|
||||
// determined. This means that client interceptor functions will see the
|
||||
// empty string until *after* they delegate to the handler they wrapped. It
|
||||
// is even possible for this to return the empty string after such delegation,
|
||||
// if the request was never actually sent to the server (and thus no
|
||||
// determination ever made about the HTTP method).
|
||||
HTTPMethod() string
|
||||
|
||||
internalOnly()
|
||||
}
|
||||
|
||||
// Create a new client (i.e. outgoing) context for use from a client. When the
|
||||
// returned context is passed to RPCs, the returned call info can be used to set
|
||||
// request metadata before the RPC is invoked and to inspect response
|
||||
// metadata after the RPC completes.
|
||||
//
|
||||
// The returned context may be re-used across RPCs as long as they are
|
||||
// not concurrent. Results of all CallInfo methods other than
|
||||
// RequestHeader() are undefined if the context is used with concurrent RPCs.
|
||||
func NewClientContext(ctx context.Context) (context.Context, CallInfo) {
|
||||
info := &clientCallInfo{}
|
||||
return context.WithValue(ctx, clientCallInfoContextKey{}, info), info
|
||||
}
|
||||
|
||||
// CallInfoForHandlerContext returns the CallInfo for the given handler (i.e. incoming) context, if there is one.
|
||||
func CallInfoForHandlerContext(ctx context.Context) (CallInfo, bool) {
|
||||
value, ok := ctx.Value(handlerCallInfoContextKey{}).(CallInfo)
|
||||
return value, ok
|
||||
}
|
||||
|
||||
// handlerCallInfo is a CallInfo implementation used for unary handlers.
|
||||
type handlerCallInfo struct {
|
||||
spec Spec
|
||||
peer Peer
|
||||
method string
|
||||
requestHeader http.Header
|
||||
responseHeader http.Header
|
||||
responseTrailer http.Header
|
||||
}
|
||||
|
||||
func (c *handlerCallInfo) Spec() Spec {
|
||||
return c.spec
|
||||
}
|
||||
|
||||
func (c *handlerCallInfo) Peer() Peer {
|
||||
return c.peer
|
||||
}
|
||||
|
||||
func (c *handlerCallInfo) RequestHeader() http.Header {
|
||||
if c.requestHeader == nil {
|
||||
c.requestHeader = make(http.Header)
|
||||
}
|
||||
return c.requestHeader
|
||||
}
|
||||
|
||||
func (c *handlerCallInfo) ResponseHeader() http.Header {
|
||||
if c.responseHeader == nil {
|
||||
c.responseHeader = make(http.Header)
|
||||
}
|
||||
return c.responseHeader
|
||||
}
|
||||
|
||||
func (c *handlerCallInfo) ResponseTrailer() http.Header {
|
||||
if c.responseTrailer == nil {
|
||||
c.responseTrailer = make(http.Header)
|
||||
}
|
||||
return c.responseTrailer
|
||||
}
|
||||
|
||||
func (c *handlerCallInfo) HTTPMethod() string {
|
||||
return c.method
|
||||
}
|
||||
|
||||
// internalOnly implements CallInfo.
|
||||
func (c *handlerCallInfo) internalOnly() {}
|
||||
|
||||
// streamingHandlerCallInfo is a CallInfo implementation used for streaming RPC handlers.
|
||||
type streamingHandlerCallInfo struct {
|
||||
conn StreamingHandlerConn
|
||||
}
|
||||
|
||||
func (c *streamingHandlerCallInfo) Spec() Spec {
|
||||
return c.conn.Spec()
|
||||
}
|
||||
|
||||
func (c *streamingHandlerCallInfo) Peer() Peer {
|
||||
return c.conn.Peer()
|
||||
}
|
||||
|
||||
func (c *streamingHandlerCallInfo) RequestHeader() http.Header {
|
||||
return c.conn.RequestHeader()
|
||||
}
|
||||
|
||||
func (c *streamingHandlerCallInfo) ResponseHeader() http.Header {
|
||||
return c.conn.ResponseHeader()
|
||||
}
|
||||
|
||||
func (c *streamingHandlerCallInfo) ResponseTrailer() http.Header {
|
||||
return c.conn.ResponseTrailer()
|
||||
}
|
||||
|
||||
func (c *streamingHandlerCallInfo) HTTPMethod() string {
|
||||
// All stream calls are POSTs
|
||||
return http.MethodPost
|
||||
}
|
||||
|
||||
// internalOnly implements CallInfo.
|
||||
func (c *streamingHandlerCallInfo) internalOnly() {}
|
||||
|
||||
// clientCallInfo is a CallInfo implementation used for clients.
|
||||
type clientCallInfo struct {
|
||||
responseSource
|
||||
spec Spec
|
||||
peer Peer
|
||||
method string
|
||||
requestHeader http.Header
|
||||
}
|
||||
|
||||
func (c *clientCallInfo) Spec() Spec {
|
||||
return c.spec
|
||||
}
|
||||
|
||||
func (c *clientCallInfo) Peer() Peer {
|
||||
return c.peer
|
||||
}
|
||||
|
||||
func (c *clientCallInfo) RequestHeader() http.Header {
|
||||
if c.requestHeader == nil {
|
||||
c.requestHeader = make(http.Header)
|
||||
}
|
||||
return c.requestHeader
|
||||
}
|
||||
|
||||
func (c *clientCallInfo) ResponseHeader() http.Header {
|
||||
if c.responseSource == nil {
|
||||
return nil
|
||||
}
|
||||
return c.responseSource.ResponseHeader()
|
||||
}
|
||||
|
||||
func (c *clientCallInfo) ResponseTrailer() http.Header {
|
||||
if c.responseSource == nil {
|
||||
return nil
|
||||
}
|
||||
return c.responseSource.ResponseTrailer()
|
||||
}
|
||||
|
||||
func (c *clientCallInfo) HTTPMethod() string {
|
||||
return c.method
|
||||
}
|
||||
|
||||
// internalOnly implements CallInfo.
|
||||
func (c *clientCallInfo) internalOnly() {}
|
||||
|
||||
// clientCallInfoContextKey is the key used to store client call info in context.
|
||||
type clientCallInfoContextKey struct{}
|
||||
|
||||
// sentinelContextKey is the key used to store a copy of client call info in context
|
||||
// when a request is made.
|
||||
// Each step in an interceptor chain compares the actual call info with the
|
||||
// sentinel call info. If the two values are different, the request will
|
||||
// return an error in the interceptor.
|
||||
// This protects against changing the call info in interceptors, which is prohibited
|
||||
// as it would allow users to modify call info mid-flight independent of the actual
|
||||
// request or response.
|
||||
// Users who wish to modify call info data such as headers and trailers should instead
|
||||
// use Connect [Request] and [Response] wrapper types.
|
||||
type sentinelContextKey struct{}
|
||||
|
||||
// handlerCallInfoContextKey is the key used to store handler call info in context.
|
||||
type handlerCallInfoContextKey struct{}
|
||||
|
||||
// responseSource indicates a type that manages response headers and trailers.
|
||||
type responseSource interface {
|
||||
ResponseHeader() http.Header
|
||||
ResponseTrailer() http.Header
|
||||
}
|
||||
|
||||
// clientCallInfoForContext gets the call info from a client/outgoing context.
|
||||
func clientCallInfoForContext(ctx context.Context) (*clientCallInfo, bool) {
|
||||
info, ok := ctx.Value(clientCallInfoContextKey{}).(*clientCallInfo)
|
||||
return info, ok
|
||||
}
|
||||
|
||||
// newHandlerContext creates a new handler/incoming context.
|
||||
func newHandlerContext(ctx context.Context, info CallInfo) context.Context {
|
||||
return context.WithValue(ctx, handlerCallInfoContextKey{}, info)
|
||||
}
|
||||
+471
@@ -0,0 +1,471 @@
|
||||
// Copyright 2021-2025 The Connect Authors
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package connect
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
)
|
||||
|
||||
// duplexHTTPCall is a full-duplex stream between the client and server. The
|
||||
// request body is the stream from client to server, and the response body is
|
||||
// the reverse.
|
||||
//
|
||||
// Be warned: we need to use some lesser-known APIs to do this with net/http.
|
||||
type duplexHTTPCall struct {
|
||||
ctx context.Context
|
||||
httpClient HTTPClient
|
||||
streamType StreamType
|
||||
onRequestSend func(*http.Request)
|
||||
validateResponse func(*http.Response) *Error
|
||||
|
||||
// io.Pipe is used to implement the request body for client streaming calls.
|
||||
// If the request is unary, requestBodyWriter is nil.
|
||||
requestBodyWriter *io.PipeWriter
|
||||
|
||||
// requestSent ensures we only send the request once.
|
||||
requestSent atomic.Bool
|
||||
request *http.Request
|
||||
|
||||
// responseReady is closed when the response is ready or when the request
|
||||
// fails. Any error on request initialisation will be set on the
|
||||
// responseErr. There's always a response if responseErr is nil.
|
||||
responseReady chan struct{}
|
||||
response *http.Response
|
||||
responseErr error
|
||||
}
|
||||
|
||||
func newDuplexHTTPCall(
|
||||
ctx context.Context,
|
||||
httpClient HTTPClient,
|
||||
url *url.URL,
|
||||
spec Spec,
|
||||
header http.Header,
|
||||
) *duplexHTTPCall {
|
||||
// ensure we make a copy of the url before we pass along to the
|
||||
// Request. This ensures if a transport out of our control wants
|
||||
// to mutate the req.URL, we don't feel the effects of it.
|
||||
url = cloneURL(url)
|
||||
|
||||
// This is mirroring what http.NewRequestContext did, but
|
||||
// using an already parsed url.URL object, rather than a string
|
||||
// and parsing it again. This is a bit funny with HTTP/1.1
|
||||
// explicitly, but this is logic copied over from
|
||||
// NewRequestContext and doesn't effect the actual version
|
||||
// being transmitted.
|
||||
request := (&http.Request{
|
||||
Method: http.MethodPost,
|
||||
URL: url,
|
||||
Header: header,
|
||||
Proto: "HTTP/1.1",
|
||||
ProtoMajor: 1,
|
||||
ProtoMinor: 1,
|
||||
Body: http.NoBody,
|
||||
GetBody: getNoBody,
|
||||
Host: url.Host,
|
||||
}).WithContext(ctx)
|
||||
return &duplexHTTPCall{
|
||||
ctx: ctx,
|
||||
httpClient: httpClient,
|
||||
streamType: spec.StreamType,
|
||||
request: request,
|
||||
responseReady: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
// Send sends a message to the server.
|
||||
func (d *duplexHTTPCall) Send(payload messagePayload) (int64, error) {
|
||||
if d.streamType&StreamTypeClient == 0 {
|
||||
return d.sendUnary(payload)
|
||||
}
|
||||
isFirst := d.requestSent.CompareAndSwap(false, true)
|
||||
if isFirst {
|
||||
// This is the first time we're sending a message to the server.
|
||||
// We need to send the request headers and start the request.
|
||||
pipeReader, pipeWriter := io.Pipe()
|
||||
d.requestBodyWriter = pipeWriter
|
||||
d.request.Body = pipeReader
|
||||
d.request.GetBody = nil // GetBody not supported for client streaming
|
||||
d.request.ContentLength = -1
|
||||
go d.makeRequest() // concurrent request
|
||||
}
|
||||
if err := d.ctx.Err(); err != nil {
|
||||
return 0, wrapIfContextError(err)
|
||||
}
|
||||
if isFirst && payload.Len() == 0 {
|
||||
// On first write a nil Send is used to send request headers. Avoid
|
||||
// writing a zero-length payload to avoid superfluous errors with close.
|
||||
return 0, nil
|
||||
}
|
||||
// It's safe to write to this side of the pipe while net/http concurrently
|
||||
// reads from the other side.
|
||||
bytesWritten, err := payload.WriteTo(d.requestBodyWriter)
|
||||
if err != nil && errors.Is(err, io.ErrClosedPipe) {
|
||||
// Signal that the stream is closed with the more-typical io.EOF instead of
|
||||
// io.ErrClosedPipe. This makes it easier for protocol-specific wrappers to
|
||||
// match grpc-go's behavior.
|
||||
err = io.EOF
|
||||
}
|
||||
return bytesWritten, err
|
||||
}
|
||||
|
||||
func (d *duplexHTTPCall) sendUnary(payload messagePayload) (int64, error) {
|
||||
// Unary messages are sent as a single HTTP request. We don't need to use a
|
||||
// pipe for the request body and we don't need to send headers separately.
|
||||
if !d.requestSent.CompareAndSwap(false, true) {
|
||||
return 0, errors.New("request already sent")
|
||||
}
|
||||
payloadLength := int64(payload.Len())
|
||||
if payloadLength > 0 {
|
||||
// Build the request body from the payload.
|
||||
payloadBody := newPayloadCloser(payload)
|
||||
d.request.Body = payloadBody
|
||||
d.request.ContentLength = payloadLength
|
||||
d.request.GetBody = func() (io.ReadCloser, error) {
|
||||
if !payloadBody.Rewind() {
|
||||
return nil, errors.New("payload cannot be retried")
|
||||
}
|
||||
return payloadBody, nil
|
||||
}
|
||||
// Release the payload ensuring that after Send returns the
|
||||
// payload is safe to be reused. See [http.RoundTripper] for
|
||||
// more details.
|
||||
defer payloadBody.Release()
|
||||
}
|
||||
d.makeRequest() // synchronous request
|
||||
if d.responseErr != nil {
|
||||
// Check on response errors for context errors. Other errors are
|
||||
// handled on read.
|
||||
if err := d.ctx.Err(); err != nil {
|
||||
return 0, wrapIfContextError(err)
|
||||
}
|
||||
}
|
||||
return payloadLength, nil
|
||||
}
|
||||
|
||||
// CloseWrite closes the request body. Callers *must* call CloseWrite before Read when
|
||||
// using HTTP/1.x.
|
||||
func (d *duplexHTTPCall) CloseWrite() error {
|
||||
// Even if Write was never called, we need to make an HTTP request. This
|
||||
// ensures that we've sent any headers to the server and that we have an HTTP
|
||||
// response to read from.
|
||||
if d.requestSent.CompareAndSwap(false, true) {
|
||||
go d.makeRequest()
|
||||
// We never setup a request body, so it's effectively already closed.
|
||||
// So nothing else to do.
|
||||
return nil
|
||||
}
|
||||
// The user calls CloseWrite to indicate that they're done sending data. It's
|
||||
// safe to close the write side of the pipe while net/http is reading from
|
||||
// it.
|
||||
//
|
||||
// Because connect also supports some RPC types over HTTP/1.1, we need to be
|
||||
// careful how we expose this method to users. HTTP/1.1 doesn't support
|
||||
// bidirectional streaming - the write side of the stream (aka request body)
|
||||
// must be closed before we start reading the response or we'll just block
|
||||
// forever. To make sure users don't have to worry about this, the generated
|
||||
// code for unary, client streaming, and server streaming RPCs must call
|
||||
// CloseWrite automatically rather than requiring the user to do it.
|
||||
if d.requestBodyWriter != nil {
|
||||
return d.requestBodyWriter.Close()
|
||||
}
|
||||
return d.request.Body.Close()
|
||||
}
|
||||
|
||||
// Header returns the HTTP request headers.
|
||||
func (d *duplexHTTPCall) Header() http.Header {
|
||||
return d.request.Header
|
||||
}
|
||||
|
||||
// Trailer returns the HTTP request trailers.
|
||||
func (d *duplexHTTPCall) Trailer() http.Header {
|
||||
return d.request.Trailer
|
||||
}
|
||||
|
||||
// URL returns the URL for the request.
|
||||
func (d *duplexHTTPCall) URL() *url.URL {
|
||||
return d.request.URL
|
||||
}
|
||||
|
||||
// Method returns the HTTP method for the request (GET or POST).
|
||||
func (d *duplexHTTPCall) Method() string {
|
||||
return d.request.Method
|
||||
}
|
||||
|
||||
// SetMethod changes the method of the request before it is sent.
|
||||
func (d *duplexHTTPCall) SetMethod(method string) {
|
||||
d.request.Method = method
|
||||
}
|
||||
|
||||
// Read from the response body. Returns the first error passed to SetError.
|
||||
func (d *duplexHTTPCall) Read(data []byte) (int, error) {
|
||||
// First, we wait until we've gotten the response headers and established the
|
||||
// server-to-client side of the stream.
|
||||
if err := d.BlockUntilResponseReady(); err != nil {
|
||||
// The stream is already closed or corrupted.
|
||||
return 0, err
|
||||
}
|
||||
// Before we read, check if the context has been canceled.
|
||||
if err := d.ctx.Err(); err != nil {
|
||||
return 0, wrapIfContextError(err)
|
||||
}
|
||||
n, err := d.response.Body.Read(data)
|
||||
if err != nil && !errors.Is(err, io.EOF) {
|
||||
err = wrapIfContextDone(d.ctx, err)
|
||||
err = wrapIfRSTError(err)
|
||||
}
|
||||
return n, err
|
||||
}
|
||||
|
||||
func (d *duplexHTTPCall) CloseRead() error {
|
||||
_ = d.BlockUntilResponseReady()
|
||||
if d.response == nil {
|
||||
return nil
|
||||
}
|
||||
err := d.response.Body.Close()
|
||||
err = wrapIfContextDone(d.ctx, err)
|
||||
return wrapIfRSTError(err)
|
||||
}
|
||||
|
||||
// ResponseStatusCode is the response's HTTP status code.
|
||||
func (d *duplexHTTPCall) ResponseStatusCode() (int, error) {
|
||||
if err := d.BlockUntilResponseReady(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return d.response.StatusCode, nil
|
||||
}
|
||||
|
||||
// ResponseHeader returns the response HTTP headers.
|
||||
func (d *duplexHTTPCall) ResponseHeader() http.Header {
|
||||
_ = d.BlockUntilResponseReady()
|
||||
if d.response != nil {
|
||||
return d.response.Header
|
||||
}
|
||||
return make(http.Header)
|
||||
}
|
||||
|
||||
// ResponseTrailer returns the response HTTP trailers.
|
||||
func (d *duplexHTTPCall) ResponseTrailer() http.Header {
|
||||
_ = d.BlockUntilResponseReady()
|
||||
if d.response != nil {
|
||||
return d.response.Trailer
|
||||
}
|
||||
return make(http.Header)
|
||||
}
|
||||
|
||||
// SetValidateResponse sets the response validation function. The function runs
|
||||
// in a background goroutine.
|
||||
func (d *duplexHTTPCall) SetValidateResponse(validate func(*http.Response) *Error) {
|
||||
d.validateResponse = validate
|
||||
}
|
||||
|
||||
// BlockUntilResponseReady returns when the response is ready or reports an
|
||||
// error from initializing the request.
|
||||
func (d *duplexHTTPCall) BlockUntilResponseReady() error {
|
||||
<-d.responseReady
|
||||
return d.responseErr
|
||||
}
|
||||
|
||||
func (d *duplexHTTPCall) makeRequest() {
|
||||
// This runs concurrently with Write and CloseWrite. Read and CloseRead wait
|
||||
// on d.responseReady, so we can't race with them.
|
||||
defer close(d.responseReady)
|
||||
|
||||
// Promote the header Host to the request object.
|
||||
if host := getHeaderCanonical(d.request.Header, headerHost); len(host) > 0 {
|
||||
d.request.Host = host
|
||||
}
|
||||
if d.onRequestSend != nil {
|
||||
d.onRequestSend(d.request)
|
||||
}
|
||||
// Once we send a message to the server, they send a message back and
|
||||
// establish the receive side of the stream.
|
||||
// On error, we close the request body using the Write side of the pipe.
|
||||
// This ensures HTTP2 streams receive an io.EOF from the Read side of the
|
||||
// pipe. Write's check for io.ErrClosedPipe and will convert this to io.EOF.
|
||||
response, err := d.httpClient.Do(d.request) //nolint:bodyclose
|
||||
if err != nil {
|
||||
if errors.Is(err, io.EOF) {
|
||||
// We use io.EOF as a sentinel in many places and don't want this
|
||||
// transport error to be confused for those other situations.
|
||||
err = io.ErrUnexpectedEOF
|
||||
}
|
||||
err = wrapIfContextError(err)
|
||||
err = wrapIfLikelyH2CNotConfiguredError(d.request, err)
|
||||
err = wrapIfLikelyWithGRPCNotUsedError(err)
|
||||
err = wrapIfRSTError(err)
|
||||
if _, ok := asError(err); !ok {
|
||||
err = NewError(CodeUnavailable, err)
|
||||
}
|
||||
d.responseErr = err
|
||||
_ = d.CloseWrite()
|
||||
return
|
||||
}
|
||||
// We've got a response. We can now read from the response body.
|
||||
// Closing the response body is delegated to the caller even on error.
|
||||
d.response = response
|
||||
if err := d.validateResponse(response); err != nil {
|
||||
d.responseErr = err
|
||||
_ = d.CloseWrite()
|
||||
return
|
||||
}
|
||||
if (d.streamType&StreamTypeBidi) == StreamTypeBidi && response.ProtoMajor < 2 {
|
||||
// If we somehow dialed an HTTP/1.x server, fail with an explicit message
|
||||
// rather than returning a more cryptic error later on.
|
||||
d.responseErr = errorf(
|
||||
CodeUnimplemented,
|
||||
"response from %v is HTTP/%d.%d: bidi streams require at least HTTP/2",
|
||||
d.request.URL,
|
||||
response.ProtoMajor,
|
||||
response.ProtoMinor,
|
||||
)
|
||||
_ = d.CloseWrite()
|
||||
}
|
||||
}
|
||||
|
||||
// getNoBody is a GetBody function for http.NoBody.
|
||||
func getNoBody() (io.ReadCloser, error) {
|
||||
return http.NoBody, nil
|
||||
}
|
||||
|
||||
// messagePayload is a sized and seekable message payload. The interface is
|
||||
// implemented by [*bytes.Reader] and *envelope. Reads must be non-blocking.
|
||||
type messagePayload interface {
|
||||
io.Reader
|
||||
io.WriterTo
|
||||
io.Seeker
|
||||
Len() int
|
||||
}
|
||||
|
||||
// nopPayload is a message payload that does nothing. It's used to send headers
|
||||
// to the server.
|
||||
type nopPayload struct{}
|
||||
|
||||
var _ messagePayload = nopPayload{}
|
||||
|
||||
func (nopPayload) Read([]byte) (int, error) {
|
||||
return 0, io.EOF
|
||||
}
|
||||
|
||||
func (nopPayload) WriteTo(io.Writer) (int64, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (nopPayload) Seek(int64, int) (int64, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (nopPayload) Len() int {
|
||||
return 0
|
||||
}
|
||||
|
||||
// messageSender sends a message payload. The interface is implemented by
|
||||
// [*duplexHTTPCall] and writeSender.
|
||||
type messageSender interface {
|
||||
Send(messagePayload) (int64, error)
|
||||
}
|
||||
|
||||
// writeSender is a sender that writes to an [io.Writer]. Useful for wrapping
|
||||
// [http.ResponseWriter].
|
||||
type writeSender struct {
|
||||
writer io.Writer
|
||||
}
|
||||
|
||||
var _ messageSender = writeSender{}
|
||||
|
||||
func (w writeSender) Send(payload messagePayload) (int64, error) {
|
||||
return payload.WriteTo(w.writer)
|
||||
}
|
||||
|
||||
// See: https://cs.opensource.google/go/go/+/refs/tags/go1.20.1:src/net/http/clone.go;l=22-33
|
||||
func cloneURL(oldURL *url.URL) *url.URL {
|
||||
if oldURL == nil {
|
||||
return nil
|
||||
}
|
||||
newURL := new(url.URL)
|
||||
*newURL = *oldURL
|
||||
if oldURL.User != nil {
|
||||
newURL.User = new(url.Userinfo)
|
||||
*newURL.User = *oldURL.User
|
||||
}
|
||||
return newURL
|
||||
}
|
||||
|
||||
// payloadCloser is an [io.ReadCloser] that wraps a messagePayload. It's used to
|
||||
// implement the request body for unary calls. To safely reuse the buffer
|
||||
// call Release after the response is received to ensure the payload is safe for
|
||||
// reuse.
|
||||
type payloadCloser struct {
|
||||
mu sync.Mutex
|
||||
payload messagePayload // nil after Release
|
||||
}
|
||||
|
||||
func newPayloadCloser(payload messagePayload) *payloadCloser {
|
||||
return &payloadCloser{
|
||||
payload: payload,
|
||||
}
|
||||
}
|
||||
|
||||
// Read implements [io.Reader].
|
||||
func (p *payloadCloser) Read(dst []byte) (readN int, err error) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
if p.payload == nil {
|
||||
return 0, io.EOF
|
||||
}
|
||||
return p.payload.Read(dst)
|
||||
}
|
||||
|
||||
// WriteTo implements [io.WriterTo].
|
||||
func (p *payloadCloser) WriteTo(dst io.Writer) (int64, error) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
if p.payload == nil {
|
||||
return 0, nil
|
||||
}
|
||||
return p.payload.WriteTo(dst)
|
||||
}
|
||||
|
||||
// Close implements [io.Closer].
|
||||
func (p *payloadCloser) Close() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Rewind rewinds the payload to the beginning. It returns false if the
|
||||
// payload has been discarded from a previous call to Release.
|
||||
func (p *payloadCloser) Rewind() bool {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
if p.payload == nil {
|
||||
return false
|
||||
}
|
||||
if _, err := p.payload.Seek(0, io.SeekStart); err != nil {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// Release discards the payload. After Release is called, the payload cannot be
|
||||
// rewound and the payload is safe to reuse.
|
||||
func (p *payloadCloser) Release() {
|
||||
p.mu.Lock()
|
||||
p.payload = nil
|
||||
p.mu.Unlock()
|
||||
}
|
||||
+387
@@ -0,0 +1,387 @@
|
||||
// Copyright 2021-2025 The Connect Authors
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package connect
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
)
|
||||
|
||||
// flagEnvelopeCompressed indicates that the data is compressed. It has the
|
||||
// same meaning in the gRPC-Web, gRPC-HTTP2, and Connect protocols.
|
||||
const flagEnvelopeCompressed = 0b00000001
|
||||
|
||||
var errSpecialEnvelope = errorf(
|
||||
CodeUnknown,
|
||||
"final message has protocol-specific flags: %w",
|
||||
// User code checks for end of stream with errors.Is(err, io.EOF).
|
||||
io.EOF,
|
||||
)
|
||||
|
||||
// envelope is a block of arbitrary bytes wrapped in gRPC and Connect's framing
|
||||
// protocol.
|
||||
//
|
||||
// Each message is preceded by a 5-byte prefix. The first byte is a uint8 used
|
||||
// as a set of bitwise flags, and the remainder is a uint32 indicating the
|
||||
// message length. gRPC and Connect interpret the bitwise flags differently, so
|
||||
// envelope leaves their interpretation up to the caller.
|
||||
type envelope struct {
|
||||
Data *bytes.Buffer
|
||||
Flags uint8
|
||||
offset int64
|
||||
}
|
||||
|
||||
var _ messagePayload = (*envelope)(nil)
|
||||
|
||||
func (e *envelope) IsSet(flag uint8) bool {
|
||||
return e.Flags&flag == flag
|
||||
}
|
||||
|
||||
// Read implements [io.Reader].
|
||||
func (e *envelope) Read(data []byte) (readN int, err error) {
|
||||
if e.offset < 5 {
|
||||
prefix, err := makeEnvelopePrefix(e.Flags, e.Data.Len())
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
readN = copy(data, prefix[e.offset:])
|
||||
e.offset += int64(readN)
|
||||
if e.offset < 5 {
|
||||
return readN, nil
|
||||
}
|
||||
data = data[readN:]
|
||||
}
|
||||
n := copy(data, e.Data.Bytes()[e.offset-5:])
|
||||
e.offset += int64(n)
|
||||
readN += n
|
||||
if readN == 0 && e.offset == int64(e.Data.Len()+5) {
|
||||
err = io.EOF
|
||||
}
|
||||
return readN, err
|
||||
}
|
||||
|
||||
// WriteTo implements [io.WriterTo].
|
||||
func (e *envelope) WriteTo(dst io.Writer) (wroteN int64, err error) {
|
||||
if e.offset < 5 {
|
||||
prefix, err := makeEnvelopePrefix(e.Flags, e.Data.Len())
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
prefixN, err := dst.Write(prefix[e.offset:])
|
||||
e.offset += int64(prefixN)
|
||||
wroteN += int64(prefixN)
|
||||
if e.offset < 5 {
|
||||
return wroteN, err
|
||||
}
|
||||
}
|
||||
n, err := dst.Write(e.Data.Bytes()[e.offset-5:])
|
||||
e.offset += int64(n)
|
||||
wroteN += int64(n)
|
||||
return wroteN, err
|
||||
}
|
||||
|
||||
// Seek implements [io.Seeker]. Based on the implementation of [bytes.Reader].
|
||||
func (e *envelope) Seek(offset int64, whence int) (int64, error) {
|
||||
var abs int64
|
||||
switch whence {
|
||||
case io.SeekStart:
|
||||
abs = offset
|
||||
case io.SeekCurrent:
|
||||
abs = e.offset + offset
|
||||
case io.SeekEnd:
|
||||
abs = int64(e.Data.Len()) + offset
|
||||
default:
|
||||
return 0, errors.New("connect.envelope.Seek: invalid whence")
|
||||
}
|
||||
if abs < 0 {
|
||||
return 0, errors.New("connect.envelope.Seek: negative position")
|
||||
}
|
||||
e.offset = abs
|
||||
return abs, nil
|
||||
}
|
||||
|
||||
// Len returns the number of bytes of the unread portion of the envelope.
|
||||
func (e *envelope) Len() int {
|
||||
if length := int(int64(e.Data.Len()) + 5 - e.offset); length > 0 {
|
||||
return length
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
type envelopeWriter struct {
|
||||
ctx context.Context //nolint:containedctx
|
||||
sender messageSender
|
||||
codec Codec
|
||||
compressMinBytes int
|
||||
compressionPool *compressionPool
|
||||
bufferPool *bufferPool
|
||||
sendMaxBytes int
|
||||
}
|
||||
|
||||
func (w *envelopeWriter) Marshal(message any) *Error {
|
||||
if message == nil {
|
||||
// Send no-op message to create the request and send headers.
|
||||
payload := nopPayload{}
|
||||
if _, err := w.sender.Send(payload); err != nil {
|
||||
if connectErr, ok := asError(err); ok {
|
||||
return connectErr
|
||||
}
|
||||
return NewError(CodeUnknown, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if appender, ok := w.codec.(marshalAppender); ok {
|
||||
return w.marshalAppend(message, appender)
|
||||
}
|
||||
return w.marshal(message)
|
||||
}
|
||||
|
||||
// Write writes the enveloped message, compressing as necessary. It doesn't
|
||||
// retain any references to the supplied envelope or its underlying data.
|
||||
func (w *envelopeWriter) Write(env *envelope) *Error {
|
||||
if env.IsSet(flagEnvelopeCompressed) ||
|
||||
w.compressionPool == nil ||
|
||||
env.Data.Len() < w.compressMinBytes {
|
||||
if w.sendMaxBytes > 0 && env.Data.Len() > w.sendMaxBytes {
|
||||
return errorf(CodeResourceExhausted, "message size %d exceeds sendMaxBytes %d", env.Data.Len(), w.sendMaxBytes)
|
||||
}
|
||||
return w.write(env)
|
||||
}
|
||||
data := w.bufferPool.Get()
|
||||
defer w.bufferPool.Put(data)
|
||||
if err := w.compressionPool.Compress(data, env.Data); err != nil {
|
||||
return err
|
||||
}
|
||||
if w.sendMaxBytes > 0 && data.Len() > w.sendMaxBytes {
|
||||
return errorf(CodeResourceExhausted, "compressed message size %d exceeds sendMaxBytes %d", data.Len(), w.sendMaxBytes)
|
||||
}
|
||||
return w.write(&envelope{
|
||||
Data: data,
|
||||
Flags: env.Flags | flagEnvelopeCompressed,
|
||||
})
|
||||
}
|
||||
|
||||
func (w *envelopeWriter) marshalAppend(message any, codec marshalAppender) *Error {
|
||||
// Codec supports MarshalAppend; try to re-use a []byte from the pool.
|
||||
buffer := w.bufferPool.Get()
|
||||
defer w.bufferPool.Put(buffer)
|
||||
raw, err := codec.MarshalAppend(buffer.Bytes(), message)
|
||||
if err != nil {
|
||||
return errorf(CodeInternal, "marshal message: %w", err)
|
||||
}
|
||||
if cap(raw) > buffer.Cap() {
|
||||
// The buffer from the pool was too small, so MarshalAppend grew the slice.
|
||||
// Pessimistically assume that the too-small buffer is insufficient for the
|
||||
// application workload, so there's no point in keeping it in the pool.
|
||||
// Instead, replace it with the larger, newly-allocated slice. This
|
||||
// allocates, but it's a small, constant-size allocation.
|
||||
*buffer = *bytes.NewBuffer(raw)
|
||||
} else {
|
||||
// MarshalAppend didn't allocate, but we need to fix the internal state of
|
||||
// the buffer. Compared to replacing the buffer (as above), buffer.Write
|
||||
// copies but avoids allocating.
|
||||
buffer.Write(raw)
|
||||
}
|
||||
envelope := &envelope{Data: buffer}
|
||||
return w.Write(envelope)
|
||||
}
|
||||
|
||||
func (w *envelopeWriter) marshal(message any) *Error {
|
||||
// Codec doesn't support MarshalAppend; let Marshal allocate a []byte.
|
||||
raw, err := w.codec.Marshal(message)
|
||||
if err != nil {
|
||||
return errorf(CodeInternal, "marshal message: %w", err)
|
||||
}
|
||||
buffer := bytes.NewBuffer(raw)
|
||||
// Put our new []byte into the pool for later reuse.
|
||||
defer w.bufferPool.Put(buffer)
|
||||
envelope := &envelope{Data: buffer}
|
||||
return w.Write(envelope)
|
||||
}
|
||||
|
||||
func (w *envelopeWriter) write(env *envelope) *Error {
|
||||
if _, err := w.sender.Send(env); err != nil {
|
||||
err = wrapIfContextDone(w.ctx, err)
|
||||
if connectErr, ok := asError(err); ok {
|
||||
return connectErr
|
||||
}
|
||||
return errorf(CodeUnknown, "write envelope: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type envelopeReader struct {
|
||||
ctx context.Context //nolint:containedctx
|
||||
reader io.Reader
|
||||
bytesRead int64 // detect trailers-only gRPC responses
|
||||
codec Codec
|
||||
last envelope
|
||||
compressionPool *compressionPool
|
||||
bufferPool *bufferPool
|
||||
readMaxBytes int
|
||||
}
|
||||
|
||||
func (r *envelopeReader) Unmarshal(message any) *Error {
|
||||
buffer := r.bufferPool.Get()
|
||||
var dontRelease *bytes.Buffer
|
||||
defer func() {
|
||||
if buffer != dontRelease {
|
||||
r.bufferPool.Put(buffer)
|
||||
}
|
||||
}()
|
||||
|
||||
env := &envelope{Data: buffer}
|
||||
err := r.Read(env)
|
||||
switch {
|
||||
case err == nil && env.IsSet(flagEnvelopeCompressed) && r.compressionPool == nil:
|
||||
return errorf(
|
||||
CodeInternal,
|
||||
"protocol error: sent compressed message without compression support",
|
||||
)
|
||||
case err == nil &&
|
||||
(env.Flags == 0 || env.Flags == flagEnvelopeCompressed) &&
|
||||
env.Data.Len() == 0:
|
||||
// This is a standard message (because none of the top 7 bits are set) and
|
||||
// there's no data, so the zero value of the message is correct.
|
||||
return nil
|
||||
case err != nil && errors.Is(err, io.EOF):
|
||||
// The stream has ended. Propagate the EOF to the caller.
|
||||
return err
|
||||
case err != nil:
|
||||
// Something's wrong.
|
||||
return err
|
||||
}
|
||||
|
||||
data := env.Data
|
||||
if data.Len() > 0 && env.IsSet(flagEnvelopeCompressed) {
|
||||
decompressed := r.bufferPool.Get()
|
||||
defer func() {
|
||||
if decompressed != dontRelease {
|
||||
r.bufferPool.Put(decompressed)
|
||||
}
|
||||
}()
|
||||
if err := r.compressionPool.Decompress(decompressed, data, int64(r.readMaxBytes)); err != nil {
|
||||
return err
|
||||
}
|
||||
data = decompressed
|
||||
}
|
||||
|
||||
if env.Flags != 0 && env.Flags != flagEnvelopeCompressed {
|
||||
// Drain the rest of the stream to ensure there is no extra data.
|
||||
numBytes, err := discard(r.reader)
|
||||
r.bytesRead += numBytes
|
||||
if err != nil {
|
||||
err = wrapIfContextError(err)
|
||||
if connErr, ok := asError(err); ok {
|
||||
return connErr
|
||||
}
|
||||
return errorf(CodeInternal, "corrupt response: I/O error after end-stream message: %w", err)
|
||||
} else if numBytes > 0 {
|
||||
return errorf(CodeInternal, "corrupt response: %d extra bytes after end of stream", numBytes)
|
||||
}
|
||||
// One of the protocol-specific flags are set, so this is the end of the
|
||||
// stream. Save the message for protocol-specific code to process and
|
||||
// return a sentinel error. We alias the buffer with dontRelease as a
|
||||
// way of marking it so above defers don't release it to the pool.
|
||||
r.last = envelope{
|
||||
Data: data,
|
||||
Flags: env.Flags,
|
||||
}
|
||||
dontRelease = data
|
||||
return errSpecialEnvelope
|
||||
}
|
||||
|
||||
if err := r.codec.Unmarshal(data.Bytes(), message); err != nil {
|
||||
return errorf(CodeInvalidArgument, "unmarshal message: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *envelopeReader) Read(env *envelope) *Error {
|
||||
prefixes := [5]byte{}
|
||||
// io.ReadFull reads the number of bytes requested, or returns an error.
|
||||
// io.EOF will only be returned if no bytes were read.
|
||||
n, err := io.ReadFull(r.reader, prefixes[:])
|
||||
r.bytesRead += int64(n)
|
||||
if err != nil {
|
||||
if errors.Is(err, io.EOF) {
|
||||
// The stream ended cleanly. That's expected, but we need to propagate an EOF
|
||||
// to the user so that they know that the stream has ended. We shouldn't
|
||||
// add any alarming text about protocol errors, though.
|
||||
return NewError(CodeUnknown, err)
|
||||
}
|
||||
err = wrapIfMaxBytesError(err, "read 5 byte message prefix")
|
||||
err = wrapIfContextDone(r.ctx, err)
|
||||
if connectErr, ok := asError(err); ok {
|
||||
return connectErr
|
||||
}
|
||||
// Something else has gone wrong - the stream didn't end cleanly.
|
||||
return errorf(
|
||||
CodeInvalidArgument,
|
||||
"protocol error: incomplete envelope: %w", err,
|
||||
)
|
||||
}
|
||||
size := int64(binary.BigEndian.Uint32(prefixes[1:5]))
|
||||
if r.readMaxBytes > 0 && size > int64(r.readMaxBytes) {
|
||||
n, err := io.CopyN(io.Discard, r.reader, size)
|
||||
r.bytesRead += n
|
||||
if err != nil && !errors.Is(err, io.EOF) {
|
||||
return errorf(CodeResourceExhausted, "message is larger than configured max %d - unable to determine message size: %w", r.readMaxBytes, err)
|
||||
}
|
||||
return errorf(CodeResourceExhausted, "message size %d is larger than configured max %d", size, r.readMaxBytes)
|
||||
}
|
||||
// We've read the prefix, so we know how many bytes to expect.
|
||||
// CopyN will return an error if it doesn't read the requested
|
||||
// number of bytes.
|
||||
readN, err := io.CopyN(env.Data, r.reader, size)
|
||||
r.bytesRead += readN
|
||||
if err != nil {
|
||||
if errors.Is(err, io.EOF) {
|
||||
// We've gotten fewer bytes than we expected, so the stream has ended
|
||||
// unexpectedly.
|
||||
return errorf(
|
||||
CodeInvalidArgument,
|
||||
"protocol error: promised %d bytes in enveloped message, got %d bytes",
|
||||
size,
|
||||
readN,
|
||||
)
|
||||
}
|
||||
err = wrapIfMaxBytesError(err, "read %d byte message", size)
|
||||
err = wrapIfContextDone(r.ctx, err)
|
||||
if connectErr, ok := asError(err); ok {
|
||||
return connectErr
|
||||
}
|
||||
return errorf(CodeUnknown, "read enveloped message: %w", err)
|
||||
}
|
||||
env.Flags = prefixes[0]
|
||||
return nil
|
||||
}
|
||||
|
||||
func makeEnvelopePrefix(flags uint8, size int) ([5]byte, error) {
|
||||
// Cast as 64-bit to ensure comparison works on all architectures.
|
||||
size64 := int64(size)
|
||||
if size64 < 0 || size64 > math.MaxUint32 {
|
||||
return [5]byte{}, fmt.Errorf("connect.makeEnvelopePrefix: size %d out of bounds", size)
|
||||
}
|
||||
prefix := [5]byte{}
|
||||
prefix[0] = flags
|
||||
binary.BigEndian.PutUint32(prefix[1:5], uint32(size64))
|
||||
return prefix, nil
|
||||
}
|
||||
+458
@@ -0,0 +1,458 @@
|
||||
// Copyright 2021-2025 The Connect Authors
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package connect
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"google.golang.org/protobuf/proto"
|
||||
"google.golang.org/protobuf/types/known/anypb"
|
||||
)
|
||||
|
||||
const (
|
||||
commonErrorsURL = "https://connectrpc.com/docs/go/common-errors"
|
||||
defaultAnyResolverPrefix = "type.googleapis.com/"
|
||||
)
|
||||
|
||||
var (
|
||||
// errNotModified signals Connect-protocol responses to GET requests to use the
|
||||
// 304 Not Modified HTTP error code.
|
||||
errNotModified = errors.New("not modified")
|
||||
// errNotModifiedClient wraps ErrNotModified for use client-side.
|
||||
errNotModifiedClient = fmt.Errorf("HTTP 304: %w", errNotModified)
|
||||
)
|
||||
|
||||
// An ErrorDetail is a self-describing Protobuf message attached to an [*Error].
|
||||
// Error details are sent over the network to clients, which can then work with
|
||||
// strongly-typed data rather than trying to parse a complex error message. For
|
||||
// example, you might use details to send a localized error message or retry
|
||||
// parameters to the client.
|
||||
//
|
||||
// The [google.golang.org/genproto/googleapis/rpc/errdetails] package contains a
|
||||
// variety of Protobuf messages commonly used as error details.
|
||||
type ErrorDetail struct {
|
||||
pbAny *anypb.Any
|
||||
pbInner proto.Message // if nil, must be extracted from pbAny
|
||||
wireJSON string // preserve human-readable JSON
|
||||
}
|
||||
|
||||
// NewErrorDetail constructs a new error detail. If msg is an *[anypb.Any] then
|
||||
// it is used as is. Otherwise, it is first marshalled into an *[anypb.Any]
|
||||
// value. This returns an error if msg cannot be marshalled.
|
||||
func NewErrorDetail(msg proto.Message) (*ErrorDetail, error) {
|
||||
// If it's already an Any, don't wrap it inside another.
|
||||
if pb, ok := msg.(*anypb.Any); ok {
|
||||
return &ErrorDetail{pbAny: pb}, nil
|
||||
}
|
||||
pb, err := anypb.New(msg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &ErrorDetail{pbAny: pb, pbInner: msg}, nil
|
||||
}
|
||||
|
||||
// Type is the fully-qualified name of the detail's Protobuf message (for
|
||||
// example, acme.foo.v1.FooDetail).
|
||||
func (d *ErrorDetail) Type() string {
|
||||
// proto.Any tries to make messages self-describing by using type URLs rather
|
||||
// than plain type names, but there aren't any descriptor registries
|
||||
// deployed. With the current state of the `Any` code, it's not possible to
|
||||
// build a useful type registry either. To hide this from users, we should
|
||||
// trim the URL prefix is added to the type name.
|
||||
//
|
||||
// If we ever want to support remote registries, we can add an explicit
|
||||
// `TypeURL` method.
|
||||
return typeNameForURL(d.pbAny.GetTypeUrl())
|
||||
}
|
||||
|
||||
// Bytes returns a copy of the Protobuf-serialized detail.
|
||||
func (d *ErrorDetail) Bytes() []byte {
|
||||
out := make([]byte, len(d.pbAny.GetValue()))
|
||||
copy(out, d.pbAny.GetValue())
|
||||
return out
|
||||
}
|
||||
|
||||
// Value uses the Protobuf runtime's package-global registry to unmarshal the
|
||||
// Detail into a strongly-typed message. Typically, clients use Go type
|
||||
// assertions to cast from the proto.Message interface to concrete types.
|
||||
func (d *ErrorDetail) Value() (proto.Message, error) {
|
||||
if d.pbInner != nil {
|
||||
// We clone it so that if the caller mutates the returned value,
|
||||
// they don't inadvertently corrupt this error detail value.
|
||||
return proto.Clone(d.pbInner), nil
|
||||
}
|
||||
return d.pbAny.UnmarshalNew()
|
||||
}
|
||||
|
||||
// An Error captures four key pieces of information: a [Code], an underlying Go
|
||||
// error, a map of metadata, and an optional collection of arbitrary Protobuf
|
||||
// messages called "details" (more on those below). Servers send the code, the
|
||||
// underlying error's Error() output, the metadata, and details over the wire
|
||||
// to clients. Remember that the underlying error's message will be sent to
|
||||
// clients - take care not to leak sensitive information from public APIs!
|
||||
//
|
||||
// Service implementations and interceptors should return errors that can be
|
||||
// cast to an [*Error] (using the standard library's [errors.As]). If the returned
|
||||
// error can't be cast to an [*Error], connect will use [CodeUnknown] and the
|
||||
// returned error's message.
|
||||
//
|
||||
// Error details are an optional mechanism for servers, interceptors, and
|
||||
// proxies to attach arbitrary Protobuf messages to the error code and message.
|
||||
// They're a clearer and more performant alternative to HTTP header
|
||||
// microformats. See [the documentation on errors] for more details.
|
||||
//
|
||||
// [the documentation on errors]: https://connectrpc.com/docs/go/errors
|
||||
type Error struct {
|
||||
code Code
|
||||
err error
|
||||
details []*ErrorDetail
|
||||
meta http.Header
|
||||
wireErr bool
|
||||
}
|
||||
|
||||
// NewError annotates any Go error with a status code.
|
||||
func NewError(c Code, underlying error) *Error {
|
||||
return &Error{code: c, err: underlying}
|
||||
}
|
||||
|
||||
// NewWireError is similar to [NewError], but the resulting *Error returns true
|
||||
// when tested with [IsWireError].
|
||||
//
|
||||
// This is useful for clients trying to propagate partial failures from
|
||||
// streaming RPCs. Often, these RPCs include error information in their
|
||||
// response messages (for example, [gRPC server reflection] and
|
||||
// OpenTelemetry's [OTLP]). Clients propagating these errors up the stack
|
||||
// should use NewWireError to clarify that the error code, message, and details
|
||||
// (if any) were explicitly sent by the server rather than inferred from a
|
||||
// lower-level networking error or timeout.
|
||||
//
|
||||
// [gRPC server reflection]: https://github.com/grpc/grpc/blob/v1.49.2/src/proto/grpc/reflection/v1alpha/reflection.proto#L132-L136
|
||||
// [OTLP]: https://github.com/open-telemetry/opentelemetry-specification/blob/main/specification/protocol/otlp.md#partial-success
|
||||
func NewWireError(c Code, underlying error) *Error {
|
||||
err := NewError(c, underlying)
|
||||
err.wireErr = true
|
||||
return err
|
||||
}
|
||||
|
||||
// IsWireError checks whether the error was returned by the server, as opposed
|
||||
// to being synthesized by the client.
|
||||
//
|
||||
// Clients may find this useful when deciding how to propagate errors. For
|
||||
// example, an RPC-to-HTTP proxy might expose a server-sent CodeUnknown as an
|
||||
// HTTP 500 but a client-synthesized CodeUnknown as a 503.
|
||||
//
|
||||
// Handlers will strip [Error.Meta] headers propagated from wire errors to avoid
|
||||
// leaking response headers. To propagate headers recreate the error as a
|
||||
// non-wire error.
|
||||
func IsWireError(err error) bool {
|
||||
se := new(Error)
|
||||
if !errors.As(err, &se) {
|
||||
return false
|
||||
}
|
||||
return se.wireErr
|
||||
}
|
||||
|
||||
// NewNotModifiedError indicates that the requested resource hasn't changed. It
|
||||
// should be used only when handlers wish to respond to conditional HTTP GET
|
||||
// requests with a 304 Not Modified. In all other circumstances, including all
|
||||
// RPCs using the gRPC or gRPC-Web protocols, it's equivalent to sending an
|
||||
// error with [CodeUnknown]. The supplied headers should include Etag,
|
||||
// Cache-Control, or any other headers required by [RFC 9110 § 15.4.5].
|
||||
//
|
||||
// Clients should check for this error using [IsNotModifiedError].
|
||||
//
|
||||
// [RFC 9110 § 15.4.5]: https://httpwg.org/specs/rfc9110.html#status.304
|
||||
func NewNotModifiedError(headers http.Header) *Error {
|
||||
err := NewError(CodeUnknown, errNotModified)
|
||||
if headers != nil {
|
||||
err.meta = headers
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (e *Error) Error() string {
|
||||
message := e.Message()
|
||||
if message == "" {
|
||||
return e.code.String()
|
||||
}
|
||||
return e.code.String() + ": " + message
|
||||
}
|
||||
|
||||
// Message returns the underlying error message. It may be empty if the
|
||||
// original error was created with a status code and a nil error.
|
||||
func (e *Error) Message() string {
|
||||
if e.err != nil {
|
||||
return e.err.Error()
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// Unwrap allows [errors.Is] and [errors.As] access to the underlying error.
|
||||
func (e *Error) Unwrap() error {
|
||||
return e.err
|
||||
}
|
||||
|
||||
// Code returns the error's status code.
|
||||
func (e *Error) Code() Code {
|
||||
return e.code
|
||||
}
|
||||
|
||||
// Details returns the error's details.
|
||||
func (e *Error) Details() []*ErrorDetail {
|
||||
return e.details
|
||||
}
|
||||
|
||||
// AddDetail appends to the error's details.
|
||||
func (e *Error) AddDetail(d *ErrorDetail) {
|
||||
e.details = append(e.details, d)
|
||||
}
|
||||
|
||||
// Meta allows the error to carry additional information as key-value pairs.
|
||||
//
|
||||
// Protocol-specific headers and trailers may be removed to avoid breaking
|
||||
// protocol semantics. For example, Content-Length and Content-Type headers
|
||||
// won't be propagated. See the documentation for each protocol for more
|
||||
// datails.
|
||||
//
|
||||
// When clients receive errors, the metadata contains the union of the HTTP
|
||||
// headers and the protocol-specific trailers (either HTTP trailers or in-body
|
||||
// metadata).
|
||||
func (e *Error) Meta() http.Header {
|
||||
if e.meta == nil {
|
||||
e.meta = make(http.Header)
|
||||
}
|
||||
return e.meta
|
||||
}
|
||||
|
||||
func (e *Error) detailsAsAny() []*anypb.Any {
|
||||
anys := make([]*anypb.Any, 0, len(e.details))
|
||||
for _, detail := range e.details {
|
||||
anys = append(anys, detail.pbAny)
|
||||
}
|
||||
return anys
|
||||
}
|
||||
|
||||
// IsNotModifiedError checks whether the supplied error indicates that the
|
||||
// requested resource hasn't changed. It only returns true if the server used
|
||||
// [NewNotModifiedError] in response to a Connect-protocol RPC made with an
|
||||
// HTTP GET.
|
||||
func IsNotModifiedError(err error) bool {
|
||||
return errors.Is(err, errNotModified)
|
||||
}
|
||||
|
||||
// errorf calls fmt.Errorf with the supplied template and arguments, then wraps
|
||||
// the resulting error.
|
||||
func errorf(c Code, template string, args ...any) *Error {
|
||||
return NewError(c, fmt.Errorf(template, args...))
|
||||
}
|
||||
|
||||
// asError uses errors.As to unwrap any error and look for a connect *Error.
|
||||
func asError(err error) (*Error, bool) {
|
||||
var connectErr *Error
|
||||
ok := errors.As(err, &connectErr)
|
||||
return connectErr, ok
|
||||
}
|
||||
|
||||
// wrapIfUncoded ensures that all errors are wrapped. It leaves already-wrapped
|
||||
// errors unchanged, uses wrapIfContextError to apply codes to context.Canceled
|
||||
// and context.DeadlineExceeded, and falls back to wrapping other errors with
|
||||
// CodeUnknown.
|
||||
func wrapIfUncoded(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
maybeCodedErr := wrapIfContextError(err)
|
||||
if _, ok := asError(maybeCodedErr); ok {
|
||||
return maybeCodedErr
|
||||
}
|
||||
return NewError(CodeUnknown, maybeCodedErr)
|
||||
}
|
||||
|
||||
// wrapIfContextError applies CodeCanceled or CodeDeadlineExceeded to Go's
|
||||
// context.Canceled and context.DeadlineExceeded errors, but only if they
|
||||
// haven't already been wrapped.
|
||||
func wrapIfContextError(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if _, ok := asError(err); ok {
|
||||
return err
|
||||
}
|
||||
if errors.Is(err, context.Canceled) {
|
||||
return NewError(CodeCanceled, err)
|
||||
}
|
||||
if errors.Is(err, context.DeadlineExceeded) {
|
||||
return NewError(CodeDeadlineExceeded, err)
|
||||
}
|
||||
// Ick, some dial errors can be returned as os.ErrDeadlineExceeded
|
||||
// instead of context.DeadlineExceeded :(
|
||||
// https://github.com/golang/go/issues/64449
|
||||
if errors.Is(err, os.ErrDeadlineExceeded) {
|
||||
return NewError(CodeDeadlineExceeded, err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// wrapIfContextDone wraps errors with CodeCanceled or CodeDeadlineExceeded
|
||||
// if the context is done. It leaves already-wrapped errors unchanged.
|
||||
func wrapIfContextDone(ctx context.Context, err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
err = wrapIfContextError(err)
|
||||
if _, ok := asError(err); ok {
|
||||
return err
|
||||
}
|
||||
ctxErr := ctx.Err()
|
||||
if errors.Is(ctxErr, context.Canceled) {
|
||||
return NewError(CodeCanceled, err)
|
||||
} else if errors.Is(ctxErr, context.DeadlineExceeded) {
|
||||
return NewError(CodeDeadlineExceeded, err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// wrapIfLikelyH2CNotConfiguredError adds a wrapping error that has a message
|
||||
// telling the caller that they likely need to use h2c but are using a raw http.Client{}.
|
||||
//
|
||||
// This happens when running a gRPC-only server.
|
||||
// This is fragile and may break over time, and this should be considered a best-effort.
|
||||
func wrapIfLikelyH2CNotConfiguredError(request *http.Request, err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if _, ok := asError(err); ok {
|
||||
return err
|
||||
}
|
||||
if url := request.URL; url != nil && url.Scheme != "http" {
|
||||
// If the scheme is not http, we definitely do not have an h2c error, so just return.
|
||||
return err
|
||||
}
|
||||
// net/http code has been investigated and there is no typing of any of these errors
|
||||
// they are all created with fmt.Errorf
|
||||
// grpc-go returns the first error 2/3-3/4 of the time, and the second error 1/4-1/3 of the time
|
||||
if errString := err.Error(); strings.HasPrefix(errString, `Post "`) &&
|
||||
(strings.Contains(errString, `net/http: HTTP/1.x transport connection broken: malformed HTTP response`) ||
|
||||
strings.HasSuffix(errString, `write: broken pipe`)) {
|
||||
return fmt.Errorf("possible h2c configuration issue when talking to gRPC server, see %s: %w", commonErrorsURL, err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// wrapIfLikelyWithGRPCNotUsedError adds a wrapping error that has a message
|
||||
// telling the caller that they likely forgot to use connect.WithGRPC().
|
||||
//
|
||||
// This happens when running a gRPC-only server.
|
||||
// This is fragile and may break over time, and this should be considered a best-effort.
|
||||
func wrapIfLikelyWithGRPCNotUsedError(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if _, ok := asError(err); ok {
|
||||
return err
|
||||
}
|
||||
// golang.org/x/net code has been investigated and there is no typing of this error
|
||||
// it is created with fmt.Errorf
|
||||
// http2/transport.go:573: return nil, fmt.Errorf("http2: Transport: cannot retry err [%v] after Request.Body was written; define Request.GetBody to avoid this error", err)
|
||||
if errString := err.Error(); strings.HasPrefix(errString, `Post "`) &&
|
||||
strings.Contains(errString, `http2: Transport: cannot retry err`) &&
|
||||
strings.HasSuffix(errString, `after Request.Body was written; define Request.GetBody to avoid this error`) {
|
||||
return fmt.Errorf("possible missing connect.WithGPRC() client option when talking to gRPC server, see %s: %w", commonErrorsURL, err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// HTTP/2 has its own set of error codes, which it sends in RST_STREAM frames.
|
||||
// When the server sends one of these errors, we should map it back into our
|
||||
// RPC error codes following
|
||||
// https://github.com/grpc/grpc/blob/master/doc/PROTOCOL-HTTP2.md#http2-transport-mapping.
|
||||
//
|
||||
// This would be vastly simpler if we were using x/net/http2 directly, since
|
||||
// the StreamError type is exported. When x/net/http2 gets vendored into
|
||||
// net/http, though, all these types become unexported...so we're left with
|
||||
// string munging.
|
||||
func wrapIfRSTError(err error) error {
|
||||
const (
|
||||
streamErrPrefix = "stream error: "
|
||||
fromPeerSuffix = "; received from peer"
|
||||
)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if _, ok := asError(err); ok {
|
||||
return err
|
||||
}
|
||||
if urlErr := new(url.Error); errors.As(err, &urlErr) {
|
||||
// If we get an RST_STREAM error from http.Client.Do, it's wrapped in a
|
||||
// *url.Error.
|
||||
err = urlErr.Unwrap()
|
||||
}
|
||||
msg := err.Error()
|
||||
if !strings.HasPrefix(msg, streamErrPrefix) {
|
||||
return err
|
||||
}
|
||||
if !strings.HasSuffix(msg, fromPeerSuffix) {
|
||||
return err
|
||||
}
|
||||
msg = strings.TrimSuffix(msg, fromPeerSuffix)
|
||||
i := strings.LastIndex(msg, ";")
|
||||
if i < 0 || i >= len(msg)-1 {
|
||||
return err
|
||||
}
|
||||
msg = msg[i+1:]
|
||||
msg = strings.TrimSpace(msg)
|
||||
switch msg {
|
||||
case "NO_ERROR", "PROTOCOL_ERROR", "INTERNAL_ERROR", "FLOW_CONTROL_ERROR",
|
||||
"SETTINGS_TIMEOUT", "FRAME_SIZE_ERROR", "COMPRESSION_ERROR", "CONNECT_ERROR":
|
||||
return NewError(CodeInternal, err)
|
||||
case "REFUSED_STREAM":
|
||||
return NewError(CodeUnavailable, err)
|
||||
case "CANCEL":
|
||||
return NewError(CodeCanceled, err)
|
||||
case "ENHANCE_YOUR_CALM":
|
||||
return NewError(CodeResourceExhausted, fmt.Errorf("bandwidth exhausted: %w", err))
|
||||
case "INADEQUATE_SECURITY":
|
||||
return NewError(CodePermissionDenied, fmt.Errorf("transport protocol insecure: %w", err))
|
||||
default:
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// wrapIfMaxBytesError wraps errors returned reading from a http.MaxBytesHandler
|
||||
// whose limit has been exceeded.
|
||||
func wrapIfMaxBytesError(err error, tmpl string, args ...any) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if _, ok := asError(err); ok {
|
||||
return err
|
||||
}
|
||||
var maxBytesErr *http.MaxBytesError
|
||||
if ok := errors.As(err, &maxBytesErr); !ok {
|
||||
return err
|
||||
}
|
||||
prefix := fmt.Sprintf(tmpl, args...)
|
||||
return errorf(CodeResourceExhausted, "%s: exceeded %d byte http.MaxBytesReader limit", prefix, maxBytesErr.Limit)
|
||||
}
|
||||
|
||||
func typeNameForURL(url string) string {
|
||||
return url[strings.LastIndexByte(url, '/')+1:]
|
||||
}
|
||||
+179
@@ -0,0 +1,179 @@
|
||||
// Copyright 2021-2025 The Connect Authors
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package connect
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// protocolType is one of the supported RPC protocols.
|
||||
type protocolType uint8
|
||||
|
||||
const (
|
||||
unknownProtocol protocolType = iota
|
||||
connectUnaryProtocol
|
||||
connectStreamProtocol
|
||||
grpcProtocol
|
||||
grpcWebProtocol
|
||||
)
|
||||
|
||||
// An ErrorWriter writes errors to an [http.ResponseWriter] in the format
|
||||
// expected by an RPC client. This is especially useful in server-side net/http
|
||||
// middleware, where you may wish to handle requests from RPC and non-RPC
|
||||
// clients with the same code.
|
||||
//
|
||||
// ErrorWriters are safe to use concurrently.
|
||||
type ErrorWriter struct {
|
||||
bufferPool *bufferPool
|
||||
protobuf Codec
|
||||
requireConnectProtocolHeader bool
|
||||
}
|
||||
|
||||
// NewErrorWriter constructs an ErrorWriter. Handler options may be passed to
|
||||
// configure the error writer behaviour to match the handlers.
|
||||
// [WithRequireConnectProtocolHeader] will assert that Connect protocol
|
||||
// requests include the version header allowing the error writer to correctly
|
||||
// classify the request.
|
||||
// Options supplied via [WithConditionalHandlerOptions] are ignored.
|
||||
func NewErrorWriter(opts ...HandlerOption) *ErrorWriter {
|
||||
config := newHandlerConfig("", StreamTypeUnary, opts)
|
||||
codecs := newReadOnlyCodecs(config.Codecs)
|
||||
return &ErrorWriter{
|
||||
bufferPool: config.BufferPool,
|
||||
protobuf: codecs.Protobuf(),
|
||||
requireConnectProtocolHeader: config.RequireConnectProtocolHeader,
|
||||
}
|
||||
}
|
||||
|
||||
func (w *ErrorWriter) classifyRequest(request *http.Request) protocolType {
|
||||
ctype := canonicalizeContentType(getHeaderCanonical(request.Header, headerContentType))
|
||||
isPost := request.Method == http.MethodPost
|
||||
isGet := request.Method == http.MethodGet
|
||||
switch {
|
||||
case isPost && (ctype == grpcContentTypeDefault || strings.HasPrefix(ctype, grpcContentTypePrefix)):
|
||||
return grpcProtocol
|
||||
case isPost && (ctype == grpcWebContentTypeDefault || strings.HasPrefix(ctype, grpcWebContentTypePrefix)):
|
||||
return grpcWebProtocol
|
||||
case isPost && strings.HasPrefix(ctype, connectStreamingContentTypePrefix):
|
||||
// Streaming ignores the requireConnectProtocolHeader option as the
|
||||
// Content-Type is enough to determine the protocol.
|
||||
if err := connectCheckProtocolVersion(request, false /* required */); err != nil {
|
||||
return unknownProtocol
|
||||
}
|
||||
return connectStreamProtocol
|
||||
case isPost && strings.HasPrefix(ctype, connectUnaryContentTypePrefix):
|
||||
if err := connectCheckProtocolVersion(request, w.requireConnectProtocolHeader); err != nil {
|
||||
return unknownProtocol
|
||||
}
|
||||
return connectUnaryProtocol
|
||||
case isGet:
|
||||
if err := connectCheckProtocolVersion(request, w.requireConnectProtocolHeader); err != nil {
|
||||
return unknownProtocol
|
||||
}
|
||||
return connectUnaryProtocol
|
||||
default:
|
||||
return unknownProtocol
|
||||
}
|
||||
}
|
||||
|
||||
// IsSupported checks whether a request is using one of the ErrorWriter's
|
||||
// supported RPC protocols.
|
||||
func (w *ErrorWriter) IsSupported(request *http.Request) bool {
|
||||
return w.classifyRequest(request) != unknownProtocol
|
||||
}
|
||||
|
||||
// Write an error, using the format appropriate for the RPC protocol in use.
|
||||
// Callers should first use IsSupported to verify that the request is using one
|
||||
// of the ErrorWriter's supported RPC protocols. If the protocol is unknown,
|
||||
// Write will send the error as unprefixed, Connect-formatted JSON.
|
||||
//
|
||||
// Write does not read or close the request body.
|
||||
func (w *ErrorWriter) Write(response http.ResponseWriter, request *http.Request, err error) error {
|
||||
ctype := canonicalizeContentType(getHeaderCanonical(request.Header, headerContentType))
|
||||
switch protocolType := w.classifyRequest(request); protocolType {
|
||||
case connectStreamProtocol:
|
||||
setHeaderCanonical(response.Header(), headerContentType, ctype)
|
||||
return w.writeConnectStreaming(response, err)
|
||||
case grpcProtocol:
|
||||
setHeaderCanonical(response.Header(), headerContentType, ctype)
|
||||
return w.writeGRPC(response, err)
|
||||
case grpcWebProtocol:
|
||||
setHeaderCanonical(response.Header(), headerContentType, ctype)
|
||||
return w.writeGRPCWeb(response, err)
|
||||
case unknownProtocol, connectUnaryProtocol:
|
||||
fallthrough
|
||||
default:
|
||||
// Unary errors are always JSON. Unknown protocols are treated as unary
|
||||
// because they are likely to be Connect clients and will still be able to
|
||||
// parse the error as it's in a human-readable format.
|
||||
setHeaderCanonical(response.Header(), headerContentType, connectUnaryContentTypeJSON)
|
||||
return w.writeConnectUnary(response, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (w *ErrorWriter) writeConnectUnary(response http.ResponseWriter, err error) error {
|
||||
if connectErr, ok := asError(err); ok && !connectErr.wireErr {
|
||||
mergeNonProtocolHeaders(response.Header(), connectErr.meta)
|
||||
}
|
||||
response.WriteHeader(connectCodeToHTTP(CodeOf(err)))
|
||||
data, marshalErr := json.Marshal(newConnectWireError(err))
|
||||
if marshalErr != nil {
|
||||
return fmt.Errorf("marshal error: %w", marshalErr)
|
||||
}
|
||||
_, writeErr := response.Write(data)
|
||||
return writeErr
|
||||
}
|
||||
|
||||
func (w *ErrorWriter) writeConnectStreaming(response http.ResponseWriter, err error) error {
|
||||
response.WriteHeader(http.StatusOK)
|
||||
marshaler := &connectStreamingMarshaler{
|
||||
envelopeWriter: envelopeWriter{
|
||||
sender: writeSender{writer: response},
|
||||
bufferPool: w.bufferPool,
|
||||
},
|
||||
}
|
||||
// MarshalEndStream returns *Error: check return value to avoid typed nils.
|
||||
if marshalErr := marshaler.MarshalEndStream(err, make(http.Header)); marshalErr != nil {
|
||||
return marshalErr
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *ErrorWriter) writeGRPC(response http.ResponseWriter, err error) error {
|
||||
trailers := make(http.Header, 2) // need space for at least code & message
|
||||
grpcErrorToTrailer(trailers, w.protobuf, err)
|
||||
// To make net/http reliably send trailers without a body, we must set the
|
||||
// Trailers header rather than using http.TrailerPrefix. See
|
||||
// https://github.com/golang/go/issues/54723.
|
||||
keys := make([]string, 0, len(trailers))
|
||||
for k := range trailers {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
setHeaderCanonical(response.Header(), headerTrailer, strings.Join(keys, ","))
|
||||
response.WriteHeader(http.StatusOK)
|
||||
mergeHeaders(response.Header(), trailers)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *ErrorWriter) writeGRPCWeb(response http.ResponseWriter, err error) error {
|
||||
// This is a trailers-only response. To match the behavior of Envoy and
|
||||
// protocol_grpc.go, put the trailers in the HTTP headers.
|
||||
grpcErrorToTrailer(response.Header(), w.protobuf, err)
|
||||
response.WriteHeader(http.StatusOK)
|
||||
return nil
|
||||
}
|
||||
+423
@@ -0,0 +1,423 @@
|
||||
// Copyright 2021-2025 The Connect Authors
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package connect
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// A Handler is the server-side implementation of a single RPC defined by a
|
||||
// service schema.
|
||||
//
|
||||
// By default, Handlers support the Connect, gRPC, and gRPC-Web protocols with
|
||||
// the binary Protobuf and JSON codecs. They support gzip compression using the
|
||||
// standard library's [compress/gzip].
|
||||
type Handler struct {
|
||||
spec Spec
|
||||
implementation StreamingHandlerFunc
|
||||
protocolHandlers map[string][]protocolHandler // Method to protocol handlers
|
||||
allowMethod string // Allow header
|
||||
acceptPost string // Accept-Post header
|
||||
}
|
||||
|
||||
// NewUnaryHandler constructs a [Handler] for a request-response procedure.
|
||||
func NewUnaryHandler[Req, Res any](
|
||||
procedure string,
|
||||
unary func(context.Context, *Request[Req]) (*Response[Res], error),
|
||||
options ...HandlerOption,
|
||||
) *Handler {
|
||||
// Wrap the strongly-typed implementation so we can apply interceptors.
|
||||
untyped := UnaryFunc(func(ctx context.Context, request AnyRequest) (AnyResponse, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
typed, ok := request.(*Request[Req])
|
||||
if !ok {
|
||||
return nil, errorf(CodeInternal, "unexpected handler request type %T", request)
|
||||
}
|
||||
res, err := unary(ctx, typed)
|
||||
if res == nil && err == nil {
|
||||
// This is going to panic during serialization. Debugging is much easier
|
||||
// if we panic here instead, so we can include the procedure name.
|
||||
panic(procedure + " returned nil *connect.Response and nil error") //nolint: forbidigo
|
||||
}
|
||||
return res, err
|
||||
})
|
||||
config := newHandlerConfig(procedure, StreamTypeUnary, options)
|
||||
if interceptor := config.Interceptor; interceptor != nil {
|
||||
untyped = interceptor.WrapUnary(untyped)
|
||||
}
|
||||
// Given a stream, how should we call the unary function?
|
||||
implementation := func(ctx context.Context, conn StreamingHandlerConn) error {
|
||||
request, err := receiveUnaryRequest[Req](conn, config.Initializer)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Add the request header to the context, and store the response header
|
||||
// and trailer to propagate back to the caller.
|
||||
info := &handlerCallInfo{
|
||||
peer: request.Peer(),
|
||||
spec: request.Spec(),
|
||||
method: request.HTTPMethod(),
|
||||
requestHeader: request.Header(),
|
||||
}
|
||||
ctx = newHandlerContext(ctx, info)
|
||||
response, err := untyped(ctx, request)
|
||||
// Add response headers/trailers from the context callinfo into the conn if they exist
|
||||
if info.responseHeader != nil {
|
||||
mergeNonProtocolHeaders(conn.ResponseHeader(), info.responseHeader)
|
||||
}
|
||||
if info.responseTrailer != nil {
|
||||
mergeNonProtocolHeaders(conn.ResponseTrailer(), info.responseTrailer)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Add response headers/trailers from the response into the conn if they exist
|
||||
if len(response.Header()) != 0 {
|
||||
mergeNonProtocolHeaders(conn.ResponseHeader(), response.Header())
|
||||
}
|
||||
if len(response.Trailer()) != 0 {
|
||||
mergeNonProtocolHeaders(conn.ResponseTrailer(), response.Trailer())
|
||||
}
|
||||
return conn.Send(response.Any())
|
||||
}
|
||||
|
||||
protocolHandlers := config.newProtocolHandlers()
|
||||
return &Handler{
|
||||
spec: config.newSpec(),
|
||||
implementation: implementation,
|
||||
protocolHandlers: mappedMethodHandlers(protocolHandlers),
|
||||
allowMethod: sortedAllowMethodValue(protocolHandlers),
|
||||
acceptPost: sortedAcceptPostValue(protocolHandlers),
|
||||
}
|
||||
}
|
||||
|
||||
// NewUnaryHandlerSimple constructs a [Handler] for a request-response procedure using the
|
||||
// function signature associated with the "simple" generation option.
|
||||
//
|
||||
// This option eliminates the [Request] and [Response] wrappers, and instead uses the
|
||||
// context.Context to propagate information such as headers.
|
||||
func NewUnaryHandlerSimple[Req, Res any](
|
||||
procedure string,
|
||||
unary func(context.Context, *Req) (*Res, error),
|
||||
options ...HandlerOption,
|
||||
) *Handler {
|
||||
return NewUnaryHandler(
|
||||
procedure,
|
||||
func(ctx context.Context, request *Request[Req]) (*Response[Res], error) {
|
||||
responseMsg, err := unary(ctx, request.Msg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return NewResponse(responseMsg), nil
|
||||
},
|
||||
options...,
|
||||
)
|
||||
}
|
||||
|
||||
// NewClientStreamHandler constructs a [Handler] for a client streaming procedure.
|
||||
func NewClientStreamHandler[Req, Res any](
|
||||
procedure string,
|
||||
implementation func(context.Context, *ClientStream[Req]) (*Response[Res], error),
|
||||
options ...HandlerOption,
|
||||
) *Handler {
|
||||
config := newHandlerConfig(procedure, StreamTypeClient, options)
|
||||
return newStreamHandler(
|
||||
config,
|
||||
func(ctx context.Context, conn StreamingHandlerConn) error {
|
||||
stream := &ClientStream[Req]{
|
||||
conn: conn,
|
||||
initializer: config.Initializer,
|
||||
}
|
||||
ctx = newHandlerContext(ctx, &streamingHandlerCallInfo{
|
||||
conn: conn,
|
||||
})
|
||||
res, err := implementation(ctx, stream)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if res == nil {
|
||||
// This is going to panic during serialization. Debugging is much easier
|
||||
// if we panic here instead, so we can include the procedure name.
|
||||
panic(procedure + " returned nil *connect.Response and nil error") //nolint: forbidigo
|
||||
}
|
||||
mergeHeaders(conn.ResponseHeader(), res.header)
|
||||
mergeHeaders(conn.ResponseTrailer(), res.trailer)
|
||||
return conn.Send(res.Msg)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// NewClientStreamHandlerSimple constructs a [Handler] for a request-streaming procedure
|
||||
// using the function signature associated with the "simple" generation option.
|
||||
//
|
||||
// This option eliminates the [Response] wrapper, and instead uses the context.Context
|
||||
// to propagate information such as headers.
|
||||
func NewClientStreamHandlerSimple[Req, Res any](
|
||||
procedure string,
|
||||
implementation func(context.Context, *ClientStream[Req]) (*Res, error),
|
||||
options ...HandlerOption,
|
||||
) *Handler {
|
||||
return NewClientStreamHandler(
|
||||
procedure,
|
||||
func(ctx context.Context, stream *ClientStream[Req]) (*Response[Res], error) {
|
||||
responseMsg, err := implementation(ctx, stream)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return NewResponse(responseMsg), nil
|
||||
},
|
||||
options...,
|
||||
)
|
||||
}
|
||||
|
||||
// NewServerStreamHandler constructs a [Handler] for a server streaming procedure.
|
||||
func NewServerStreamHandler[Req, Res any](
|
||||
procedure string,
|
||||
implementation func(context.Context, *Request[Req], *ServerStream[Res]) error,
|
||||
options ...HandlerOption,
|
||||
) *Handler {
|
||||
config := newHandlerConfig(procedure, StreamTypeServer, options)
|
||||
return newStreamHandler(
|
||||
config,
|
||||
func(ctx context.Context, conn StreamingHandlerConn) error {
|
||||
req, err := receiveUnaryRequest[Req](conn, config.Initializer)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ctx = newHandlerContext(ctx, &streamingHandlerCallInfo{
|
||||
conn: conn,
|
||||
})
|
||||
return implementation(ctx, req, &ServerStream[Res]{conn: conn})
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// NewServerStreamHandlerSimple constructs a [Handler] a server streaming procedure using the function
|
||||
// signature associated with the "simple" generation option.
|
||||
//
|
||||
// This option eliminates the [Request] wrapper, and instead uses the context.Context to
|
||||
// propagate information such as headers.
|
||||
func NewServerStreamHandlerSimple[Req, Res any](
|
||||
procedure string,
|
||||
implementation func(context.Context, *Req, *ServerStream[Res]) error,
|
||||
options ...HandlerOption,
|
||||
) *Handler {
|
||||
return NewServerStreamHandler(
|
||||
procedure,
|
||||
func(ctx context.Context, request *Request[Req], serverStream *ServerStream[Res]) error {
|
||||
return implementation(ctx, request.Msg, serverStream)
|
||||
},
|
||||
options...,
|
||||
)
|
||||
}
|
||||
|
||||
// NewBidiStreamHandler constructs a [Handler] for a bidirectional streaming procedure.
|
||||
func NewBidiStreamHandler[Req, Res any](
|
||||
procedure string,
|
||||
implementation func(context.Context, *BidiStream[Req, Res]) error,
|
||||
options ...HandlerOption,
|
||||
) *Handler {
|
||||
config := newHandlerConfig(procedure, StreamTypeBidi, options)
|
||||
return newStreamHandler(
|
||||
config,
|
||||
func(ctx context.Context, conn StreamingHandlerConn) error {
|
||||
ctx = newHandlerContext(ctx, &streamingHandlerCallInfo{
|
||||
conn: conn,
|
||||
})
|
||||
return implementation(
|
||||
ctx,
|
||||
&BidiStream[Req, Res]{
|
||||
conn: conn,
|
||||
initializer: config.Initializer,
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// ServeHTTP implements [http.Handler].
|
||||
func (h *Handler) ServeHTTP(responseWriter http.ResponseWriter, request *http.Request) {
|
||||
// We don't need to defer functions to close the request body or read to
|
||||
// EOF: the stream we construct later on already does that, and we only
|
||||
// return early when dealing with misbehaving clients. In those cases, it's
|
||||
// okay if we can't re-use the connection.
|
||||
isBidi := (h.spec.StreamType & StreamTypeBidi) == StreamTypeBidi
|
||||
if isBidi && request.ProtoMajor < 2 {
|
||||
// Clients coded to expect full-duplex connections may hang if they've
|
||||
// mistakenly negotiated HTTP/1.1. To unblock them, we must close the
|
||||
// underlying TCP connection.
|
||||
responseWriter.Header().Set("Connection", "close")
|
||||
responseWriter.WriteHeader(http.StatusHTTPVersionNotSupported)
|
||||
return
|
||||
}
|
||||
|
||||
protocolHandlers := h.protocolHandlers[request.Method]
|
||||
if len(protocolHandlers) == 0 {
|
||||
responseWriter.Header().Set("Allow", h.allowMethod)
|
||||
responseWriter.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
contentType := canonicalizeContentType(getHeaderCanonical(request.Header, headerContentType))
|
||||
|
||||
// Find our implementation of the RPC protocol in use.
|
||||
var protocolHandler protocolHandler
|
||||
for _, handler := range protocolHandlers {
|
||||
if handler.CanHandlePayload(request, contentType) {
|
||||
protocolHandler = handler
|
||||
break
|
||||
}
|
||||
}
|
||||
if protocolHandler == nil {
|
||||
responseWriter.Header().Set("Accept-Post", h.acceptPost)
|
||||
responseWriter.WriteHeader(http.StatusUnsupportedMediaType)
|
||||
return
|
||||
}
|
||||
|
||||
if request.Method == http.MethodGet {
|
||||
// A body must not be present.
|
||||
hasBody := request.ContentLength > 0
|
||||
if request.ContentLength < 0 {
|
||||
// No content-length header.
|
||||
// Test if body is empty by trying to read a single byte.
|
||||
var b [1]byte
|
||||
n, _ := request.Body.Read(b[:])
|
||||
hasBody = n > 0
|
||||
}
|
||||
if hasBody {
|
||||
responseWriter.WriteHeader(http.StatusUnsupportedMediaType)
|
||||
return
|
||||
}
|
||||
_ = request.Body.Close()
|
||||
}
|
||||
|
||||
// Establish a stream and serve the RPC.
|
||||
setHeaderCanonical(request.Header, headerContentType, contentType)
|
||||
setHeaderCanonical(request.Header, headerHost, request.Host)
|
||||
ctx, cancel, timeoutErr := protocolHandler.SetTimeout(request) //nolint: contextcheck
|
||||
if timeoutErr != nil {
|
||||
ctx = request.Context()
|
||||
}
|
||||
if cancel != nil {
|
||||
defer cancel()
|
||||
}
|
||||
connCloser, ok := protocolHandler.NewConn(
|
||||
responseWriter,
|
||||
request.WithContext(ctx),
|
||||
)
|
||||
if !ok {
|
||||
// Failed to create stream, usually because client used an unknown
|
||||
// compression algorithm. Nothing further to do.
|
||||
return
|
||||
}
|
||||
if timeoutErr != nil {
|
||||
_ = connCloser.Close(timeoutErr)
|
||||
return
|
||||
}
|
||||
_ = connCloser.Close(h.implementation(ctx, connCloser))
|
||||
}
|
||||
|
||||
type handlerConfig struct {
|
||||
CompressionPools map[string]*compressionPool
|
||||
CompressionNames []string
|
||||
Codecs map[string]Codec
|
||||
CompressMinBytes int
|
||||
Interceptor Interceptor
|
||||
Procedure string
|
||||
Schema any
|
||||
Initializer maybeInitializer
|
||||
RequireConnectProtocolHeader bool
|
||||
IdempotencyLevel IdempotencyLevel
|
||||
BufferPool *bufferPool
|
||||
ReadMaxBytes int
|
||||
SendMaxBytes int
|
||||
StreamType StreamType
|
||||
}
|
||||
|
||||
func newHandlerConfig(procedure string, streamType StreamType, options []HandlerOption) *handlerConfig {
|
||||
protoPath := extractProtoPath(procedure)
|
||||
config := handlerConfig{
|
||||
Procedure: protoPath,
|
||||
CompressionPools: make(map[string]*compressionPool),
|
||||
Codecs: make(map[string]Codec),
|
||||
BufferPool: newBufferPool(),
|
||||
StreamType: streamType,
|
||||
}
|
||||
withProtoBinaryCodec().applyToHandler(&config)
|
||||
withProtoJSONCodecs().applyToHandler(&config)
|
||||
withGzip().applyToHandler(&config)
|
||||
for _, opt := range options {
|
||||
opt.applyToHandler(&config)
|
||||
}
|
||||
return &config
|
||||
}
|
||||
|
||||
func (c *handlerConfig) newSpec() Spec {
|
||||
return Spec{
|
||||
Procedure: c.Procedure,
|
||||
Schema: c.Schema,
|
||||
StreamType: c.StreamType,
|
||||
IdempotencyLevel: c.IdempotencyLevel,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *handlerConfig) newProtocolHandlers() []protocolHandler {
|
||||
protocols := []protocol{
|
||||
&protocolConnect{},
|
||||
&protocolGRPC{web: false},
|
||||
&protocolGRPC{web: true},
|
||||
}
|
||||
handlers := make([]protocolHandler, 0, len(protocols))
|
||||
codecs := newReadOnlyCodecs(c.Codecs)
|
||||
compressors := newReadOnlyCompressionPools(
|
||||
c.CompressionPools,
|
||||
c.CompressionNames,
|
||||
)
|
||||
for _, protocol := range protocols {
|
||||
handlers = append(handlers, protocol.NewHandler(&protocolHandlerParams{
|
||||
Spec: c.newSpec(),
|
||||
Codecs: codecs,
|
||||
CompressionPools: compressors,
|
||||
CompressMinBytes: c.CompressMinBytes,
|
||||
BufferPool: c.BufferPool,
|
||||
ReadMaxBytes: c.ReadMaxBytes,
|
||||
SendMaxBytes: c.SendMaxBytes,
|
||||
RequireConnectProtocolHeader: c.RequireConnectProtocolHeader,
|
||||
IdempotencyLevel: c.IdempotencyLevel,
|
||||
}))
|
||||
}
|
||||
return handlers
|
||||
}
|
||||
|
||||
func newStreamHandler(
|
||||
config *handlerConfig,
|
||||
implementation StreamingHandlerFunc,
|
||||
) *Handler {
|
||||
if ic := config.Interceptor; ic != nil {
|
||||
implementation = ic.WrapStreamingHandler(implementation)
|
||||
}
|
||||
protocolHandlers := config.newProtocolHandlers()
|
||||
return &Handler{
|
||||
spec: config.newSpec(),
|
||||
implementation: implementation,
|
||||
protocolHandlers: mappedMethodHandlers(protocolHandlers),
|
||||
allowMethod: sortedAllowMethodValue(protocolHandlers),
|
||||
acceptPost: sortedAcceptPostValue(protocolHandlers),
|
||||
}
|
||||
}
|
||||
+198
@@ -0,0 +1,198 @@
|
||||
// Copyright 2021-2025 The Connect Authors
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package connect
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// ClientStream is the handler's view of a client streaming RPC.
|
||||
//
|
||||
// It's constructed as part of [Handler] invocation, but doesn't currently have
|
||||
// an exported constructor.
|
||||
type ClientStream[Req any] struct {
|
||||
conn StreamingHandlerConn
|
||||
initializer maybeInitializer
|
||||
msg *Req
|
||||
err error
|
||||
}
|
||||
|
||||
// Spec returns the specification for the RPC.
|
||||
func (c *ClientStream[_]) Spec() Spec {
|
||||
return c.conn.Spec()
|
||||
}
|
||||
|
||||
// Peer describes the client for this RPC.
|
||||
func (c *ClientStream[_]) Peer() Peer {
|
||||
return c.conn.Peer()
|
||||
}
|
||||
|
||||
// RequestHeader returns the headers received from the client.
|
||||
func (c *ClientStream[Req]) RequestHeader() http.Header {
|
||||
return c.conn.RequestHeader()
|
||||
}
|
||||
|
||||
// Receive advances the stream to the next message, which will then be
|
||||
// available through the Msg method. It returns false when the stream stops,
|
||||
// either by reaching the end or by encountering an unexpected error. After
|
||||
// Receive returns false, the Err method will return any unexpected error
|
||||
// encountered.
|
||||
func (c *ClientStream[Req]) Receive() bool {
|
||||
if c.err != nil {
|
||||
return false
|
||||
}
|
||||
c.msg = new(Req)
|
||||
if err := c.initializer.maybe(c.Spec(), c.msg); err != nil {
|
||||
c.err = err
|
||||
return false
|
||||
}
|
||||
c.err = c.conn.Receive(c.msg)
|
||||
return c.err == nil
|
||||
}
|
||||
|
||||
// Msg returns the most recent message unmarshaled by a call to Receive.
|
||||
func (c *ClientStream[Req]) Msg() *Req {
|
||||
if c.msg == nil {
|
||||
c.msg = new(Req)
|
||||
}
|
||||
return c.msg
|
||||
}
|
||||
|
||||
// Err returns the first non-EOF error that was encountered by Receive.
|
||||
func (c *ClientStream[Req]) Err() error {
|
||||
if c.err == nil || errors.Is(c.err, io.EOF) {
|
||||
return nil
|
||||
}
|
||||
return c.err
|
||||
}
|
||||
|
||||
// Conn exposes the underlying StreamingHandlerConn. This may be useful if
|
||||
// you'd prefer to wrap the connection in a different high-level API.
|
||||
func (c *ClientStream[Req]) Conn() StreamingHandlerConn {
|
||||
return c.conn
|
||||
}
|
||||
|
||||
// ServerStream is the handler's view of a server streaming RPC.
|
||||
//
|
||||
// It's constructed as part of [Handler] invocation, but doesn't currently have
|
||||
// an exported constructor.
|
||||
type ServerStream[Res any] struct {
|
||||
conn StreamingHandlerConn
|
||||
}
|
||||
|
||||
// ResponseHeader returns the response headers. Headers are sent with the first
|
||||
// call to Send.
|
||||
//
|
||||
// Headers beginning with "Connect-" and "Grpc-" are reserved for use by the
|
||||
// Connect and gRPC protocols. Applications shouldn't write them.
|
||||
func (s *ServerStream[Res]) ResponseHeader() http.Header {
|
||||
return s.conn.ResponseHeader()
|
||||
}
|
||||
|
||||
// ResponseTrailer returns the response trailers. Handlers may write to the
|
||||
// response trailers at any time before returning.
|
||||
//
|
||||
// Trailers beginning with "Connect-" and "Grpc-" are reserved for use by the
|
||||
// Connect and gRPC protocols. Applications shouldn't write them.
|
||||
func (s *ServerStream[Res]) ResponseTrailer() http.Header {
|
||||
return s.conn.ResponseTrailer()
|
||||
}
|
||||
|
||||
// Send a message to the client. The first call to Send also sends the response
|
||||
// headers.
|
||||
func (s *ServerStream[Res]) Send(msg *Res) error {
|
||||
if msg == nil {
|
||||
return s.conn.Send(nil)
|
||||
}
|
||||
return s.conn.Send(msg)
|
||||
}
|
||||
|
||||
// Conn exposes the underlying StreamingHandlerConn. This may be useful if
|
||||
// you'd prefer to wrap the connection in a different high-level API.
|
||||
func (s *ServerStream[Res]) Conn() StreamingHandlerConn {
|
||||
return s.conn
|
||||
}
|
||||
|
||||
// BidiStream is the handler's view of a bidirectional streaming RPC.
|
||||
//
|
||||
// It's constructed as part of [Handler] invocation, but doesn't currently have
|
||||
// an exported constructor.
|
||||
type BidiStream[Req, Res any] struct {
|
||||
conn StreamingHandlerConn
|
||||
initializer maybeInitializer
|
||||
}
|
||||
|
||||
// Spec returns the specification for the RPC.
|
||||
func (b *BidiStream[_, _]) Spec() Spec {
|
||||
return b.conn.Spec()
|
||||
}
|
||||
|
||||
// Peer describes the client for this RPC.
|
||||
func (b *BidiStream[_, _]) Peer() Peer {
|
||||
return b.conn.Peer()
|
||||
}
|
||||
|
||||
// RequestHeader returns the headers received from the client.
|
||||
func (b *BidiStream[Req, Res]) RequestHeader() http.Header {
|
||||
return b.conn.RequestHeader()
|
||||
}
|
||||
|
||||
// Receive a message. When the client is done sending messages, Receive will
|
||||
// return an error that wraps [io.EOF].
|
||||
func (b *BidiStream[Req, Res]) Receive() (*Req, error) {
|
||||
var req Req
|
||||
if err := b.initializer.maybe(b.Spec(), &req); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := b.conn.Receive(&req); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &req, nil
|
||||
}
|
||||
|
||||
// ResponseHeader returns the response headers. Headers are sent with the first
|
||||
// call to Send.
|
||||
//
|
||||
// Headers beginning with "Connect-" and "Grpc-" are reserved for use by the
|
||||
// Connect and gRPC protocols. Applications shouldn't write them.
|
||||
func (b *BidiStream[Req, Res]) ResponseHeader() http.Header {
|
||||
return b.conn.ResponseHeader()
|
||||
}
|
||||
|
||||
// ResponseTrailer returns the response trailers. Handlers may write to the
|
||||
// response trailers at any time before returning.
|
||||
//
|
||||
// Trailers beginning with "Connect-" and "Grpc-" are reserved for use by the
|
||||
// Connect and gRPC protocols. Applications shouldn't write them.
|
||||
func (b *BidiStream[Req, Res]) ResponseTrailer() http.Header {
|
||||
return b.conn.ResponseTrailer()
|
||||
}
|
||||
|
||||
// Send a message to the client. The first call to Send also sends the response
|
||||
// headers.
|
||||
func (b *BidiStream[Req, Res]) Send(msg *Res) error {
|
||||
if msg == nil {
|
||||
return b.conn.Send(nil)
|
||||
}
|
||||
return b.conn.Send(msg)
|
||||
}
|
||||
|
||||
// Conn exposes the underlying StreamingHandlerConn. This may be useful if
|
||||
// you'd prefer to wrap the connection in a different high-level API.
|
||||
func (b *BidiStream[Req, Res]) Conn() StreamingHandlerConn {
|
||||
return b.conn
|
||||
}
|
||||
+128
@@ -0,0 +1,128 @@
|
||||
// Copyright 2021-2025 The Connect Authors
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package connect
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
//nolint:gochecknoglobals
|
||||
var protocolHeaders = map[string]struct{}{
|
||||
// HTTP headers.
|
||||
headerContentType: {},
|
||||
headerContentLength: {},
|
||||
headerContentEncoding: {},
|
||||
headerHost: {},
|
||||
headerUserAgent: {},
|
||||
headerTrailer: {},
|
||||
headerDate: {},
|
||||
// Connect headers.
|
||||
connectUnaryHeaderAcceptCompression: {},
|
||||
connectUnaryTrailerPrefix: {},
|
||||
connectStreamingHeaderCompression: {},
|
||||
connectStreamingHeaderAcceptCompression: {},
|
||||
connectHeaderTimeout: {},
|
||||
connectHeaderProtocolVersion: {},
|
||||
// gRPC headers.
|
||||
grpcHeaderCompression: {},
|
||||
grpcHeaderAcceptCompression: {},
|
||||
grpcHeaderTimeout: {},
|
||||
grpcHeaderStatus: {},
|
||||
grpcHeaderMessage: {},
|
||||
grpcHeaderDetails: {},
|
||||
}
|
||||
|
||||
// EncodeBinaryHeader base64-encodes the data. It always emits unpadded values.
|
||||
//
|
||||
// In the Connect, gRPC, and gRPC-Web protocols, binary headers must have keys
|
||||
// ending in "-Bin".
|
||||
func EncodeBinaryHeader(data []byte) string {
|
||||
// gRPC specification says that implementations should emit unpadded values.
|
||||
return base64.RawStdEncoding.EncodeToString(data)
|
||||
}
|
||||
|
||||
// DecodeBinaryHeader base64-decodes the data. It can decode padded or unpadded
|
||||
// values. Following usual HTTP semantics, multiple base64-encoded values may
|
||||
// be joined with a comma. When receiving such comma-separated values, split
|
||||
// them with [strings.Split] before calling DecodeBinaryHeader.
|
||||
//
|
||||
// Binary headers sent using the Connect, gRPC, and gRPC-Web protocols have
|
||||
// keys ending in "-Bin".
|
||||
func DecodeBinaryHeader(data string) ([]byte, error) {
|
||||
if len(data)%4 != 0 {
|
||||
// Data definitely isn't padded.
|
||||
return base64.RawStdEncoding.DecodeString(data)
|
||||
}
|
||||
// Either the data was padded, or padding wasn't necessary. In both cases,
|
||||
// the padding-aware decoder works.
|
||||
return base64.StdEncoding.DecodeString(data)
|
||||
}
|
||||
|
||||
func mergeHeaders(into, from http.Header) {
|
||||
for key, vals := range from {
|
||||
if len(vals) == 0 {
|
||||
// For response trailers, net/http will pre-populate entries
|
||||
// with nil values based on the "Trailer" header. But if there
|
||||
// are no actual values for those keys, we skip them.
|
||||
continue
|
||||
}
|
||||
into[key] = append(into[key], vals...)
|
||||
}
|
||||
}
|
||||
|
||||
// mergeNonProtocolHeaders merges headers excluding protocol headers defined in
|
||||
// protocolHeaders.
|
||||
func mergeNonProtocolHeaders(into, from http.Header) {
|
||||
for key, vals := range from {
|
||||
if len(vals) == 0 {
|
||||
// For response trailers, net/http will pre-populate entries
|
||||
// with nil values based on the "Trailer" header. But if there
|
||||
// are no actual values for those keys, we skip them.
|
||||
continue
|
||||
}
|
||||
if _, isProtocolHeader := protocolHeaders[key]; !isProtocolHeader {
|
||||
into[key] = append(into[key], vals...)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// getHeaderCanonical is a shortcut for Header.Get() which
|
||||
// bypasses the CanonicalMIMEHeaderKey operation when we
|
||||
// know the key is already in canonical form.
|
||||
func getHeaderCanonical(h http.Header, key string) string {
|
||||
if h == nil {
|
||||
return ""
|
||||
}
|
||||
v := h[key]
|
||||
if len(v) == 0 {
|
||||
return ""
|
||||
}
|
||||
return v[0]
|
||||
}
|
||||
|
||||
// setHeaderCanonical is a shortcut for Header.Set() which
|
||||
// bypasses the CanonicalMIMEHeaderKey operation when we
|
||||
// know the key is already in canonical form.
|
||||
func setHeaderCanonical(h http.Header, key, value string) {
|
||||
h[key] = []string{value}
|
||||
}
|
||||
|
||||
// delHeaderCanonical is a shortcut for Header.Del() which
|
||||
// bypasses the CanonicalMIMEHeaderKey operation when we
|
||||
// know the key is already in canonical form.
|
||||
func delHeaderCanonical(h http.Header, key string) {
|
||||
delete(h, key)
|
||||
}
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
// Copyright 2021-2025 The Connect Authors
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package connect
|
||||
|
||||
import "fmt"
|
||||
|
||||
// An IdempotencyLevel is a value that declares how "idempotent" an RPC is. This
|
||||
// value can affect RPC behaviors, such as determining whether it is safe to
|
||||
// retry a request, or what kinds of request modalities are allowed for a given
|
||||
// procedure.
|
||||
type IdempotencyLevel int
|
||||
|
||||
// NOTE: For simplicity, these should be kept in sync with the values of the
|
||||
// google.protobuf.MethodOptions.IdempotencyLevel enumeration.
|
||||
|
||||
const (
|
||||
// IdempotencyUnknown is the default idempotency level. A procedure with
|
||||
// this idempotency level may not be idempotent. This is appropriate for
|
||||
// any kind of procedure.
|
||||
IdempotencyUnknown IdempotencyLevel = 0
|
||||
|
||||
// IdempotencyNoSideEffects is the idempotency level that specifies that a
|
||||
// given call has no side-effects. This is equivalent to [RFC 9110 § 9.2.1]
|
||||
// "safe" methods in terms of semantics. This procedure should not mutate
|
||||
// any state. This idempotency level is appropriate for queries, or anything
|
||||
// that would be suitable for an HTTP GET request. In addition, due to the
|
||||
// lack of side-effects, such a procedure would be suitable to retry and
|
||||
// expect that the results will not be altered by preceding attempts.
|
||||
//
|
||||
// [RFC 9110 § 9.2.1]: https://www.rfc-editor.org/rfc/rfc9110.html#section-9.2.1
|
||||
IdempotencyNoSideEffects IdempotencyLevel = 1
|
||||
|
||||
// IdempotencyIdempotent is the idempotency level that specifies that a
|
||||
// given call is "idempotent", such that multiple instances of the same
|
||||
// request to this procedure would have the same side-effects as a single
|
||||
// request. This is equivalent to [RFC 9110 § 9.2.2] "idempotent" methods.
|
||||
// This level is a subset of the previous level. This idempotency level is
|
||||
// appropriate for any procedure that is safe to retry multiple times
|
||||
// and be guaranteed that the response and side-effects will not be altered
|
||||
// as a result of multiple attempts, for example, entity deletion requests.
|
||||
//
|
||||
// [RFC 9110 § 9.2.2]: https://www.rfc-editor.org/rfc/rfc9110.html#section-9.2.2
|
||||
IdempotencyIdempotent IdempotencyLevel = 2
|
||||
)
|
||||
|
||||
func (i IdempotencyLevel) String() string {
|
||||
switch i {
|
||||
case IdempotencyUnknown:
|
||||
return "idempotency_unknown"
|
||||
case IdempotencyNoSideEffects:
|
||||
return "no_side_effects"
|
||||
case IdempotencyIdempotent:
|
||||
return "idempotent"
|
||||
}
|
||||
return fmt.Sprintf("idempotency_%d", i)
|
||||
}
|
||||
+138
@@ -0,0 +1,138 @@
|
||||
// Copyright 2021-2025 The Connect Authors
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package connect
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
)
|
||||
|
||||
var (
|
||||
// errNewClientContextProhibited signals that a new client context was created
|
||||
// in an interceptor, which is prohibited.
|
||||
errNewClientContextProhibited = errors.New("creating a new context in an interceptor is prohibited")
|
||||
)
|
||||
|
||||
// UnaryFunc is the generic signature of a unary RPC. Interceptors may wrap
|
||||
// Funcs.
|
||||
//
|
||||
// The type of the request and response structs depend on the codec being used.
|
||||
// When using Protobuf, request.Any() and response.Any() will always be
|
||||
// [proto.Message] implementations.
|
||||
type UnaryFunc func(context.Context, AnyRequest) (AnyResponse, error)
|
||||
|
||||
// StreamingClientFunc is the generic signature of a streaming RPC from the client's
|
||||
// perspective. Interceptors may wrap StreamingClientFuncs.
|
||||
type StreamingClientFunc func(context.Context, Spec) StreamingClientConn
|
||||
|
||||
// StreamingHandlerFunc is the generic signature of a streaming RPC from the
|
||||
// handler's perspective. Interceptors may wrap StreamingHandlerFuncs.
|
||||
type StreamingHandlerFunc func(context.Context, StreamingHandlerConn) error
|
||||
|
||||
// An Interceptor adds logic to a generated handler or client, like the
|
||||
// decorators or middleware you may have seen in other libraries. Interceptors
|
||||
// may mutate requests and responses, handle errors, retry, recover from panics,
|
||||
// emit logs and metrics, or do nearly anything else.
|
||||
//
|
||||
// The returned functions must be safe to call concurrently.
|
||||
type Interceptor interface {
|
||||
WrapUnary(UnaryFunc) UnaryFunc
|
||||
WrapStreamingClient(StreamingClientFunc) StreamingClientFunc
|
||||
WrapStreamingHandler(StreamingHandlerFunc) StreamingHandlerFunc
|
||||
}
|
||||
|
||||
// UnaryInterceptorFunc is a simple Interceptor implementation that only
|
||||
// wraps unary RPCs. It has no effect on streaming RPCs.
|
||||
type UnaryInterceptorFunc func(UnaryFunc) UnaryFunc
|
||||
|
||||
// WrapUnary implements [Interceptor] by applying the interceptor function.
|
||||
func (f UnaryInterceptorFunc) WrapUnary(next UnaryFunc) UnaryFunc { return f(next) }
|
||||
|
||||
// WrapStreamingClient implements [Interceptor] with a no-op.
|
||||
func (f UnaryInterceptorFunc) WrapStreamingClient(next StreamingClientFunc) StreamingClientFunc {
|
||||
return next
|
||||
}
|
||||
|
||||
// WrapStreamingHandler implements [Interceptor] with a no-op.
|
||||
func (f UnaryInterceptorFunc) WrapStreamingHandler(next StreamingHandlerFunc) StreamingHandlerFunc {
|
||||
return next
|
||||
}
|
||||
|
||||
// A chain composes multiple interceptors into one.
|
||||
type chain struct {
|
||||
interceptors []Interceptor
|
||||
}
|
||||
|
||||
// newChain composes multiple interceptors into one.
|
||||
func newChain(interceptors []Interceptor) *chain {
|
||||
// We usually wrap in reverse order to have the first interceptor from
|
||||
// the slice act first. Rather than doing this dance repeatedly, reverse the
|
||||
// interceptor order now.
|
||||
var chain chain
|
||||
for i := len(interceptors) - 1; i >= 0; i-- {
|
||||
if interceptor := interceptors[i]; interceptor != nil {
|
||||
chain.interceptors = append(chain.interceptors, interceptor)
|
||||
}
|
||||
}
|
||||
return &chain
|
||||
}
|
||||
|
||||
func (c *chain) WrapUnary(next UnaryFunc) UnaryFunc {
|
||||
for _, interceptor := range c.interceptors {
|
||||
next = unaryThunk(next)
|
||||
next = interceptor.WrapUnary(next)
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
func (c *chain) WrapStreamingClient(next StreamingClientFunc) StreamingClientFunc {
|
||||
for _, interceptor := range c.interceptors {
|
||||
next = streamingClientThunk(next)
|
||||
next = interceptor.WrapStreamingClient(next)
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
func (c *chain) WrapStreamingHandler(next StreamingHandlerFunc) StreamingHandlerFunc {
|
||||
for _, interceptor := range c.interceptors {
|
||||
next = interceptor.WrapStreamingHandler(next)
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
func unaryThunk(next UnaryFunc) UnaryFunc {
|
||||
return func(ctx context.Context, req AnyRequest) (AnyResponse, error) {
|
||||
if err := checkSentinel(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return next(ctx, req)
|
||||
}
|
||||
}
|
||||
|
||||
func streamingClientThunk(next StreamingClientFunc) StreamingClientFunc {
|
||||
return func(ctx context.Context, spec Spec) StreamingClientConn {
|
||||
if err := checkSentinel(ctx); err != nil {
|
||||
return &errStreamingClientConn{err: err}
|
||||
}
|
||||
return next(ctx, spec)
|
||||
}
|
||||
}
|
||||
|
||||
func checkSentinel(ctx context.Context) error {
|
||||
if ctx.Value(clientCallInfoContextKey{}) != ctx.Value(sentinelContextKey{}) {
|
||||
return errNewClientContextProhibited
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Generated
Vendored
+165
@@ -0,0 +1,165 @@
|
||||
// Copyright 2021-2025 The Connect Authors
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
// Code generated by protoc-gen-go. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-go v1.36.9
|
||||
// protoc (unknown)
|
||||
// source: connectext/grpc/status/v1/status.proto
|
||||
|
||||
// This package is for internal use by Connect, and provides no backward
|
||||
// compatibility guarantees whatsoever.
|
||||
|
||||
package statusv1
|
||||
|
||||
import (
|
||||
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
|
||||
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
|
||||
anypb "google.golang.org/protobuf/types/known/anypb"
|
||||
reflect "reflect"
|
||||
sync "sync"
|
||||
unsafe "unsafe"
|
||||
)
|
||||
|
||||
const (
|
||||
// Verify that this generated code is sufficiently up-to-date.
|
||||
_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
|
||||
// Verify that runtime/protoimpl is sufficiently up-to-date.
|
||||
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
|
||||
)
|
||||
|
||||
// See https://cloud.google.com/apis/design/errors.
|
||||
//
|
||||
// This struct must remain binary-compatible with
|
||||
// https://github.com/googleapis/googleapis/blob/master/google/rpc/status.proto.
|
||||
type Status struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Code int32 `protobuf:"varint,1,opt,name=code,proto3" json:"code,omitempty"` // a google.rpc.Code
|
||||
Message string `protobuf:"bytes,2,opt,name=message,proto3" json:"message,omitempty"` // developer-facing, English (localize in details or client-side)
|
||||
Details []*anypb.Any `protobuf:"bytes,3,rep,name=details,proto3" json:"details,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *Status) Reset() {
|
||||
*x = Status{}
|
||||
mi := &file_connectext_grpc_status_v1_status_proto_msgTypes[0]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *Status) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*Status) ProtoMessage() {}
|
||||
|
||||
func (x *Status) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_connectext_grpc_status_v1_status_proto_msgTypes[0]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use Status.ProtoReflect.Descriptor instead.
|
||||
func (*Status) Descriptor() ([]byte, []int) {
|
||||
return file_connectext_grpc_status_v1_status_proto_rawDescGZIP(), []int{0}
|
||||
}
|
||||
|
||||
func (x *Status) GetCode() int32 {
|
||||
if x != nil {
|
||||
return x.Code
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *Status) GetMessage() string {
|
||||
if x != nil {
|
||||
return x.Message
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *Status) GetDetails() []*anypb.Any {
|
||||
if x != nil {
|
||||
return x.Details
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var File_connectext_grpc_status_v1_status_proto protoreflect.FileDescriptor
|
||||
|
||||
const file_connectext_grpc_status_v1_status_proto_rawDesc = "" +
|
||||
"\n" +
|
||||
"&connectext/grpc/status/v1/status.proto\x12\x0egrpc.status.v1\x1a\x19google/protobuf/any.proto\"f\n" +
|
||||
"\x06Status\x12\x12\n" +
|
||||
"\x04code\x18\x01 \x01(\x05R\x04code\x12\x18\n" +
|
||||
"\amessage\x18\x02 \x01(\tR\amessage\x12.\n" +
|
||||
"\adetails\x18\x03 \x03(\v2\x14.google.protobuf.AnyR\adetailsB\xc3\x01\n" +
|
||||
"\x12com.grpc.status.v1B\vStatusProtoP\x01ZFconnectrpc.com/connect/internal/gen/connectext/grpc/status/v1;statusv1\xa2\x02\x03GSX\xaa\x02\x0eGrpc.Status.V1\xca\x02\x0eGrpc\\Status\\V1\xe2\x02\x1aGrpc\\Status\\V1\\GPBMetadata\xea\x02\x10Grpc::Status::V1b\x06proto3"
|
||||
|
||||
var (
|
||||
file_connectext_grpc_status_v1_status_proto_rawDescOnce sync.Once
|
||||
file_connectext_grpc_status_v1_status_proto_rawDescData []byte
|
||||
)
|
||||
|
||||
func file_connectext_grpc_status_v1_status_proto_rawDescGZIP() []byte {
|
||||
file_connectext_grpc_status_v1_status_proto_rawDescOnce.Do(func() {
|
||||
file_connectext_grpc_status_v1_status_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_connectext_grpc_status_v1_status_proto_rawDesc), len(file_connectext_grpc_status_v1_status_proto_rawDesc)))
|
||||
})
|
||||
return file_connectext_grpc_status_v1_status_proto_rawDescData
|
||||
}
|
||||
|
||||
var file_connectext_grpc_status_v1_status_proto_msgTypes = make([]protoimpl.MessageInfo, 1)
|
||||
var file_connectext_grpc_status_v1_status_proto_goTypes = []any{
|
||||
(*Status)(nil), // 0: grpc.status.v1.Status
|
||||
(*anypb.Any)(nil), // 1: google.protobuf.Any
|
||||
}
|
||||
var file_connectext_grpc_status_v1_status_proto_depIdxs = []int32{
|
||||
1, // 0: grpc.status.v1.Status.details:type_name -> google.protobuf.Any
|
||||
1, // [1:1] is the sub-list for method output_type
|
||||
1, // [1:1] is the sub-list for method input_type
|
||||
1, // [1:1] is the sub-list for extension type_name
|
||||
1, // [1:1] is the sub-list for extension extendee
|
||||
0, // [0:1] is the sub-list for field type_name
|
||||
}
|
||||
|
||||
func init() { file_connectext_grpc_status_v1_status_proto_init() }
|
||||
func file_connectext_grpc_status_v1_status_proto_init() {
|
||||
if File_connectext_grpc_status_v1_status_proto != nil {
|
||||
return
|
||||
}
|
||||
type x struct{}
|
||||
out := protoimpl.TypeBuilder{
|
||||
File: protoimpl.DescBuilder{
|
||||
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
|
||||
RawDescriptor: unsafe.Slice(unsafe.StringData(file_connectext_grpc_status_v1_status_proto_rawDesc), len(file_connectext_grpc_status_v1_status_proto_rawDesc)),
|
||||
NumEnums: 0,
|
||||
NumMessages: 1,
|
||||
NumExtensions: 0,
|
||||
NumServices: 0,
|
||||
},
|
||||
GoTypes: file_connectext_grpc_status_v1_status_proto_goTypes,
|
||||
DependencyIndexes: file_connectext_grpc_status_v1_status_proto_depIdxs,
|
||||
MessageInfos: file_connectext_grpc_status_v1_status_proto_msgTypes,
|
||||
}.Build()
|
||||
File_connectext_grpc_status_v1_status_proto = out.File
|
||||
file_connectext_grpc_status_v1_status_proto_goTypes = nil
|
||||
file_connectext_grpc_status_v1_status_proto_depIdxs = nil
|
||||
}
|
||||
+647
@@ -0,0 +1,647 @@
|
||||
// Copyright 2021-2025 The Connect Authors
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package connect
|
||||
|
||||
import (
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// A ClientOption configures a [Client].
|
||||
//
|
||||
// In addition to any options grouped in the documentation below, remember that
|
||||
// any [Option] is also a valid ClientOption.
|
||||
type ClientOption interface {
|
||||
applyToClient(*clientConfig)
|
||||
}
|
||||
|
||||
// WithAcceptCompression makes a compression algorithm available to a client.
|
||||
// Clients ask servers to compress responses using any of the registered
|
||||
// algorithms. The first registered algorithm is treated as the least
|
||||
// preferred, and the last registered algorithm is the most preferred.
|
||||
//
|
||||
// It's safe to use this option liberally: servers will ignore any
|
||||
// compression algorithms they don't support. To compress requests, pair this
|
||||
// option with [WithSendCompression]. To remove support for a
|
||||
// previously-registered compression algorithm, use WithAcceptCompression with
|
||||
// nil decompressor and compressor constructors.
|
||||
//
|
||||
// Clients accept gzipped responses by default, using a compressor backed by the
|
||||
// standard library's [gzip] package with the default compression level. Use
|
||||
// [WithSendGzip] to compress requests with gzip.
|
||||
//
|
||||
// Calling WithAcceptCompression with an empty name is a no-op.
|
||||
func WithAcceptCompression(
|
||||
name string,
|
||||
newDecompressor func() Decompressor,
|
||||
newCompressor func() Compressor,
|
||||
) ClientOption {
|
||||
return &compressionOption{
|
||||
Name: name,
|
||||
CompressionPool: newCompressionPool(newDecompressor, newCompressor),
|
||||
}
|
||||
}
|
||||
|
||||
// WithClientOptions composes multiple ClientOptions into one.
|
||||
func WithClientOptions(options ...ClientOption) ClientOption {
|
||||
return &clientOptionsOption{options}
|
||||
}
|
||||
|
||||
// WithGRPC configures clients to use the HTTP/2 gRPC protocol.
|
||||
func WithGRPC() ClientOption {
|
||||
return &grpcOption{web: false}
|
||||
}
|
||||
|
||||
// WithGRPCWeb configures clients to use the gRPC-Web protocol.
|
||||
func WithGRPCWeb() ClientOption {
|
||||
return &grpcOption{web: true}
|
||||
}
|
||||
|
||||
// WithProtoJSON configures a client to send JSON-encoded data instead of
|
||||
// binary Protobuf. It uses the standard Protobuf JSON mapping as implemented
|
||||
// by [google.golang.org/protobuf/encoding/protojson]: fields are named using
|
||||
// lowerCamelCase, zero values are omitted, missing required fields are errors,
|
||||
// enums are emitted as strings, etc.
|
||||
func WithProtoJSON() ClientOption {
|
||||
return WithCodec(&protoJSONCodec{codecNameJSON})
|
||||
}
|
||||
|
||||
// WithSendCompression configures the client to use the specified algorithm to
|
||||
// compress request messages. If the algorithm has not been registered using
|
||||
// [WithAcceptCompression], the client will return errors at runtime.
|
||||
//
|
||||
// Because some servers don't support compression, clients default to sending
|
||||
// uncompressed requests.
|
||||
func WithSendCompression(name string) ClientOption {
|
||||
return &sendCompressionOption{Name: name}
|
||||
}
|
||||
|
||||
// WithSendGzip configures the client to gzip requests. Since clients have
|
||||
// access to a gzip compressor by default, WithSendGzip doesn't require
|
||||
// [WithSendCompression].
|
||||
//
|
||||
// Some servers don't support gzip, so clients default to sending uncompressed
|
||||
// requests.
|
||||
func WithSendGzip() ClientOption {
|
||||
return WithSendCompression(compressionGzip)
|
||||
}
|
||||
|
||||
// A HandlerOption configures a [Handler].
|
||||
//
|
||||
// In addition to any options grouped in the documentation below, remember that
|
||||
// any [Option] is also a HandlerOption.
|
||||
type HandlerOption interface {
|
||||
applyToHandler(*handlerConfig)
|
||||
}
|
||||
|
||||
// WithCompression configures handlers to support a compression algorithm.
|
||||
// Clients may send messages compressed with that algorithm and/or request
|
||||
// compressed responses. The [Compressor] and [Decompressor] produced by the
|
||||
// supplied constructors must use the same algorithm. Internally, Connect pools
|
||||
// compressors and decompressors.
|
||||
//
|
||||
// By default, handlers support gzip using the standard library's
|
||||
// [compress/gzip] package at the default compression level. To remove support for
|
||||
// a previously-registered compression algorithm, use WithCompression with nil
|
||||
// decompressor and compressor constructors.
|
||||
//
|
||||
// Calling WithCompression with an empty name is a no-op.
|
||||
func WithCompression(
|
||||
name string,
|
||||
newDecompressor func() Decompressor,
|
||||
newCompressor func() Compressor,
|
||||
) HandlerOption {
|
||||
return &compressionOption{
|
||||
Name: name,
|
||||
CompressionPool: newCompressionPool(newDecompressor, newCompressor),
|
||||
}
|
||||
}
|
||||
|
||||
// WithHandlerOptions composes multiple HandlerOptions into one.
|
||||
func WithHandlerOptions(options ...HandlerOption) HandlerOption {
|
||||
return &handlerOptionsOption{options}
|
||||
}
|
||||
|
||||
// WithRecover adds an interceptor that recovers from panics. The supplied
|
||||
// function receives the context, [Spec], request headers, and the recovered
|
||||
// value (which may be nil). It must return an error to send back to the
|
||||
// client. It may also log the panic, emit metrics, or execute other
|
||||
// error-handling logic. Handler functions must be safe to call concurrently.
|
||||
//
|
||||
// To preserve compatibility with [net/http]'s semantics, this interceptor
|
||||
// doesn't handle panics with [http.ErrAbortHandler].
|
||||
//
|
||||
// By default, handlers don't recover from panics. Because the standard
|
||||
// library's [http.Server] recovers from panics by default, this option isn't
|
||||
// usually necessary to prevent crashes. Instead, it helps servers collect
|
||||
// RPC-specific data during panics and send a more detailed error to
|
||||
// clients.
|
||||
func WithRecover(handle func(context.Context, Spec, http.Header, any) error) HandlerOption {
|
||||
return WithInterceptors(&recoverHandlerInterceptor{handle: handle})
|
||||
}
|
||||
|
||||
// WithRequireConnectProtocolHeader configures the Handler to require requests
|
||||
// using the Connect RPC protocol to include the Connect-Protocol-Version
|
||||
// header. This ensures that HTTP proxies and net/http middleware can easily
|
||||
// identify valid Connect requests, even if they use a common Content-Type like
|
||||
// application/json. However, it makes ad-hoc requests with tools like cURL
|
||||
// more laborious. Streaming requests are not affected by this option.
|
||||
//
|
||||
// This option has no effect if the client uses the gRPC or gRPC-Web protocols.
|
||||
func WithRequireConnectProtocolHeader() HandlerOption {
|
||||
return &requireConnectProtocolHeaderOption{}
|
||||
}
|
||||
|
||||
// WithConditionalHandlerOptions allows procedures in the same service to have
|
||||
// different configurations: for example, one procedure may need a much larger
|
||||
// WithReadMaxBytes setting than the others.
|
||||
//
|
||||
// WithConditionalHandlerOptions takes a function which may inspect each
|
||||
// procedure's Spec before deciding which options to apply. Returning a nil
|
||||
// slice is safe.
|
||||
func WithConditionalHandlerOptions(conditional func(spec Spec) []HandlerOption) HandlerOption {
|
||||
return &conditionalHandlerOptions{conditional: conditional}
|
||||
}
|
||||
|
||||
// Option implements both [ClientOption] and [HandlerOption], so it can be
|
||||
// applied both client-side and server-side.
|
||||
type Option interface {
|
||||
ClientOption
|
||||
HandlerOption
|
||||
}
|
||||
|
||||
// WithSchema provides a parsed representation of the schema for an RPC to a
|
||||
// client or handler. The supplied schema is exposed as [Spec].Schema. This
|
||||
// option is typically added by generated code.
|
||||
//
|
||||
// For services using protobuf schemas, the supplied schema should be a
|
||||
// [google.golang.org/protobuf/reflect/protoreflect.MethodDescriptor].
|
||||
func WithSchema(schema any) Option {
|
||||
return &schemaOption{Schema: schema}
|
||||
}
|
||||
|
||||
// WithRequestInitializer provides a function that initializes a new message.
|
||||
// It may be used to dynamically construct request messages. It is called on
|
||||
// server receives to construct the message to be unmarshaled into. The message
|
||||
// will be a non nil pointer to the type created by the handler. Use the Schema
|
||||
// field of the [Spec] to determine the type of the message.
|
||||
func WithRequestInitializer(initializer func(spec Spec, message any) error) HandlerOption {
|
||||
return &initializerOption{Initializer: initializer}
|
||||
}
|
||||
|
||||
// WithResponseInitializer provides a function that initializes a new message.
|
||||
// It may be used to dynamically construct response messages. It is called on
|
||||
// client receives to construct the message to be unmarshaled into. The message
|
||||
// will be a non nil pointer to the type created by the client. Use the Schema
|
||||
// field of the [Spec] to determine the type of the message.
|
||||
func WithResponseInitializer(initializer func(spec Spec, message any) error) ClientOption {
|
||||
return &initializerOption{Initializer: initializer}
|
||||
}
|
||||
|
||||
// WithCodec registers a serialization method with a client or handler.
|
||||
// Handlers may have multiple codecs registered, and use whichever the client
|
||||
// chooses. Clients may only have a single codec.
|
||||
//
|
||||
// By default, handlers and clients support binary Protocol Buffer data using
|
||||
// [google.golang.org/protobuf/proto]. Handlers also support JSON by default,
|
||||
// using the standard Protobuf JSON mapping. Users with more specialized needs
|
||||
// may override the default codecs by registering a new codec under the "proto"
|
||||
// or "json" names. When supplying a custom "proto" codec, keep in mind that
|
||||
// some unexported, protocol-specific messages are serialized using Protobuf -
|
||||
// take care to fall back to the standard Protobuf implementation if
|
||||
// necessary.
|
||||
//
|
||||
// Registering a codec with an empty name is a no-op.
|
||||
func WithCodec(codec Codec) Option {
|
||||
return &codecOption{Codec: codec}
|
||||
}
|
||||
|
||||
// WithCompressMinBytes sets a minimum size threshold for compression:
|
||||
// regardless of compressor configuration, messages smaller than the configured
|
||||
// minimum are sent uncompressed.
|
||||
//
|
||||
// The default minimum is zero. Setting a minimum compression threshold may
|
||||
// improve overall performance, because the CPU cost of compressing very small
|
||||
// messages usually isn't worth the small reduction in network I/O.
|
||||
func WithCompressMinBytes(minBytes int) Option {
|
||||
return &compressMinBytesOption{Min: minBytes}
|
||||
}
|
||||
|
||||
// WithReadMaxBytes limits the performance impact of pathologically large
|
||||
// messages sent by the other party. For handlers, WithReadMaxBytes limits the size
|
||||
// of a message that the client can send. For clients, WithReadMaxBytes limits the
|
||||
// size of a message that the server can respond with. Limits apply to each Protobuf
|
||||
// message, not to the stream as a whole.
|
||||
//
|
||||
// Setting WithReadMaxBytes to zero allows any message size. Both clients and
|
||||
// handlers default to allowing any request size.
|
||||
//
|
||||
// Handlers may also use [http.MaxBytesHandler] to limit the total size of the
|
||||
// HTTP request stream (rather than the per-message size). Connect handles
|
||||
// [http.MaxBytesError] specially, so clients still receive errors with the
|
||||
// appropriate error code and informative messages.
|
||||
func WithReadMaxBytes(maxBytes int) Option {
|
||||
return &readMaxBytesOption{Max: maxBytes}
|
||||
}
|
||||
|
||||
// WithSendMaxBytes prevents sending messages too large for the client/handler
|
||||
// to handle without significant performance overhead. For handlers, WithSendMaxBytes
|
||||
// limits the size of a message that the handler can respond with. For clients,
|
||||
// WithSendMaxBytes limits the size of a message that the client can send. Limits
|
||||
// apply to each message, not to the stream as a whole.
|
||||
//
|
||||
// Setting WithSendMaxBytes to zero allows any message size. Both clients and
|
||||
// handlers default to allowing any message size.
|
||||
func WithSendMaxBytes(maxBytes int) Option {
|
||||
return &sendMaxBytesOption{Max: maxBytes}
|
||||
}
|
||||
|
||||
// WithIdempotency declares the idempotency of the procedure. This can determine
|
||||
// whether a procedure call can safely be retried, and may affect which request
|
||||
// modalities are allowed for a given procedure call.
|
||||
//
|
||||
// In most cases, you should not need to manually set this. It is normally set
|
||||
// by the code generator for your schema. For protobuf schemas, it can be set like this:
|
||||
//
|
||||
// rpc Ping(PingRequest) returns (PingResponse) {
|
||||
// option idempotency_level = NO_SIDE_EFFECTS;
|
||||
// }
|
||||
func WithIdempotency(idempotencyLevel IdempotencyLevel) Option {
|
||||
return &idempotencyOption{idempotencyLevel: idempotencyLevel}
|
||||
}
|
||||
|
||||
// WithHTTPGet allows Connect-protocol clients to use HTTP GET requests for
|
||||
// side-effect free unary RPC calls. Typically, the service schema indicates
|
||||
// which procedures are idempotent (see [WithIdempotency] for an example
|
||||
// protobuf schema). The gRPC and gRPC-Web protocols are POST-only, so this
|
||||
// option has no effect when combined with [WithGRPC] or [WithGRPCWeb].
|
||||
//
|
||||
// Using HTTP GET requests makes it easier to take advantage of CDNs, caching
|
||||
// reverse proxies, and browsers' built-in caching. Note, however, that servers
|
||||
// don't automatically set any cache headers; you can set cache headers using
|
||||
// interceptors or by adding headers in individual procedure implementations.
|
||||
//
|
||||
// By default, all requests are made as HTTP POSTs.
|
||||
func WithHTTPGet() ClientOption {
|
||||
return &enableGet{}
|
||||
}
|
||||
|
||||
// WithInterceptors configures a client or handler's interceptor stack. Repeated
|
||||
// WithInterceptors options are applied in order, so
|
||||
//
|
||||
// WithInterceptors(A) + WithInterceptors(B, C) == WithInterceptors(A, B, C)
|
||||
//
|
||||
// Unary interceptors compose like an onion. The first interceptor provided is
|
||||
// the outermost layer of the onion: it acts first on the context and request,
|
||||
// and last on the response and error.
|
||||
//
|
||||
// Stream interceptors also behave like an onion: the first interceptor
|
||||
// provided is the outermost wrapper for the [StreamingClientConn] or
|
||||
// [StreamingHandlerConn]. It's the first to see sent messages and the last to
|
||||
// see received messages.
|
||||
//
|
||||
// Applied to client and handler, WithInterceptors(A, B, ..., Y, Z) produces:
|
||||
//
|
||||
// client.Send() client.Receive()
|
||||
// | ^
|
||||
// v |
|
||||
// A --- --- A
|
||||
// B --- --- B
|
||||
// : ... ... :
|
||||
// Y --- --- Y
|
||||
// Z --- --- Z
|
||||
// | ^
|
||||
// v |
|
||||
// = = = = = = = = = = = = = = = =
|
||||
// network
|
||||
// = = = = = = = = = = = = = = = =
|
||||
// | ^
|
||||
// v |
|
||||
// A --- --- A
|
||||
// B --- --- B
|
||||
// : ... ... :
|
||||
// Y --- --- Y
|
||||
// Z --- --- Z
|
||||
// | ^
|
||||
// v |
|
||||
// handler.Receive() handler.Send()
|
||||
// | ^
|
||||
// | |
|
||||
// '-> handler logic >-'
|
||||
//
|
||||
// Note that in clients, Send handles the request message(s) and Receive
|
||||
// handles the response message(s). For handlers, it's the reverse. Depending
|
||||
// on your interceptor's logic, you may need to wrap one method in clients and
|
||||
// the other in handlers.
|
||||
func WithInterceptors(interceptors ...Interceptor) Option {
|
||||
return &interceptorsOption{interceptors}
|
||||
}
|
||||
|
||||
// WithOptions composes multiple Options into one.
|
||||
func WithOptions(options ...Option) Option {
|
||||
return &optionsOption{options}
|
||||
}
|
||||
|
||||
type schemaOption struct {
|
||||
Schema any
|
||||
}
|
||||
|
||||
func (o *schemaOption) applyToClient(config *clientConfig) {
|
||||
config.Schema = o.Schema
|
||||
}
|
||||
|
||||
func (o *schemaOption) applyToHandler(config *handlerConfig) {
|
||||
config.Schema = o.Schema
|
||||
}
|
||||
|
||||
type initializerOption struct {
|
||||
Initializer func(spec Spec, message any) error
|
||||
}
|
||||
|
||||
func (o *initializerOption) applyToHandler(config *handlerConfig) {
|
||||
config.Initializer = maybeInitializer{initializer: o.Initializer}
|
||||
}
|
||||
|
||||
func (o *initializerOption) applyToClient(config *clientConfig) {
|
||||
config.Initializer = maybeInitializer{initializer: o.Initializer}
|
||||
}
|
||||
|
||||
type maybeInitializer struct {
|
||||
initializer func(spec Spec, message any) error
|
||||
}
|
||||
|
||||
func (o maybeInitializer) maybe(spec Spec, message any) error {
|
||||
if o.initializer != nil {
|
||||
return o.initializer(spec, message)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type clientOptionsOption struct {
|
||||
options []ClientOption
|
||||
}
|
||||
|
||||
func (o *clientOptionsOption) applyToClient(config *clientConfig) {
|
||||
for _, option := range o.options {
|
||||
option.applyToClient(config)
|
||||
}
|
||||
}
|
||||
|
||||
type codecOption struct {
|
||||
Codec Codec
|
||||
}
|
||||
|
||||
func (o *codecOption) applyToClient(config *clientConfig) {
|
||||
if o.Codec == nil || o.Codec.Name() == "" {
|
||||
return
|
||||
}
|
||||
config.Codec = o.Codec
|
||||
}
|
||||
|
||||
func (o *codecOption) applyToHandler(config *handlerConfig) {
|
||||
if o.Codec == nil || o.Codec.Name() == "" {
|
||||
return
|
||||
}
|
||||
config.Codecs[o.Codec.Name()] = o.Codec
|
||||
}
|
||||
|
||||
type compressionOption struct {
|
||||
Name string
|
||||
CompressionPool *compressionPool
|
||||
}
|
||||
|
||||
func (o *compressionOption) applyToClient(config *clientConfig) {
|
||||
o.apply(&config.CompressionNames, config.CompressionPools)
|
||||
}
|
||||
|
||||
func (o *compressionOption) applyToHandler(config *handlerConfig) {
|
||||
o.apply(&config.CompressionNames, config.CompressionPools)
|
||||
}
|
||||
|
||||
func (o *compressionOption) apply(configuredNames *[]string, configuredPools map[string]*compressionPool) {
|
||||
if o.Name == "" {
|
||||
return
|
||||
}
|
||||
if o.CompressionPool == nil {
|
||||
delete(configuredPools, o.Name)
|
||||
var names []string
|
||||
for _, name := range *configuredNames {
|
||||
if name == o.Name {
|
||||
continue
|
||||
}
|
||||
names = append(names, name)
|
||||
}
|
||||
*configuredNames = names
|
||||
return
|
||||
}
|
||||
configuredPools[o.Name] = o.CompressionPool
|
||||
*configuredNames = append(*configuredNames, o.Name)
|
||||
}
|
||||
|
||||
type compressMinBytesOption struct {
|
||||
Min int
|
||||
}
|
||||
|
||||
func (o *compressMinBytesOption) applyToClient(config *clientConfig) {
|
||||
config.CompressMinBytes = o.Min
|
||||
}
|
||||
|
||||
func (o *compressMinBytesOption) applyToHandler(config *handlerConfig) {
|
||||
config.CompressMinBytes = o.Min
|
||||
}
|
||||
|
||||
type readMaxBytesOption struct {
|
||||
Max int
|
||||
}
|
||||
|
||||
func (o *readMaxBytesOption) applyToClient(config *clientConfig) {
|
||||
config.ReadMaxBytes = o.Max
|
||||
}
|
||||
|
||||
func (o *readMaxBytesOption) applyToHandler(config *handlerConfig) {
|
||||
config.ReadMaxBytes = o.Max
|
||||
}
|
||||
|
||||
type sendMaxBytesOption struct {
|
||||
Max int
|
||||
}
|
||||
|
||||
func (o *sendMaxBytesOption) applyToClient(config *clientConfig) {
|
||||
config.SendMaxBytes = o.Max
|
||||
}
|
||||
|
||||
func (o *sendMaxBytesOption) applyToHandler(config *handlerConfig) {
|
||||
config.SendMaxBytes = o.Max
|
||||
}
|
||||
|
||||
type handlerOptionsOption struct {
|
||||
options []HandlerOption
|
||||
}
|
||||
|
||||
func (o *handlerOptionsOption) applyToHandler(config *handlerConfig) {
|
||||
for _, option := range o.options {
|
||||
option.applyToHandler(config)
|
||||
}
|
||||
}
|
||||
|
||||
type requireConnectProtocolHeaderOption struct{}
|
||||
|
||||
func (o *requireConnectProtocolHeaderOption) applyToHandler(config *handlerConfig) {
|
||||
config.RequireConnectProtocolHeader = true
|
||||
}
|
||||
|
||||
type idempotencyOption struct {
|
||||
idempotencyLevel IdempotencyLevel
|
||||
}
|
||||
|
||||
func (o *idempotencyOption) applyToClient(config *clientConfig) {
|
||||
config.IdempotencyLevel = o.idempotencyLevel
|
||||
}
|
||||
|
||||
func (o *idempotencyOption) applyToHandler(config *handlerConfig) {
|
||||
config.IdempotencyLevel = o.idempotencyLevel
|
||||
}
|
||||
|
||||
type grpcOption struct {
|
||||
web bool
|
||||
}
|
||||
|
||||
func (o *grpcOption) applyToClient(config *clientConfig) {
|
||||
config.Protocol = &protocolGRPC{web: o.web}
|
||||
}
|
||||
|
||||
type enableGet struct{}
|
||||
|
||||
func (o *enableGet) applyToClient(config *clientConfig) {
|
||||
config.EnableGet = true
|
||||
}
|
||||
|
||||
// WithHTTPGetMaxURLSize sets the maximum allowable URL length for GET requests
|
||||
// made using the Connect protocol. It has no effect on gRPC or gRPC-Web
|
||||
// clients, since those protocols are POST-only.
|
||||
//
|
||||
// Limiting the URL size is useful as most user agents, proxies, and servers
|
||||
// have limits on the allowable length of a URL. For example, Apache and Nginx
|
||||
// limit the size of a request line to around 8 KiB, meaning that maximum
|
||||
// length of a URL is a bit smaller than this. If you run into URL size
|
||||
// limitations imposed by your network infrastructure and don't know the
|
||||
// maximum allowable size, or if you'd prefer to be cautious from the start, a
|
||||
// 4096 byte (4 KiB) limit works with most common proxies and CDNs.
|
||||
//
|
||||
// If fallback is set to true and the URL would be longer than the configured
|
||||
// maximum value, the request will be sent as an HTTP POST instead. If fallback
|
||||
// is set to false, the request will fail with [CodeResourceExhausted].
|
||||
//
|
||||
// By default, Connect-protocol clients with GET requests enabled may send a
|
||||
// URL of any size.
|
||||
func WithHTTPGetMaxURLSize(bytes int, fallback bool) ClientOption {
|
||||
return &getURLMaxBytes{Max: bytes, Fallback: fallback}
|
||||
}
|
||||
|
||||
type getURLMaxBytes struct {
|
||||
Max int
|
||||
Fallback bool
|
||||
}
|
||||
|
||||
func (o *getURLMaxBytes) applyToClient(config *clientConfig) {
|
||||
config.GetURLMaxBytes = o.Max
|
||||
config.GetUseFallback = o.Fallback
|
||||
}
|
||||
|
||||
type interceptorsOption struct {
|
||||
Interceptors []Interceptor
|
||||
}
|
||||
|
||||
func (o *interceptorsOption) applyToClient(config *clientConfig) {
|
||||
config.Interceptor = o.chainWith(config.Interceptor)
|
||||
}
|
||||
|
||||
func (o *interceptorsOption) applyToHandler(config *handlerConfig) {
|
||||
config.Interceptor = o.chainWith(config.Interceptor)
|
||||
}
|
||||
|
||||
func (o *interceptorsOption) chainWith(current Interceptor) Interceptor {
|
||||
if len(o.Interceptors) == 0 {
|
||||
return current
|
||||
}
|
||||
if current == nil && len(o.Interceptors) == 1 {
|
||||
return o.Interceptors[0]
|
||||
}
|
||||
if current == nil && len(o.Interceptors) > 1 {
|
||||
return newChain(o.Interceptors)
|
||||
}
|
||||
return newChain(append([]Interceptor{current}, o.Interceptors...))
|
||||
}
|
||||
|
||||
type optionsOption struct {
|
||||
options []Option
|
||||
}
|
||||
|
||||
func (o *optionsOption) applyToClient(config *clientConfig) {
|
||||
for _, option := range o.options {
|
||||
option.applyToClient(config)
|
||||
}
|
||||
}
|
||||
|
||||
func (o *optionsOption) applyToHandler(config *handlerConfig) {
|
||||
for _, option := range o.options {
|
||||
option.applyToHandler(config)
|
||||
}
|
||||
}
|
||||
|
||||
type sendCompressionOption struct {
|
||||
Name string
|
||||
}
|
||||
|
||||
func (o *sendCompressionOption) applyToClient(config *clientConfig) {
|
||||
config.RequestCompressionName = o.Name
|
||||
}
|
||||
|
||||
func withGzip() Option {
|
||||
return &compressionOption{
|
||||
Name: compressionGzip,
|
||||
CompressionPool: newCompressionPool(
|
||||
func() Decompressor { return &gzip.Reader{} },
|
||||
func() Compressor { return gzip.NewWriter(io.Discard) },
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
func withProtoBinaryCodec() Option {
|
||||
return WithCodec(&protoBinaryCodec{})
|
||||
}
|
||||
|
||||
func withProtoJSONCodecs() HandlerOption {
|
||||
return WithHandlerOptions(
|
||||
WithCodec(&protoJSONCodec{codecNameJSON}),
|
||||
WithCodec(&protoJSONCodec{codecNameJSONCharsetUTF8}),
|
||||
)
|
||||
}
|
||||
|
||||
type conditionalHandlerOptions struct {
|
||||
conditional func(spec Spec) []HandlerOption
|
||||
}
|
||||
|
||||
func (o *conditionalHandlerOptions) applyToHandler(config *handlerConfig) {
|
||||
spec := config.newSpec()
|
||||
if spec.Procedure == "" {
|
||||
return // ignore empty specs
|
||||
}
|
||||
for _, option := range o.conditional(spec) {
|
||||
option.applyToHandler(config)
|
||||
}
|
||||
}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
// Copyright 2021-2025 The Connect Authors
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package connect
|
||||
|
||||
import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// extractProtoPath returns the trailing portion of the URL's path,
|
||||
// corresponding to the Protobuf package, service, and method. It always starts
|
||||
// with a slash. Within connect, we use this as (1) Spec.Procedure and (2) the
|
||||
// path when mounting handlers on muxes.
|
||||
func extractProtoPath(path string) string {
|
||||
segments := strings.Split(path, "/")
|
||||
var pkg, method string
|
||||
if len(segments) > 0 {
|
||||
pkg = segments[0]
|
||||
}
|
||||
if len(segments) > 1 {
|
||||
pkg = segments[len(segments)-2]
|
||||
method = segments[len(segments)-1]
|
||||
}
|
||||
if pkg == "" {
|
||||
return "/"
|
||||
}
|
||||
if method == "" {
|
||||
return "/" + pkg
|
||||
}
|
||||
return "/" + pkg + "/" + method
|
||||
}
|
||||
+424
@@ -0,0 +1,424 @@
|
||||
// Copyright 2021-2025 The Connect Authors
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package connect
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// The names of the Connect, gRPC, and gRPC-Web protocols (as exposed by
|
||||
// [Peer].Protocol). Additional protocols may be added in the future.
|
||||
const (
|
||||
ProtocolConnect = "connect"
|
||||
ProtocolGRPC = "grpc"
|
||||
ProtocolGRPCWeb = "grpcweb"
|
||||
)
|
||||
|
||||
const (
|
||||
headerContentType = "Content-Type"
|
||||
headerContentEncoding = "Content-Encoding"
|
||||
headerContentLength = "Content-Length"
|
||||
headerHost = "Host"
|
||||
headerUserAgent = "User-Agent"
|
||||
headerTrailer = "Trailer"
|
||||
headerDate = "Date"
|
||||
|
||||
discardLimit = 1024 * 1024 * 4 // 4MiB
|
||||
)
|
||||
|
||||
var errNoTimeout = errors.New("no timeout")
|
||||
|
||||
// A Protocol defines the HTTP semantics to use when sending and receiving
|
||||
// messages. It ties together codecs, compressors, and net/http to produce
|
||||
// Senders and Receivers.
|
||||
//
|
||||
// For example, connect supports the gRPC protocol using this abstraction. Among
|
||||
// many other things, the protocol implementation is responsible for
|
||||
// translating timeouts from Go contexts to HTTP and vice versa. For gRPC, it
|
||||
// converts timeouts to and from strings (for example, 10*time.Second <->
|
||||
// "10S"), and puts those strings into the "Grpc-Timeout" HTTP header. Other
|
||||
// protocols might encode durations differently, put them into a different HTTP
|
||||
// header, or ignore them entirely.
|
||||
//
|
||||
// We don't have any short-term plans to export this interface; it's just here
|
||||
// to separate the protocol-specific portions of connect from the
|
||||
// protocol-agnostic plumbing.
|
||||
type protocol interface {
|
||||
NewHandler(*protocolHandlerParams) protocolHandler
|
||||
NewClient(*protocolClientParams) (protocolClient, error)
|
||||
}
|
||||
|
||||
// HandlerParams are the arguments provided to a Protocol's NewHandler
|
||||
// method, bundled into a struct to allow backward-compatible argument
|
||||
// additions. Protocol implementations should take care to use the supplied
|
||||
// Spec rather than constructing their own, since new fields may have been
|
||||
// added.
|
||||
type protocolHandlerParams struct {
|
||||
Spec Spec
|
||||
Codecs readOnlyCodecs
|
||||
CompressionPools readOnlyCompressionPools
|
||||
CompressMinBytes int
|
||||
BufferPool *bufferPool
|
||||
ReadMaxBytes int
|
||||
SendMaxBytes int
|
||||
RequireConnectProtocolHeader bool
|
||||
IdempotencyLevel IdempotencyLevel
|
||||
}
|
||||
|
||||
// Handler is the server side of a protocol. HTTP handlers typically support
|
||||
// multiple protocols, codecs, and compressors.
|
||||
type protocolHandler interface {
|
||||
// Methods is the set of HTTP methods the protocol can handle.
|
||||
Methods() map[string]struct{}
|
||||
|
||||
// ContentTypes is the set of HTTP Content-Types that the protocol can
|
||||
// handle.
|
||||
ContentTypes() map[string]struct{}
|
||||
|
||||
// SetTimeout runs before NewStream. Implementations may inspect the HTTP
|
||||
// request, parse any timeout set by the client, and return a modified
|
||||
// context and cancellation function.
|
||||
//
|
||||
// If the client didn't send a timeout, SetTimeout should return the
|
||||
// request's context, a nil cancellation function, and a nil error.
|
||||
SetTimeout(*http.Request) (context.Context, context.CancelFunc, error)
|
||||
|
||||
// CanHandlePayload returns true if the protocol can handle an HTTP request.
|
||||
// This is called after the request method is validated, so we only need to
|
||||
// be concerned with the content type/payload specifically.
|
||||
CanHandlePayload(*http.Request, string) bool
|
||||
|
||||
// NewConn constructs a HandlerConn for the message exchange.
|
||||
NewConn(http.ResponseWriter, *http.Request) (handlerConnCloser, bool)
|
||||
}
|
||||
|
||||
// ClientParams are the arguments provided to a Protocol's NewClient method,
|
||||
// bundled into a struct to allow backward-compatible argument additions.
|
||||
// Protocol implementations should take care to use the supplied Spec rather
|
||||
// than constructing their own, since new fields may have been added.
|
||||
type protocolClientParams struct {
|
||||
CompressionName string
|
||||
CompressionPools readOnlyCompressionPools
|
||||
Codec Codec
|
||||
CompressMinBytes int
|
||||
HTTPClient HTTPClient
|
||||
URL *url.URL
|
||||
BufferPool *bufferPool
|
||||
ReadMaxBytes int
|
||||
SendMaxBytes int
|
||||
EnableGet bool
|
||||
GetURLMaxBytes int
|
||||
GetUseFallback bool
|
||||
// The gRPC family of protocols always needs access to a Protobuf codec to
|
||||
// marshal and unmarshal errors.
|
||||
Protobuf Codec
|
||||
}
|
||||
|
||||
// Client is the client side of a protocol. HTTP clients typically use a single
|
||||
// protocol, codec, and compressor to send requests.
|
||||
type protocolClient interface {
|
||||
// Peer describes the server for the RPC.
|
||||
Peer() Peer
|
||||
|
||||
// WriteRequestHeader writes any protocol-specific request headers.
|
||||
WriteRequestHeader(StreamType, http.Header)
|
||||
|
||||
// NewConn constructs a StreamingClientConn for the message exchange.
|
||||
//
|
||||
// Implementations should assume that the supplied HTTP headers have already
|
||||
// been populated by WriteRequestHeader. When constructing a stream for a
|
||||
// unary call, implementations may assume that the Sender's Send and Close
|
||||
// methods return before the Receiver's Receive or Close methods are called.
|
||||
NewConn(context.Context, Spec, http.Header) streamingClientConn
|
||||
}
|
||||
|
||||
// streamingClientConn extends StreamingClientConn with a method for registering
|
||||
// a hook when the HTTP request is actually sent.
|
||||
type streamingClientConn interface {
|
||||
StreamingClientConn
|
||||
|
||||
onRequestSend(fn func(*http.Request))
|
||||
}
|
||||
|
||||
// errorTranslatingHandlerConnCloser wraps a handlerConnCloser to ensure that
|
||||
// we always return coded errors to users and write coded errors to the
|
||||
// network.
|
||||
//
|
||||
// It's used in protocol implementations.
|
||||
type errorTranslatingHandlerConnCloser struct {
|
||||
handlerConnCloser
|
||||
|
||||
toWire func(error) error
|
||||
fromWire func(error) error
|
||||
}
|
||||
|
||||
func (hc *errorTranslatingHandlerConnCloser) Send(msg any) error {
|
||||
return hc.fromWire(hc.handlerConnCloser.Send(msg))
|
||||
}
|
||||
|
||||
func (hc *errorTranslatingHandlerConnCloser) Receive(msg any) error {
|
||||
return hc.fromWire(hc.handlerConnCloser.Receive(msg))
|
||||
}
|
||||
|
||||
func (hc *errorTranslatingHandlerConnCloser) Close(err error) error {
|
||||
closeErr := hc.handlerConnCloser.Close(hc.toWire(err))
|
||||
return hc.fromWire(closeErr)
|
||||
}
|
||||
|
||||
func (hc *errorTranslatingHandlerConnCloser) getHTTPMethod() string {
|
||||
if methoder, ok := hc.handlerConnCloser.(interface{ getHTTPMethod() string }); ok {
|
||||
return methoder.getHTTPMethod()
|
||||
}
|
||||
return http.MethodPost
|
||||
}
|
||||
|
||||
// errorTranslatingClientConn wraps a StreamingClientConn to make sure that we always
|
||||
// return coded errors from clients.
|
||||
//
|
||||
// It's used in protocol implementations.
|
||||
type errorTranslatingClientConn struct {
|
||||
streamingClientConn
|
||||
|
||||
fromWire func(error) error
|
||||
}
|
||||
|
||||
func (cc *errorTranslatingClientConn) Send(msg any) error {
|
||||
return cc.fromWire(cc.streamingClientConn.Send(msg))
|
||||
}
|
||||
|
||||
func (cc *errorTranslatingClientConn) Receive(msg any) error {
|
||||
return cc.fromWire(cc.streamingClientConn.Receive(msg))
|
||||
}
|
||||
|
||||
func (cc *errorTranslatingClientConn) CloseRequest() error {
|
||||
return cc.fromWire(cc.streamingClientConn.CloseRequest())
|
||||
}
|
||||
|
||||
func (cc *errorTranslatingClientConn) CloseResponse() error {
|
||||
return cc.fromWire(cc.streamingClientConn.CloseResponse())
|
||||
}
|
||||
|
||||
func (cc *errorTranslatingClientConn) onRequestSend(fn func(*http.Request)) {
|
||||
cc.streamingClientConn.onRequestSend(fn)
|
||||
}
|
||||
|
||||
// wrapHandlerConnWithCodedErrors ensures that we (1) automatically code
|
||||
// context-related errors correctly when writing them to the network, and (2)
|
||||
// return *Errors from all exported APIs.
|
||||
func wrapHandlerConnWithCodedErrors(conn handlerConnCloser) handlerConnCloser {
|
||||
return &errorTranslatingHandlerConnCloser{
|
||||
handlerConnCloser: conn,
|
||||
toWire: wrapIfContextError,
|
||||
fromWire: wrapIfUncoded,
|
||||
}
|
||||
}
|
||||
|
||||
// wrapClientConnWithCodedErrors ensures that we always return *Errors from
|
||||
// public APIs.
|
||||
func wrapClientConnWithCodedErrors(conn streamingClientConn) streamingClientConn {
|
||||
return &errorTranslatingClientConn{
|
||||
streamingClientConn: conn,
|
||||
fromWire: wrapIfUncoded,
|
||||
}
|
||||
}
|
||||
|
||||
func mappedMethodHandlers(handlers []protocolHandler) map[string][]protocolHandler {
|
||||
methodHandlers := make(map[string][]protocolHandler)
|
||||
for _, handler := range handlers {
|
||||
for method := range handler.Methods() {
|
||||
methodHandlers[method] = append(methodHandlers[method], handler)
|
||||
}
|
||||
}
|
||||
return methodHandlers
|
||||
}
|
||||
|
||||
func sortedAcceptPostValue(handlers []protocolHandler) string {
|
||||
contentTypes := make(map[string]struct{})
|
||||
for _, handler := range handlers {
|
||||
for contentType := range handler.ContentTypes() {
|
||||
contentTypes[contentType] = struct{}{}
|
||||
}
|
||||
}
|
||||
accept := make([]string, 0, len(contentTypes))
|
||||
for ct := range contentTypes {
|
||||
accept = append(accept, ct)
|
||||
}
|
||||
sort.Strings(accept)
|
||||
return strings.Join(accept, ", ")
|
||||
}
|
||||
|
||||
func sortedAllowMethodValue(handlers []protocolHandler) string {
|
||||
methods := make(map[string]struct{})
|
||||
for _, handler := range handlers {
|
||||
for method := range handler.Methods() {
|
||||
methods[method] = struct{}{}
|
||||
}
|
||||
}
|
||||
allow := make([]string, 0, len(methods))
|
||||
for ct := range methods {
|
||||
allow = append(allow, ct)
|
||||
}
|
||||
sort.Strings(allow)
|
||||
return strings.Join(allow, ", ")
|
||||
}
|
||||
|
||||
func isCommaOrSpace(c rune) bool {
|
||||
return c == ',' || c == ' '
|
||||
}
|
||||
|
||||
func discard(reader io.Reader) (int64, error) {
|
||||
if lr, ok := reader.(*io.LimitedReader); ok {
|
||||
return io.Copy(io.Discard, lr)
|
||||
}
|
||||
// We don't want to get stuck throwing data away forever, so limit how much
|
||||
// we're willing to do here.
|
||||
lr := &io.LimitedReader{R: reader, N: discardLimit}
|
||||
return io.Copy(io.Discard, lr)
|
||||
}
|
||||
|
||||
// negotiateCompression determines and validates the request compression and
|
||||
// response compression using the available compressors and protocol-specific
|
||||
// Content-Encoding and Accept-Encoding headers.
|
||||
func negotiateCompression( //nolint:nonamedreturns
|
||||
availableCompressors readOnlyCompressionPools,
|
||||
sent, accept string,
|
||||
) (requestCompression, responseCompression string, clientVisibleErr *Error) {
|
||||
requestCompression = compressionIdentity
|
||||
if sent != "" && sent != compressionIdentity {
|
||||
// We default to identity, so we only care if the client sends something
|
||||
// other than the empty string or compressIdentity.
|
||||
if availableCompressors.Contains(sent) {
|
||||
requestCompression = sent
|
||||
} else {
|
||||
// To comply with
|
||||
// https://github.com/grpc/grpc/blob/master/doc/compression.md and the
|
||||
// Connect protocol, we should return CodeUnimplemented and specify
|
||||
// acceptable compression(s) (in addition to setting the a
|
||||
// protocol-specific accept-encoding header).
|
||||
return "", "", errorf(
|
||||
CodeUnimplemented,
|
||||
"unknown compression %q: supported encodings are %v",
|
||||
sent, availableCompressors.CommaSeparatedNames(),
|
||||
)
|
||||
}
|
||||
}
|
||||
// Support asymmetric compression. This logic follows
|
||||
// https://github.com/grpc/grpc/blob/master/doc/compression.md and common
|
||||
// sense.
|
||||
responseCompression = requestCompression
|
||||
// If we're not already planning to compress the response, check whether the
|
||||
// client requested a compression algorithm we support.
|
||||
if responseCompression == compressionIdentity && accept != "" {
|
||||
for _, name := range strings.FieldsFunc(accept, isCommaOrSpace) {
|
||||
if availableCompressors.Contains(name) {
|
||||
// We found a mutually supported compression algorithm. Unlike standard
|
||||
// HTTP, there's no preference weighting, so can bail out immediately.
|
||||
responseCompression = name
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return requestCompression, responseCompression, nil
|
||||
}
|
||||
|
||||
// checkServerStreamsCanFlush ensures that bidi and server streaming handlers
|
||||
// have received an http.ResponseWriter that implements http.Flusher, since
|
||||
// they must flush data after sending each message.
|
||||
func checkServerStreamsCanFlush(spec Spec, responseWriter http.ResponseWriter) *Error {
|
||||
requiresFlusher := (spec.StreamType & StreamTypeServer) == StreamTypeServer
|
||||
if _, flushable := responseWriter.(http.Flusher); requiresFlusher && !flushable {
|
||||
return NewError(CodeInternal, fmt.Errorf("%T does not implement http.Flusher", responseWriter))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func flushResponseWriter(w http.ResponseWriter) {
|
||||
if f, ok := w.(http.Flusher); ok {
|
||||
f.Flush()
|
||||
}
|
||||
}
|
||||
|
||||
func canonicalizeContentType(contentType string) string {
|
||||
// Typically, clients send Content-Type in canonical form, without
|
||||
// parameters. In those cases, we'd like to avoid parsing and
|
||||
// canonicalization overhead.
|
||||
//
|
||||
// See https://www.rfc-editor.org/rfc/rfc2045.html#section-5.1 for a full
|
||||
// grammar.
|
||||
var slashes int
|
||||
for _, r := range contentType {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z':
|
||||
case r == '.' || r == '+' || r == '-':
|
||||
case r == '/':
|
||||
slashes++
|
||||
default:
|
||||
return canonicalizeContentTypeSlow(contentType)
|
||||
}
|
||||
}
|
||||
if slashes == 1 {
|
||||
return contentType
|
||||
}
|
||||
return canonicalizeContentTypeSlow(contentType)
|
||||
}
|
||||
|
||||
func canonicalizeContentTypeSlow(contentType string) string {
|
||||
base, params, err := mime.ParseMediaType(contentType)
|
||||
if err != nil {
|
||||
return contentType
|
||||
}
|
||||
// According to RFC 9110 Section 8.3.2, the charset parameter value should be treated as case-insensitive.
|
||||
// mime.FormatMediaType canonicalizes parameter names, but not parameter values,
|
||||
// because the case sensitivity of a parameter value depends on its semantics.
|
||||
// Therefore, the charset parameter value should be canonicalized here.
|
||||
// ref.) https://httpwg.org/specs/rfc9110.html#rfc.section.8.3.2
|
||||
if charset, ok := params["charset"]; ok {
|
||||
params["charset"] = strings.ToLower(charset)
|
||||
}
|
||||
return mime.FormatMediaType(base, params)
|
||||
}
|
||||
|
||||
func httpToCode(httpCode int) Code {
|
||||
// https://github.com/grpc/grpc/blob/master/doc/http-grpc-status-mapping.md
|
||||
// Note that this is NOT the inverse of the gRPC-to-HTTP or Connect-to-HTTP
|
||||
// mappings.
|
||||
|
||||
// Literals are easier to compare to the specification (vs named
|
||||
// constants).
|
||||
switch httpCode {
|
||||
case 400:
|
||||
return CodeInternal
|
||||
case 401:
|
||||
return CodeUnauthenticated
|
||||
case 403:
|
||||
return CodePermissionDenied
|
||||
case 404:
|
||||
return CodeUnimplemented
|
||||
case 429:
|
||||
return CodeUnavailable
|
||||
case 502, 503, 504:
|
||||
return CodeUnavailable
|
||||
default:
|
||||
return CodeUnknown
|
||||
}
|
||||
}
|
||||
+1449
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user