├── .github └── workflows │ ├── release.yml │ └── workflow.yml ├── .gitignore ├── Dockerfile ├── LICENSE ├── docker-smoketests ├── go.mod ├── go.sum ├── smoketests.go └── smoketests.sh ├── emulator.go ├── emulator_from_env.sh ├── emulator_test.go ├── go.mod ├── go.sum ├── oidc.cert ├── oidc.go ├── oidc.key ├── oidc_internal_test.go ├── protohelpers.go ├── queue.go ├── readme-owncert.md ├── readme.MD ├── task.go └── task_internal_test.go /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | release: 5 | types: [created] 6 | 7 | jobs: 8 | releases-matrix: 9 | name: Release Go Binary 10 | runs-on: ubuntu-latest 11 | strategy: 12 | matrix: 13 | # build and publish in parallel: linux/386, linux/amd64, linux/arm64, windows/386, windows/amd64, darwin/amd64, darwin/arm64 14 | goos: [linux, windows, darwin] 15 | goarch: ["386", amd64, arm64] 16 | exclude: 17 | - goarch: "386" 18 | goos: darwin 19 | - goarch: arm64 20 | goos: windows 21 | steps: 22 | - uses: actions/checkout@v3 23 | - uses: wangyoucao577/go-release-action@v1.28 24 | with: 25 | github_token: ${{ secrets.GITHUB_TOKEN }} 26 | goos: ${{ matrix.goos }} 27 | goarch: ${{ matrix.goarch }} 28 | -------------------------------------------------------------------------------- /.github/workflows/workflow.yml: -------------------------------------------------------------------------------- 1 | name: CI/CD 2 | on: 3 | push: 4 | pull_request: 5 | env: 6 | IMAGE_NAME: cloud-tasks-emulator 7 | jobs: 8 | test: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Set up Go 1.x 12 | uses: actions/setup-go@v2 13 | with: 14 | go-version: ^1.13 15 | id: go 16 | - name: Check out code into the Go module directory 17 | uses: actions/checkout@v2 18 | - name: Cache dependencies 19 | uses: actions/cache@v2 20 | with: 21 | path: ~/go/pkg/mod 22 | key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} 23 | restore-keys: | 24 | ${{ runner.os }}-go- 25 | - name: Get dependencies 26 | run: | 27 | go get -v -t -d ./... 28 | if [ -f Gopkg.toml ]; then 29 | curl https://raw.githubusercontent.com/golang/dep/master/install.sh | sh 30 | dep ensure 31 | fi 32 | - name: Build 33 | run: go build -v . 34 | - name: Test 35 | run: go test -v . 36 | docker-smoke-test: 37 | runs-on: ubuntu-latest 38 | steps: 39 | - name: Checkout 40 | uses: actions/checkout@v2 41 | - name: Build image 42 | run: docker build . --file Dockerfile --tag $IMAGE_NAME 43 | - name: Cache smoketest dependencies 44 | uses: actions/cache@v2 45 | with: 46 | path: /tmp/smoketest-packages 47 | key: smoketests-pkgs-${{ hashFiles('docker-smoketests/go.*') }} 48 | restore-keys: | 49 | smoketests-pkgs 50 | - name: Run container smoketests 51 | run: EMULATOR_DOCKER_IMAGE=$IMAGE_NAME docker-smoketests/smoketests.sh 52 | docker-publish: 53 | runs-on: ubuntu-latest 54 | needs: [test, docker-smoke-test] 55 | if: ${{ github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') }} 56 | steps: 57 | - name: Checkout 58 | uses: actions/checkout@v2 59 | - name: Set up Docker Buildx 60 | uses: docker/setup-buildx-action@v2 61 | - name: Publish to Github Packages 62 | run: | 63 | set -o errexit 64 | set -o nounset 65 | 66 | # Login to Github registry 67 | # Needs a PAT with `read:packages` and `write:packages` scopes, be *very* careful not to grant `repo` scope 68 | # or anyone with push access on this repo can hijack your GH account! 69 | echo "${{ secrets.GH_CR_IMG_PUSH_PAT }}" | docker login https://ghcr.io -u ${{ github.actor }} --password-stdin 70 | IMAGE_ID=ghcr.io/${{ github.repository_owner }}/$IMAGE_NAME 71 | 72 | # Change all uppercase to lowercase 73 | IMAGE_ID=$(echo $IMAGE_ID | tr '[A-Z]' '[a-z]') 74 | 75 | # Strip git ref prefix from version 76 | VERSION=$(echo "${{ github.ref }}" | sed -e 's,.*/\(.*\),\1,') 77 | 78 | # Strip "v" prefix from tag name 79 | [[ "${{ github.ref }}" == "refs/tags/"* ]] && VERSION=$(echo $VERSION | sed -e 's/^v//') 80 | 81 | # Push to the remote repo 82 | # This assumes a higher version will always be tagged later 83 | echo "Publishing $IMAGE_ID:$VERSION" 84 | docker buildx build . --platform linux/amd64,linux/arm64 --tag $IMAGE_ID:$VERSION --tag $IMAGE_ID:latest --push 85 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode 2 | __debug_bin 3 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.13-alpine as builder 2 | 3 | WORKDIR /app 4 | 5 | COPY go.mod go.sum ./ 6 | 7 | RUN go mod download 8 | 9 | COPY . . 10 | RUN go build -o emulator . 11 | 12 | FROM alpine:latest 13 | 14 | LABEL org.opencontainers.image.source=https://github.com/aertje/cloud-tasks-emulator 15 | 16 | WORKDIR / 17 | 18 | COPY --from=builder /app/oidc.key oidc.key 19 | COPY --from=builder /app/oidc.cert oidc.cert 20 | COPY --from=builder /app/emulator . 21 | COPY --from=builder /app/emulator_from_env.sh . 22 | RUN chmod +x emulator_from_env.sh 23 | 24 | ENTRYPOINT ["./emulator"] 25 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2020 Aert van de Hulsbeek 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. -------------------------------------------------------------------------------- /docker-smoketests/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/aertje/cloud-tasks-emulator/smoketests 2 | 3 | go 1.13 4 | 5 | require ( 6 | cloud.google.com/go v0.72.0 7 | github.com/golang-jwt/jwt v3.2.1+incompatible 8 | github.com/lestrrat-go/jwx v1.0.5 9 | google.golang.org/api v0.35.0 10 | google.golang.org/genproto v0.0.0-20201119123407-9b1e624d6bc4 11 | google.golang.org/grpc v1.33.2 12 | ) 13 | -------------------------------------------------------------------------------- /docker-smoketests/go.sum: -------------------------------------------------------------------------------- 1 | cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= 2 | cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= 3 | cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU= 4 | cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU= 5 | cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY= 6 | cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc= 7 | cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0= 8 | cloud.google.com/go v0.50.0/go.mod h1:r9sluTvynVuxRIOHXQEHMFffphuXHOMZMycpNR5e6To= 9 | cloud.google.com/go v0.52.0/go.mod h1:pXajvRH/6o3+F9jDHZWQ5PbGhn+o8w9qiu/CffaVdO4= 10 | cloud.google.com/go v0.53.0/go.mod h1:fp/UouUEsRkN6ryDKNW/Upv/JBKnv6WDthjR6+vze6M= 11 | cloud.google.com/go v0.54.0/go.mod h1:1rq2OEkV3YMf6n/9ZvGWI3GWw0VoqH/1x2nd8Is/bPc= 12 | cloud.google.com/go v0.56.0/go.mod h1:jr7tqZxxKOVYizybht9+26Z/gUq7tiRzu+ACVAMbKVk= 13 | cloud.google.com/go v0.57.0/go.mod h1:oXiQ6Rzq3RAkkY7N6t3TcE6jE+CIBBbA36lwQ1JyzZs= 14 | cloud.google.com/go v0.62.0/go.mod h1:jmCYTdRCQuc1PHIIJ/maLInMho30T/Y0M4hTdTShOYc= 15 | cloud.google.com/go v0.65.0/go.mod h1:O5N8zS7uWy9vkA9vayVHs65eM1ubvY4h553ofrNHObY= 16 | cloud.google.com/go v0.72.0 h1:eWRCuwubtDrCJG0oSUMgnsbD4CmPFQF2ei4OFbXvwww= 17 | cloud.google.com/go v0.72.0/go.mod h1:M+5Vjvlc2wnp6tjzE102Dw08nGShTscUx2nZMufOKPI= 18 | cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o= 19 | cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE= 20 | cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc= 21 | cloud.google.com/go/bigquery v1.5.0/go.mod h1:snEHRnqQbz117VIFhE8bmtwIDY80NLUZUMb4Nv6dBIg= 22 | cloud.google.com/go/bigquery v1.7.0/go.mod h1://okPTzCYNXSlb24MZs83e2Do+h+VXtc4gLoIoXIAPc= 23 | cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ= 24 | cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE= 25 | cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk= 26 | cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I= 27 | cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw= 28 | cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA= 29 | cloud.google.com/go/pubsub v1.3.1/go.mod h1:i+ucay31+CNRpDW4Lu78I4xXG+O1r/MAHgjpRVR+TSU= 30 | cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw= 31 | cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos= 32 | cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk= 33 | cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs= 34 | cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0= 35 | dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= 36 | github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= 37 | github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= 38 | github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= 39 | github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= 40 | github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= 41 | github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= 42 | github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= 43 | github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= 44 | github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= 45 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 46 | github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= 47 | github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= 48 | github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= 49 | github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= 50 | github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= 51 | github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= 52 | github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= 53 | github.com/golang-jwt/jwt v3.2.1+incompatible h1:73Z+4BJcrTC+KczS6WvTPvRGOp1WmfEP4Q1lOd9Z/+c= 54 | github.com/golang-jwt/jwt v3.2.1+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= 55 | github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= 56 | github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= 57 | github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= 58 | github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e h1:1r7pUrabqp18hOBcwBwiTsbnFeTZHV9eER/QT5JVZxY= 59 | github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= 60 | github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= 61 | github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= 62 | github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y= 63 | github.com/golang/mock v1.4.0/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= 64 | github.com/golang/mock v1.4.1/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= 65 | github.com/golang/mock v1.4.3/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= 66 | github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4= 67 | github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 68 | github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 69 | github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 70 | github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= 71 | github.com/golang/protobuf v1.3.4/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= 72 | github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk= 73 | github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= 74 | github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= 75 | github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= 76 | github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= 77 | github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= 78 | github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= 79 | github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= 80 | github.com/golang/protobuf v1.4.3 h1:JjCZWpVbqXDqFVmTfYWEVTMIYrL/NPdPSCHPJ0T/raM= 81 | github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= 82 | github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= 83 | github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= 84 | github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= 85 | github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= 86 | github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= 87 | github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 88 | github.com/google/go-cmp v0.4.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 89 | github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 90 | github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 91 | github.com/google/go-cmp v0.5.2 h1:X2ev0eStA3AbceY54o37/0PQ/UWqKEiiO2dKL5OPaFM= 92 | github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 93 | github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= 94 | github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= 95 | github.com/google/martian/v3 v3.1.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= 96 | github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= 97 | github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= 98 | github.com/google/pprof v0.0.0-20191218002539-d4f498aebedc/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= 99 | github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= 100 | github.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= 101 | github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= 102 | github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= 103 | github.com/google/pprof v0.0.0-20201023163331-3e6fc7fc9c4c/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= 104 | github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= 105 | github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 106 | github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= 107 | github.com/googleapis/gax-go/v2 v2.0.5 h1:sjZBwGj9Jlw33ImPtvFviGYvseOtDM7hkSKB7+Tv3SM= 108 | github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= 109 | github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= 110 | github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= 111 | github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= 112 | github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= 113 | github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= 114 | github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk= 115 | github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= 116 | github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= 117 | github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= 118 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 119 | github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= 120 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 121 | github.com/lestrrat-go/iter v0.0.0-20200422075355-fc1769541911 h1:FvnrqecqX4zT0wOIbYK1gNgTm0677INEWiFY8UEYggY= 122 | github.com/lestrrat-go/iter v0.0.0-20200422075355-fc1769541911/go.mod h1:zIdgO1mRKhn8l9vrZJZz9TUMMFbQbLeTsbqPDrJ/OJc= 123 | github.com/lestrrat-go/jwx v1.0.5 h1:8bVUGXXkR3+YQNwuFof3lLxSJMLtrscHJfGI6ZIBRD0= 124 | github.com/lestrrat-go/jwx v1.0.5/go.mod h1:TPF17WiSFegZo+c20fdpw49QD+/7n4/IsGvEmCSWwT0= 125 | github.com/lestrrat-go/pdebug v0.0.0-20200204225717-4d6bd78da58d/go.mod h1:B06CSso/AWxiPejj+fheUINGeBKeeEZNt8w+EoU7+L8= 126 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 127 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 128 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 129 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 130 | github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= 131 | github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= 132 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 133 | github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= 134 | github.com/stretchr/testify v1.5.1 h1:nOGnQDM7FYENwehXlg/kFVnos3rEvtKTjRvOWSzb6H4= 135 | github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= 136 | github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 137 | github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 138 | github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 139 | github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 140 | go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU= 141 | go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8= 142 | go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= 143 | go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= 144 | go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= 145 | go.opencensus.io v0.22.5 h1:dntmOdLpSpHlVqbW5Eay97DelsZHe+55D+xC6i0dDS0= 146 | go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk= 147 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 148 | golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 149 | golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 150 | golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 151 | golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 152 | golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= 153 | golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= 154 | golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= 155 | golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek= 156 | golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY= 157 | golang.org/x/exp v0.0.0-20191129062945-2f5052295587/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= 158 | golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= 159 | golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= 160 | golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM= 161 | golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU= 162 | golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= 163 | golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= 164 | golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= 165 | golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= 166 | golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= 167 | golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= 168 | golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= 169 | golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= 170 | golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= 171 | golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRuDixDT3tpyyb+LUpUlRWLxfhWrs= 172 | golang.org/x/lint v0.0.0-20200130185559-910be7a94367/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= 173 | golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= 174 | golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE= 175 | golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o= 176 | golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= 177 | golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY= 178 | golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= 179 | golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= 180 | golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 181 | golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 182 | golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 183 | golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 184 | golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 185 | golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 186 | golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 187 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 188 | golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 189 | golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 190 | golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= 191 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 192 | golang.org/x/net v0.0.0-20190628185345-da137c7871d7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 193 | golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 194 | golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 195 | golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 196 | golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 197 | golang.org/x/net v0.0.0-20200222125558-5a598a2470a0/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 198 | golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 199 | golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 200 | golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= 201 | golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= 202 | golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= 203 | golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= 204 | golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= 205 | golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= 206 | golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= 207 | golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= 208 | golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= 209 | golang.org/x/net v0.0.0-20201031054903-ff519b6c9102 h1:42cLlJJdEh+ySyeUUbEQ5bsTiq8voBeTuweGVkY6Puw= 210 | golang.org/x/net v0.0.0-20201031054903-ff519b6c9102/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= 211 | golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= 212 | golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= 213 | golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= 214 | golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= 215 | golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= 216 | golang.org/x/oauth2 v0.0.0-20200902213428-5d25da1a8d43 h1:ld7aEMNHoBnnDAX15v1T6z31v8HwR2A9FYOuAhWqkwc= 217 | golang.org/x/oauth2 v0.0.0-20200902213428-5d25da1a8d43/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= 218 | golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 219 | golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 220 | golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 221 | golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 222 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 223 | golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 224 | golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 225 | golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 226 | golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 227 | golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 228 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 229 | golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 230 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 231 | golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 232 | golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 233 | golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 234 | golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 235 | golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 236 | golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 237 | golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 238 | golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 239 | golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 240 | golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 241 | golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 242 | golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 243 | golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 244 | golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 245 | golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 246 | golang.org/x/sys v0.0.0-20200331124033-c3d80250170d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 247 | golang.org/x/sys v0.0.0-20200501052902-10377860bb8e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 248 | golang.org/x/sys v0.0.0-20200511232937-7e40ca221e25/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 249 | golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 250 | golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 251 | golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 252 | golang.org/x/sys v0.0.0-20200905004654-be1d3432aa8f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 253 | golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f h1:+Nyd8tzPX9R7BWHguqsrbFdRx3WQ/1ib8I44HXV5yTA= 254 | golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 255 | golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 256 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 257 | golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 258 | golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= 259 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 260 | golang.org/x/text v0.3.4 h1:0YWbFKbhXG/wIiuHDSKpS0Iy7FSA+u45VtBMfQcFTTc= 261 | golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 262 | golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= 263 | golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= 264 | golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= 265 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 266 | golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 267 | golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= 268 | golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= 269 | golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= 270 | golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= 271 | golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= 272 | golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= 273 | golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= 274 | golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= 275 | golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= 276 | golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= 277 | golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 278 | golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 279 | golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 280 | golang.org/x/tools v0.0.0-20191113191852-77e3bb0ad9e7/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 281 | golang.org/x/tools v0.0.0-20191115202509-3a792d9c32b2/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 282 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 283 | golang.org/x/tools v0.0.0-20191125144606-a911d9008d1f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 284 | golang.org/x/tools v0.0.0-20191130070609-6e064ea0cf2d/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 285 | golang.org/x/tools v0.0.0-20191216173652-a0e659d51361/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= 286 | golang.org/x/tools v0.0.0-20191227053925-7b8e75db28f4/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= 287 | golang.org/x/tools v0.0.0-20200117161641-43d50277825c/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= 288 | golang.org/x/tools v0.0.0-20200122220014-bf1340f18c4a/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= 289 | golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= 290 | golang.org/x/tools v0.0.0-20200204074204-1cc6d1ef6c74/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= 291 | golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= 292 | golang.org/x/tools v0.0.0-20200212150539-ea181f53ac56/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= 293 | golang.org/x/tools v0.0.0-20200224181240-023911ca70b2/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= 294 | golang.org/x/tools v0.0.0-20200227222343-706bc42d1f0d/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= 295 | golang.org/x/tools v0.0.0-20200304193943-95d2e580d8eb/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw= 296 | golang.org/x/tools v0.0.0-20200312045724-11d5b4c81c7d/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw= 297 | golang.org/x/tools v0.0.0-20200331025713-a30bf2db82d4/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8= 298 | golang.org/x/tools v0.0.0-20200417140056-c07e33ef3290/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= 299 | golang.org/x/tools v0.0.0-20200501065659-ab2804fb9c9d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= 300 | golang.org/x/tools v0.0.0-20200512131952-2bc93b1c0c88/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= 301 | golang.org/x/tools v0.0.0-20200515010526-7d3b6ebf133d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= 302 | golang.org/x/tools v0.0.0-20200618134242-20370b0cb4b2/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= 303 | golang.org/x/tools v0.0.0-20200729194436-6467de6f59a7/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= 304 | golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= 305 | golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= 306 | golang.org/x/tools v0.0.0-20200904185747-39188db58858/go.mod h1:Cj7w3i3Rnn0Xh82ur9kSqwfTHTeVxaDqrfMjpcNT6bE= 307 | golang.org/x/tools v0.0.0-20201110124207-079ba7bd75cd/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= 308 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 309 | golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 310 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 311 | golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE= 312 | golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 313 | google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE= 314 | google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M= 315 | google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= 316 | google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= 317 | google.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= 318 | google.golang.org/api v0.14.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= 319 | google.golang.org/api v0.15.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= 320 | google.golang.org/api v0.17.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= 321 | google.golang.org/api v0.18.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= 322 | google.golang.org/api v0.19.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= 323 | google.golang.org/api v0.20.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= 324 | google.golang.org/api v0.22.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= 325 | google.golang.org/api v0.24.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE= 326 | google.golang.org/api v0.28.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE= 327 | google.golang.org/api v0.29.0/go.mod h1:Lcubydp8VUV7KeIHD9z2Bys/sm/vGKnG1UHuDBSrHWM= 328 | google.golang.org/api v0.30.0/go.mod h1:QGmEvQ87FHZNiUVJkT14jQNYJ4ZJjdRF23ZXz5138Fc= 329 | google.golang.org/api v0.35.0 h1:TBCmTTxUrRDA1iTctnK/fIeitxIZ+TQuaf0j29fmCGo= 330 | google.golang.org/api v0.35.0/go.mod h1:/XrVsuzM0rZmrsbjJutiuftIzeuTQcEeaYcSk/mQ1dg= 331 | google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= 332 | google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= 333 | google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= 334 | google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0= 335 | google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= 336 | google.golang.org/appengine v1.6.6 h1:lMO5rYAqUxkmaj76jAkRUvt5JZgFymx/+Q5Mzfivuhc= 337 | google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= 338 | google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= 339 | google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= 340 | google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= 341 | google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= 342 | google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= 343 | google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= 344 | google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= 345 | google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8= 346 | google.golang.org/genproto v0.0.0-20191108220845-16a3f7862a1a/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= 347 | google.golang.org/genproto v0.0.0-20191115194625-c23dd37a84c9/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= 348 | google.golang.org/genproto v0.0.0-20191216164720-4f79533eabd1/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= 349 | google.golang.org/genproto v0.0.0-20191230161307-f3c370f40bfb/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= 350 | google.golang.org/genproto v0.0.0-20200115191322-ca5a22157cba/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= 351 | google.golang.org/genproto v0.0.0-20200122232147-0452cf42e150/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= 352 | google.golang.org/genproto v0.0.0-20200204135345-fa8e72b47b90/go.mod h1:GmwEX6Z4W5gMy59cAlVYjN9JhxgbQH6Gn+gFDQe2lzA= 353 | google.golang.org/genproto v0.0.0-20200212174721-66ed5ce911ce/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= 354 | google.golang.org/genproto v0.0.0-20200224152610-e50cd9704f63/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= 355 | google.golang.org/genproto v0.0.0-20200228133532-8c2c7df3a383/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= 356 | google.golang.org/genproto v0.0.0-20200305110556-506484158171/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= 357 | google.golang.org/genproto v0.0.0-20200312145019-da6875a35672/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= 358 | google.golang.org/genproto v0.0.0-20200331122359-1ee6d9798940/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= 359 | google.golang.org/genproto v0.0.0-20200430143042-b979b6f78d84/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= 360 | google.golang.org/genproto v0.0.0-20200511104702-f5ebc3bea380/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= 361 | google.golang.org/genproto v0.0.0-20200515170657-fc4c6c6a6587/go.mod h1:YsZOwe1myG/8QRHRsmBRE1LrgQY60beZKjly0O1fX9U= 362 | google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= 363 | google.golang.org/genproto v0.0.0-20200618031413-b414f8b61790/go.mod h1:jDfRM7FcilCzHH/e9qn6dsT145K34l5v+OpcnNgKAAA= 364 | google.golang.org/genproto v0.0.0-20200729003335-053ba62fc06f/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= 365 | google.golang.org/genproto v0.0.0-20200804131852-c06518451d9c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= 366 | google.golang.org/genproto v0.0.0-20200825200019-8632dd797987/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= 367 | google.golang.org/genproto v0.0.0-20200904004341-0bd0a958aa1d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= 368 | google.golang.org/genproto v0.0.0-20201109203340-2640f1f9cdfb/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= 369 | google.golang.org/genproto v0.0.0-20201119123407-9b1e624d6bc4 h1:Rt0FRalMgdSlXAVJvX4pr65KfqaxHXSLkSJRD9pw6g0= 370 | google.golang.org/genproto v0.0.0-20201119123407-9b1e624d6bc4/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= 371 | google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= 372 | google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= 373 | google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= 374 | google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= 375 | google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= 376 | google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= 377 | google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= 378 | google.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= 379 | google.golang.org/grpc v1.28.0/go.mod h1:rpkK4SK4GF4Ach/+MFLZUBavHOvF2JJB5uozKKal+60= 380 | google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk= 381 | google.golang.org/grpc v1.30.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= 382 | google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= 383 | google.golang.org/grpc v1.31.1/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= 384 | google.golang.org/grpc v1.33.2 h1:EQyQC3sa8M+p6Ulc8yy9SWSS2GVwyRc83gAbG8lrl4o= 385 | google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc= 386 | google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= 387 | google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= 388 | google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= 389 | google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= 390 | google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= 391 | google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= 392 | google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= 393 | google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= 394 | google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4= 395 | google.golang.org/protobuf v1.25.0 h1:Ejskq+SyPohKW+1uil0JJMtmHCgJPJ/qWTxr8qp+R4c= 396 | google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= 397 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 398 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= 399 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 400 | gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= 401 | gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw= 402 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 403 | honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= 404 | honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= 405 | honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= 406 | honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= 407 | honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= 408 | honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= 409 | honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= 410 | rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= 411 | rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0= 412 | rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA= 413 | -------------------------------------------------------------------------------- /docker-smoketests/smoketests.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "crypto/rsa" 6 | "encoding/json" 7 | "flag" 8 | "fmt" 9 | "io/ioutil" 10 | "log" 11 | "net" 12 | "net/http" 13 | "net/http/httputil" 14 | "strings" 15 | "time" 16 | 17 | cloudtasks "cloud.google.com/go/cloudtasks/apiv2" 18 | "github.com/golang-jwt/jwt" 19 | "github.com/lestrrat-go/jwx/jwk" 20 | "google.golang.org/api/iterator" 21 | "google.golang.org/api/option" 22 | taskspb "google.golang.org/genproto/googleapis/cloud/tasks/v2" 23 | "google.golang.org/grpc" 24 | ) 25 | 26 | // Duplicated from app source to avoid having to import the project code 27 | type OpenIDConnectClaims struct { 28 | Email string `json:"email"` 29 | EmailVerified bool `json:"email_verified"` 30 | jwt.StandardClaims 31 | } 32 | 33 | func runTaskHttpServer(listenAddr string) <-chan *http.Request { 34 | receivedRequests := make(chan *http.Request) 35 | 36 | http.HandleFunc("/handler-test", func(w http.ResponseWriter, req *http.Request) { 37 | // Whatever happens, return 200 to clear the task from the emulator 38 | w.Write([]byte("OK")) 39 | 40 | requestDump, err := httputil.DumpRequest(req, true) 41 | fatalIfError(err) 42 | log.Printf("Received HTTP request:\n%s", requestDump) 43 | 44 | receivedRequests <- req 45 | }) 46 | 47 | log.Println("Starting test server on " + listenAddr) 48 | 49 | socket, err := net.Listen("tcp", listenAddr) 50 | fatalIfError(err) 51 | 52 | go http.Serve(socket, nil) 53 | 54 | return receivedRequests 55 | } 56 | 57 | func purgeQueue(client *cloudtasks.Client, queuePath string) { 58 | log.Printf("Purging queue %s", queuePath) 59 | purgeRequest := &taskspb.PurgeQueueRequest{ 60 | Name: queuePath, 61 | } 62 | 63 | _, err := client.PurgeQueue(context.Background(), purgeRequest) 64 | fatalIfError(err) 65 | } 66 | 67 | func createTasksClient(emulatorAddress string) *cloudtasks.Client { 68 | log.Printf("Building connection for emulator %s", emulatorAddress) 69 | conn, _ := grpc.Dial(emulatorAddress, grpc.WithInsecure()) 70 | clientOpt := option.WithGRPCConn(conn) 71 | client, _ := cloudtasks.NewClient(context.Background(), clientOpt) 72 | 73 | return client 74 | } 75 | 76 | func createTask(client *cloudtasks.Client, queuePath string, httpHandlerUrl string) string { 77 | log.Printf("Queuing task:\n -> Queue: %s\n -> Target URL: %s", queuePath, httpHandlerUrl) 78 | 79 | createTaskRequest := taskspb.CreateTaskRequest{ 80 | Parent: queuePath, 81 | Task: &taskspb.Task{ 82 | MessageType: &taskspb.Task_HttpRequest{ 83 | HttpRequest: &taskspb.HttpRequest{ 84 | Url: httpHandlerUrl, 85 | Headers: map[string]string{ 86 | "X-My-Header": "isThis", 87 | }, 88 | AuthorizationHeader: &taskspb.HttpRequest_OidcToken{ 89 | OidcToken: &taskspb.OidcToken{ 90 | ServiceAccountEmail: "emulator@service.test", 91 | }, 92 | }, 93 | Body: []byte("Here is a body for you"), 94 | }, 95 | }, 96 | }, 97 | } 98 | 99 | createdTaskResp, err := client.CreateTask(context.Background(), &createTaskRequest) 100 | fatalIfError(err) 101 | 102 | log.Printf("Created task: %s\n", createdTaskResp.GetName()) 103 | return createdTaskResp.GetName() 104 | } 105 | 106 | func listTasks(client *cloudtasks.Client, queuePath string) map[string]string { 107 | listTasksRequest := &taskspb.ListTasksRequest{ 108 | Parent: queuePath, 109 | } 110 | 111 | taskIterator := client.ListTasks(context.Background(), listTasksRequest) 112 | 113 | result := make(map[string]string) 114 | 115 | for { 116 | task, err := taskIterator.Next() 117 | if err == iterator.Done { 118 | break 119 | } 120 | fatalIfError(err) 121 | result[task.GetName()] = task.String() 122 | } 123 | return result 124 | } 125 | 126 | func waitForRequestOrTimeout(channel <-chan *http.Request, timeout time.Duration) (*http.Request, error) { 127 | select { 128 | case result := <-channel: 129 | return result, nil 130 | case <-time.After(timeout): 131 | return nil, fmt.Errorf("Timed out after %v waiting for task delivery", timeout) 132 | } 133 | } 134 | 135 | func assertEqual(expect string, actual string) { 136 | if expect != actual { 137 | log.Fatalf("Failed asserting %s = %s", expect, actual) 138 | } 139 | } 140 | 141 | func fatalIfError(err error) { 142 | if err != nil { 143 | log.Fatal(err) 144 | } 145 | } 146 | 147 | func readRequestBody(req *http.Request) string { 148 | body, err := ioutil.ReadAll(req.Body) 149 | fatalIfError(err) 150 | return string(body) 151 | } 152 | 153 | func getUnverifiedIssuerFromJWT(tokenStr string) string { 154 | token, _, err := new(jwt.Parser).ParseUnverified(tokenStr, &jwt.StandardClaims{}) 155 | fatalIfError(err) 156 | claims := token.Claims.(*jwt.StandardClaims) 157 | return claims.Issuer 158 | } 159 | 160 | func parseOpenIDConnectToken(tokenStr string, keySet *jwk.Set) (*jwt.Token, *OpenIDConnectClaims) { 161 | 162 | token, err := new(jwt.Parser).ParseWithClaims( 163 | tokenStr, 164 | &OpenIDConnectClaims{}, 165 | func(token *jwt.Token) (interface{}, error) { 166 | keyId := token.Header["kid"].(string) 167 | keys := keySet.LookupKeyID(keyId) 168 | 169 | var key rsa.PublicKey 170 | err := keys[0].Raw(&key) 171 | 172 | return &key, err 173 | }, 174 | ) 175 | 176 | fatalIfError(err) 177 | return token, token.Claims.(*OpenIDConnectClaims) 178 | } 179 | 180 | func fetchJsonFromUrl(url string) map[string]interface{} { 181 | client := http.Client{ 182 | Timeout: time.Second * 1, 183 | } 184 | req, err := http.NewRequest(http.MethodGet, url, nil) 185 | fatalIfError(err) 186 | 187 | res, err := client.Do(req) 188 | fatalIfError(err) 189 | 190 | body, err := ioutil.ReadAll(res.Body) 191 | fatalIfError(err) 192 | 193 | var parsedBody map[string]interface{} 194 | err = json.Unmarshal(body, &parsedBody) 195 | fatalIfError(err) 196 | 197 | return parsedBody 198 | } 199 | 200 | func main() { 201 | emulatorHost := flag.String("emulator-host", "cloud-tasks-emulator", "The hostname for the emulator") 202 | emulatorPort := flag.String("emulator-port", "8123", "The port for the emulator") 203 | httpHandlerHost := flag.String("http-handler-host", "ct-smoketests", "The hostname we can be reached on") 204 | httpHandlerPort := flag.String("http-handler-port", "8920", "The port our HTTP handler can be reached on") 205 | queuePath := flag.String("queue-path", "projects/test-project/locations/us-central1/queues/test", "Queue to use (must exist)") 206 | 207 | flag.Parse() 208 | 209 | handlerUrl := fmt.Sprintf("http://%s:%s/handler-test?param=foo", *httpHandlerHost, *httpHandlerPort) 210 | taskDeliveries := runTaskHttpServer(fmt.Sprintf("0.0.0.0:%s", *httpHandlerPort)) 211 | 212 | client := createTasksClient(fmt.Sprintf("%s:%s", *emulatorHost, *emulatorPort)) 213 | 214 | // In normal use during build the queue will be empty because it will be a clean emulator 215 | // but purge it now to ensure clean state if running multiple times when working on this test suite 216 | purgeQueue(client, *queuePath) 217 | 218 | createTask(client, *queuePath, handlerUrl) 219 | 220 | request, err := waitForRequestOrTimeout(taskDeliveries, 2*time.Second) 221 | fatalIfError(err) 222 | 223 | assertEqual("POST", request.Method) 224 | assertEqual("Here is a body for you", readRequestBody(request)) 225 | assertEqual("isThis", request.Header.Get("X-My-Header")) 226 | assertEqual("/handler-test?param=foo", request.URL.String()) 227 | log.Println("HTTP request matched expectations") 228 | 229 | log.Println("Verifying OIDC Authentication and discovery") 230 | authHeader := request.Header.Get("Authorization") 231 | tokenStr := strings.Replace(authHeader, "Bearer ", "", 1) 232 | 233 | // So far, so good. Now check token can be verified using http discovery 234 | // Note - this is *never* how you would usually do this, you should *always* 235 | // start from a whitelist of issuers and pre-load their certs to your app. 236 | issuer := getUnverifiedIssuerFromJWT(tokenStr) 237 | log.Printf("Got token issued by %v", issuer) 238 | discovery := fetchJsonFromUrl(issuer + "/.well-known/openid-configuration") 239 | 240 | jwks_uri := discovery["jwks_uri"].(string) 241 | keySet, err := jwk.Fetch(jwks_uri) 242 | fatalIfError(err) 243 | log.Printf("Retrieved issuer keys from %v", jwks_uri) 244 | 245 | // Basic validation, values are fully validated in oidc_internal_test 246 | token, claims := parseOpenIDConnectToken(tokenStr, keySet) 247 | if !token.Valid { 248 | log.Fatal("Auth token was not valid") 249 | } 250 | assertEqual("emulator@service.test", claims.Email) 251 | log.Printf("Validated auth token from %v for %v", claims.Audience, claims.Issuer) 252 | 253 | log.Println("Verifying dispatched tasks are removed from the list") 254 | queuedTasks := listTasks(client, *queuePath) 255 | if len(queuedTasks) > 0 { 256 | log.Fatalf("Unexpectedly got %v tasks remaining:\n%v", len(queuedTasks), queuedTasks) 257 | } 258 | 259 | log.Println("Waiting to verify no duplicate deliveries") 260 | request, _ = waitForRequestOrTimeout(taskDeliveries, 5*time.Second) 261 | if request != nil { 262 | // The request is logged on receipt, so it's not necessary to log it again here 263 | log.Fatal("Got unexpected extra HTTP delivery") 264 | } 265 | 266 | log.Println("Test complete") 267 | } 268 | -------------------------------------------------------------------------------- /docker-smoketests/smoketests.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -o nounset 3 | set -o errexit 4 | 5 | network_name=cloud-tasks-emulator-net 6 | test_dir="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" 7 | 8 | echo "Running smoketests for docker image $EMULATOR_DOCKER_IMAGE" 9 | 10 | echo "Creating docker network $network_name" 11 | docker network create "$network_name" 12 | 13 | echo "Starting emulator" 14 | # Intentionally uses a non-standard port to verify that the port argument is handled as expected 15 | docker run \ 16 | --rm \ 17 | -d \ 18 | -p 8930:8930 \ 19 | -p 8050:8050 \ 20 | --name cloud-tasks-emulator \ 21 | --network $network_name \ 22 | "$EMULATOR_DOCKER_IMAGE" \ 23 | -host 0.0.0.0 \ 24 | -queue projects/test-project/locations/us-central1/queues/test \ 25 | -port 8930 \ 26 | -openid-issuer http://cloud-tasks-emulator:8050 27 | 28 | echo "" 29 | echo "-------------------" 30 | echo "Running smoketests" 31 | set +o errexit 32 | docker run \ 33 | --rm \ 34 | -v $test_dir:/go/src \ 35 | -v /tmp/smoketest-packages:/go/pkg \ 36 | -w /go/src \ 37 | -p 8920:8920 \ 38 | --name ct-smoketests \ 39 | --network $network_name \ 40 | golang:1.13-alpine \ 41 | go run smoketests.go \ 42 | -emulator-port 8930 43 | 44 | test_result=$? 45 | set -o errexit 46 | 47 | echo "Test completed with code $test_result" 48 | echo "" 49 | echo "-------------------" 50 | echo "Logs from emulator:" 51 | docker logs cloud-tasks-emulator 52 | 53 | echo "" 54 | echo "-------------------" 55 | echo "Cleaning up" 56 | docker kill cloud-tasks-emulator 57 | docker network rm "$network_name" 58 | 59 | exit $test_result 60 | -------------------------------------------------------------------------------- /emulator.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "flag" 6 | "fmt" 7 | "net" 8 | "regexp" 9 | "strings" 10 | "sync" 11 | 12 | tasks "google.golang.org/genproto/googleapis/cloud/tasks/v2" 13 | v1 "google.golang.org/genproto/googleapis/iam/v1" 14 | 15 | codes "google.golang.org/grpc/codes" 16 | status "google.golang.org/grpc/status" 17 | 18 | "github.com/golang/protobuf/proto" 19 | "github.com/golang/protobuf/ptypes/empty" 20 | "google.golang.org/grpc" 21 | ) 22 | 23 | // NewServer creates a new emulator server with its own task and queue bookkeeping 24 | func NewServer() *Server { 25 | return &Server{ 26 | qs: make(map[string]*Queue), 27 | ts: make(map[string]*Task), 28 | Options: ServerOptions{ 29 | HardResetOnPurgeQueue: false, 30 | }, 31 | } 32 | } 33 | 34 | type ServerOptions struct { 35 | HardResetOnPurgeQueue bool 36 | } 37 | 38 | // Server represents the emulator server 39 | type Server struct { 40 | qs map[string]*Queue 41 | ts map[string]*Task 42 | 43 | qsMux sync.Mutex 44 | tsMux sync.Mutex 45 | Options ServerOptions 46 | } 47 | 48 | func (s *Server) setQueue(queueName string, queue *Queue) { 49 | s.qsMux.Lock() 50 | defer s.qsMux.Unlock() 51 | s.qs[queueName] = queue 52 | } 53 | 54 | func (s *Server) fetchQueue(queueName string) (*Queue, bool) { 55 | s.qsMux.Lock() 56 | defer s.qsMux.Unlock() 57 | queue, ok := s.qs[queueName] 58 | return queue, ok 59 | } 60 | 61 | func (s *Server) removeQueue(queueName string) { 62 | s.setQueue(queueName, nil) 63 | } 64 | 65 | func (s *Server) setTask(taskName string, task *Task) { 66 | s.tsMux.Lock() 67 | defer s.tsMux.Unlock() 68 | s.ts[taskName] = task 69 | } 70 | 71 | func (s *Server) fetchTask(taskName string) (*Task, bool) { 72 | s.tsMux.Lock() 73 | defer s.tsMux.Unlock() 74 | task, ok := s.ts[taskName] 75 | return task, ok 76 | } 77 | 78 | func (s *Server) removeTask(taskName string) { 79 | s.setTask(taskName, nil) 80 | } 81 | 82 | func (s *Server) hardDeleteTask(taskName string) { 83 | s.tsMux.Lock() 84 | defer s.tsMux.Unlock() 85 | delete(s.ts, taskName) 86 | } 87 | 88 | // ListQueues lists the existing queues 89 | func (s *Server) ListQueues(ctx context.Context, in *tasks.ListQueuesRequest) (*tasks.ListQueuesResponse, error) { 90 | // TODO: Implement pageing 91 | 92 | var queueStates []*tasks.Queue 93 | 94 | s.qsMux.Lock() 95 | defer s.qsMux.Unlock() 96 | 97 | for _, queue := range s.qs { 98 | if queue != nil { 99 | queueStates = append(queueStates, queue.state) 100 | } 101 | } 102 | 103 | return &tasks.ListQueuesResponse{ 104 | Queues: queueStates, 105 | }, nil 106 | } 107 | 108 | // GetQueue returns the requested queue 109 | func (s *Server) GetQueue(ctx context.Context, in *tasks.GetQueueRequest) (*tasks.Queue, error) { 110 | queue, ok := s.fetchQueue(in.GetName()) 111 | 112 | // Cloud responds with the same error message whether the queue was recently deleted or never existed 113 | if !ok || queue == nil { 114 | return nil, status.Errorf(codes.NotFound, "Queue does not exist. If you just created the queue, wait at least a minute for the queue to initialize.") 115 | } 116 | 117 | return queue.state, nil 118 | } 119 | 120 | // CreateQueue creates a new queue 121 | func (s *Server) CreateQueue(ctx context.Context, in *tasks.CreateQueueRequest) (*tasks.Queue, error) { 122 | queueState := in.GetQueue() 123 | 124 | name := queueState.GetName() 125 | nameMatched, _ := regexp.MatchString("projects/[A-Za-z0-9-]+/locations/[A-Za-z0-9-]+/queues/[A-Za-z0-9-]+", name) 126 | if !nameMatched { 127 | return nil, status.Errorf(codes.InvalidArgument, "Queue name must be formatted: \"projects//locations//queues/\"") 128 | } 129 | parent := in.GetParent() 130 | parentMatched, _ := regexp.MatchString("projects/[A-Za-z0-9-]+/locations/[A-Za-z0-9-]+", parent) 131 | if !parentMatched { 132 | return nil, status.Errorf(codes.InvalidArgument, "Invalid resource field value in the request.") 133 | } 134 | queue, ok := s.fetchQueue(name) 135 | if ok { 136 | if queue != nil { 137 | return nil, status.Errorf(codes.AlreadyExists, "Queue already exists") 138 | } 139 | 140 | return nil, status.Errorf(codes.FailedPrecondition, "The queue cannot be created because a queue with this name existed too recently.") 141 | } 142 | 143 | // Make a deep copy so that the original is frozen for the http response 144 | queue, queueState = NewQueue( 145 | name, 146 | proto.Clone(queueState).(*tasks.Queue), 147 | func(task *Task) { 148 | s.removeTask(task.state.GetName()) 149 | }, 150 | ) 151 | s.setQueue(name, queue) 152 | queue.Run() 153 | 154 | return queueState, nil 155 | } 156 | 157 | // UpdateQueue updates an existing queue (not implemented yet) 158 | func (s *Server) UpdateQueue(ctx context.Context, in *tasks.UpdateQueueRequest) (*tasks.Queue, error) { 159 | return nil, status.Errorf(codes.Unimplemented, "Not yet implemented") 160 | } 161 | 162 | // DeleteQueue removes an existing queue. 163 | func (s *Server) DeleteQueue(ctx context.Context, in *tasks.DeleteQueueRequest) (*empty.Empty, error) { 164 | queue, ok := s.fetchQueue(in.GetName()) 165 | 166 | // Cloud responds with same error for recently deleted queue 167 | if !ok || queue == nil { 168 | return nil, status.Errorf(codes.NotFound, "Requested entity was not found.") 169 | } 170 | 171 | queue.Delete() 172 | 173 | s.removeQueue(in.GetName()) 174 | 175 | return &empty.Empty{}, nil 176 | } 177 | 178 | // PurgeQueue purges the specified queue 179 | func (s *Server) PurgeQueue(ctx context.Context, in *tasks.PurgeQueueRequest) (*tasks.Queue, error) { 180 | queue, _ := s.fetchQueue(in.GetName()) 181 | 182 | if s.Options.HardResetOnPurgeQueue { 183 | // Use the development environment behaviour - synchronously purge the queue and release all task names 184 | queue.HardReset(s) 185 | } else { 186 | // Mirror production behaviour - spin off an asynchronous purge operation and return 187 | queue.Purge() 188 | } 189 | 190 | return queue.state, nil 191 | } 192 | 193 | // PauseQueue pauses queue execution 194 | func (s *Server) PauseQueue(ctx context.Context, in *tasks.PauseQueueRequest) (*tasks.Queue, error) { 195 | queue, _ := s.fetchQueue(in.GetName()) 196 | 197 | queue.Pause() 198 | 199 | return queue.state, nil 200 | } 201 | 202 | // ResumeQueue resumes a paused queue 203 | func (s *Server) ResumeQueue(ctx context.Context, in *tasks.ResumeQueueRequest) (*tasks.Queue, error) { 204 | queue, _ := s.fetchQueue(in.GetName()) 205 | 206 | queue.Resume() 207 | 208 | return queue.state, nil 209 | } 210 | 211 | // GetIamPolicy doesn't do anything 212 | func (s *Server) GetIamPolicy(ctx context.Context, in *v1.GetIamPolicyRequest) (*v1.Policy, error) { 213 | return nil, status.Errorf(codes.Unimplemented, "Not yet implemented") 214 | } 215 | 216 | // SetIamPolicy doesn't do anything 217 | func (s *Server) SetIamPolicy(ctx context.Context, in *v1.SetIamPolicyRequest) (*v1.Policy, error) { 218 | return nil, status.Errorf(codes.Unimplemented, "Not yet implemented") 219 | } 220 | 221 | // TestIamPermissions doesn't do anything 222 | func (s *Server) TestIamPermissions(ctx context.Context, in *v1.TestIamPermissionsRequest) (*v1.TestIamPermissionsResponse, error) { 223 | return nil, status.Errorf(codes.Unimplemented, "Not yet implemented") 224 | } 225 | 226 | // ListTasks lists the tasks in the specified queue 227 | func (s *Server) ListTasks(ctx context.Context, in *tasks.ListTasksRequest) (*tasks.ListTasksResponse, error) { 228 | // TODO: Implement pageing of some sort 229 | queue, ok := s.fetchQueue(in.GetParent()) 230 | if !ok || queue == nil { 231 | return nil, status.Errorf(codes.NotFound, "Queue does not exist. If you just created the queue, wait at least a minute for the queue to initialize.") 232 | } 233 | 234 | var taskStates []*tasks.Task 235 | 236 | queue.tsMux.Lock() 237 | defer queue.tsMux.Unlock() 238 | 239 | for _, task := range queue.ts { 240 | if task != nil { 241 | taskStates = append(taskStates, task.state) 242 | } 243 | } 244 | 245 | return &tasks.ListTasksResponse{ 246 | Tasks: taskStates, 247 | }, nil 248 | } 249 | 250 | // GetTask returns the specified task 251 | func (s *Server) GetTask(ctx context.Context, in *tasks.GetTaskRequest) (*tasks.Task, error) { 252 | task, ok := s.fetchTask(in.GetName()) 253 | if !ok { 254 | return nil, status.Errorf(codes.NotFound, "Task does not exist.") 255 | } 256 | if task == nil { 257 | return nil, status.Errorf(codes.FailedPrecondition, "The task no longer exists, though a task with this name existed recently. The task either successfully completed or was deleted.") 258 | } 259 | 260 | return task.state, nil 261 | } 262 | 263 | // CreateTask creates a new task 264 | func (s *Server) CreateTask(ctx context.Context, in *tasks.CreateTaskRequest) (*tasks.Task, error) { 265 | 266 | queueName := in.GetParent() 267 | queue, ok := s.fetchQueue(queueName) 268 | if !ok { 269 | return nil, status.Errorf(codes.NotFound, "Queue does not exist.") 270 | } 271 | if queue == nil { 272 | return nil, status.Errorf(codes.FailedPrecondition, "The queue no longer exists, though a queue with this name existed recently.") 273 | } 274 | 275 | if in.Task.Name != "" { 276 | // If a name is specified, it must be valid, it must be unique, and it must belong to this queue 277 | if !isValidTaskName(in.Task.Name) { 278 | return nil, status.Errorf(codes.InvalidArgument, `Task name must be formatted: "projects//locations//queues//tasks/"`) 279 | } 280 | if !strings.HasPrefix(in.Task.Name, queueName+"/tasks/") { 281 | return nil, status.Errorf( 282 | codes.InvalidArgument, 283 | "The queue name from request ('%s') must be the same as the queue name in the named task ('%s').", 284 | in.Task.Name, 285 | queueName, 286 | ) 287 | } 288 | if _, exists := s.fetchTask(in.Task.Name); exists { 289 | return nil, status.Errorf(codes.AlreadyExists, "Requested entity already exists") 290 | } 291 | } 292 | 293 | task, taskState := queue.NewTask(in.GetTask()) 294 | 295 | s.setTask(taskState.GetName(), task) 296 | 297 | return taskState, nil 298 | } 299 | 300 | // DeleteTask removes an existing task 301 | func (s *Server) DeleteTask(ctx context.Context, in *tasks.DeleteTaskRequest) (*empty.Empty, error) { 302 | task, ok := s.fetchTask(in.GetName()) 303 | if !ok { 304 | return nil, status.Errorf(codes.NotFound, "Task does not exist.") 305 | } 306 | if task == nil { 307 | return nil, status.Errorf(codes.NotFound, "The task no longer exists, though a task with this name existed recently. The task either successfully completed or was deleted.") 308 | } 309 | 310 | // The removal of the task from the server struct is handled in the queue callback 311 | task.Delete() 312 | 313 | return &empty.Empty{}, nil 314 | } 315 | 316 | // RunTask executes an existing task immediately 317 | func (s *Server) RunTask(ctx context.Context, in *tasks.RunTaskRequest) (*tasks.Task, error) { 318 | task, ok := s.fetchTask(in.GetName()) 319 | 320 | if !ok { 321 | return nil, status.Errorf(codes.NotFound, "Task does not exist.") 322 | } 323 | if task == nil { 324 | return nil, status.Errorf(codes.NotFound, "The task no longer exists, though a task with this name existed recently. The task either successfully completed or was deleted.") 325 | } 326 | 327 | taskState := task.Run() 328 | 329 | return taskState, nil 330 | } 331 | 332 | // arrayFlags used for parsing list of potentially repeated flags e.g. -queue $Q1 -queue $Q2 333 | type arrayFlags []string 334 | 335 | func (i *arrayFlags) String() string { 336 | return strings.Join(*i, ", ") 337 | } 338 | 339 | func (i *arrayFlags) Set(value string) error { 340 | *i = append(*i, value) 341 | return nil 342 | } 343 | 344 | // Creates an initial queue on the emulator 345 | func createInitialQueue(emulatorServer *Server, name string) { 346 | print(fmt.Sprintf("Creating initial queue %s\n", name)) 347 | 348 | r := regexp.MustCompile("/queues/[A-Za-z0-9-]+$") 349 | parentName := r.ReplaceAllString(name, "") 350 | 351 | queue := &tasks.Queue{Name: name} 352 | req := &tasks.CreateQueueRequest{ 353 | Parent: parentName, 354 | Queue: queue, 355 | } 356 | 357 | _, err := emulatorServer.CreateQueue(context.TODO(), req) 358 | if err != nil { 359 | panic(err) 360 | } 361 | } 362 | 363 | func main() { 364 | var initialQueues arrayFlags 365 | 366 | host := flag.String("host", "localhost", "The host name") 367 | port := flag.String("port", "8123", "The port") 368 | openidIssuer := flag.String("openid-issuer", "", "URL to serve the OpenID configuration on, if required") 369 | hardResetOnPurgeQueue := flag.Bool("hard-reset-on-purge-queue", false, "Set to force the 'Purge Queue' call to perform a hard reset of all state (differs from production)") 370 | 371 | flag.Var(&initialQueues, "queue", "A queue to create on startup (repeat as required)") 372 | 373 | flag.Parse() 374 | 375 | if *openidIssuer != "" { 376 | srv, err := configureOpenIdIssuer(*openidIssuer) 377 | if err != nil { 378 | panic(err) 379 | } 380 | defer srv.Shutdown(context.Background()) 381 | } 382 | 383 | lis, err := net.Listen("tcp", fmt.Sprintf("%v:%v", *host, *port)) 384 | if err != nil { 385 | panic(err) 386 | } 387 | 388 | print(fmt.Sprintf("Starting cloud tasks emulator, listening on %v:%v\n", *host, *port)) 389 | 390 | grpcServer := grpc.NewServer() 391 | emulatorServer := NewServer() 392 | emulatorServer.Options.HardResetOnPurgeQueue = *hardResetOnPurgeQueue 393 | tasks.RegisterCloudTasksServer(grpcServer, emulatorServer) 394 | 395 | for i := 0; i < len(initialQueues); i++ { 396 | createInitialQueue(emulatorServer, initialQueues[i]) 397 | } 398 | 399 | grpcServer.Serve(lis) 400 | } 401 | -------------------------------------------------------------------------------- /emulator_from_env.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | RUN_CMD="/emulator" 4 | 5 | if [ -n "$HOST" ]; then 6 | RUN_CMD="$RUN_CMD -host=$HOST" 7 | fi 8 | 9 | if [ -n "$PORT" ]; then 10 | RUN_CMD="$RUN_CMD -port=$PORT" 11 | fi 12 | 13 | if [ -n "$HARD_RESET_ON_PURGE_QUEUE" ]; then 14 | RUN_CMD="$RUN_CMD -hard_reset_on_purge_queue=$HARD_RESET_ON_PURGE_QUEUE" 15 | fi 16 | 17 | if [ -n "$OPENID_ISSUER" ]; then 18 | RUN_CMD="$RUN_CMD -openid_issuer=$OPENID_ISSUER" 19 | fi 20 | 21 | if [ -n "$INITIAL_QUEUES" ]; then 22 | IFS="," 23 | set -- $INITIAL_QUEUES 24 | for i in "$@"; do 25 | RUN_CMD="$RUN_CMD -queue=$i" 26 | done 27 | fi 28 | 29 | `$RUN_CMD` 30 | -------------------------------------------------------------------------------- /emulator_test.go: -------------------------------------------------------------------------------- 1 | package main_test 2 | 3 | import ( 4 | "context" 5 | "flag" 6 | "fmt" 7 | "log" 8 | "math" 9 | "net" 10 | "net/http" 11 | "os" 12 | "strconv" 13 | "strings" 14 | "testing" 15 | "time" 16 | 17 | "github.com/golang-jwt/jwt" 18 | 19 | . "cloud.google.com/go/cloudtasks/apiv2" 20 | . "github.com/aertje/cloud-tasks-emulator" 21 | "github.com/stretchr/testify/assert" 22 | "github.com/stretchr/testify/require" 23 | "google.golang.org/api/iterator" 24 | "google.golang.org/api/option" 25 | taskspb "google.golang.org/genproto/googleapis/cloud/tasks/v2" 26 | "google.golang.org/grpc" 27 | grpcCodes "google.golang.org/grpc/codes" 28 | grpcStatus "google.golang.org/grpc/status" 29 | ) 30 | 31 | var formattedParent = formatParent("TestProject", "TestLocation") 32 | 33 | func TestMain(m *testing.M) { 34 | flag.Parse() 35 | 36 | os.Exit(m.Run()) 37 | } 38 | 39 | func setUp(t *testing.T, options ServerOptions) (*grpc.Server, *Client) { 40 | serv := grpc.NewServer() 41 | emulatorServer := NewServer() 42 | emulatorServer.Options = options 43 | taskspb.RegisterCloudTasksServer(serv, emulatorServer) 44 | 45 | lis, err := net.Listen("tcp", "localhost:0") 46 | if err != nil { 47 | log.Fatal(err) 48 | } 49 | go serv.Serve(lis) 50 | 51 | conn, err := grpc.Dial(lis.Addr().String(), grpc.WithInsecure()) 52 | if err != nil { 53 | log.Fatal(err) 54 | } 55 | clientOpt := option.WithGRPCConn(conn) 56 | 57 | client, err := NewClient(context.Background(), clientOpt) 58 | if err != nil { 59 | log.Fatal(err) 60 | } 61 | 62 | return serv, client 63 | } 64 | 65 | func tearDown(t *testing.T, serv *grpc.Server) { 66 | serv.Stop() 67 | } 68 | 69 | func tearDownQueue(t *testing.T, client *Client, queue *taskspb.Queue) { 70 | deleteQueueRequest := taskspb.DeleteQueueRequest{ 71 | Name: queue.GetName(), 72 | } 73 | err := client.DeleteQueue(context.Background(), &deleteQueueRequest) 74 | require.NoError(t, err) 75 | // Wait a moment for the queue to delete and all tasks to definitely be done & not going to fire again 76 | time.Sleep(100 * time.Millisecond) 77 | } 78 | 79 | func TestCloudTasksCreateQueue(t *testing.T) { 80 | serv, client := setUp(t, ServerOptions{}) 81 | defer tearDown(t, serv) 82 | queue := newQueue(formattedParent, "testCloudTasksCreateQueue") 83 | request := taskspb.CreateQueueRequest{ 84 | Parent: formattedParent, 85 | Queue: queue, 86 | } 87 | 88 | resp, err := client.CreateQueue(context.Background(), &request) 89 | require.NoError(t, err) 90 | assert.Equal(t, request.GetQueue().Name, resp.Name) 91 | assert.Equal(t, taskspb.Queue_RUNNING, resp.State) 92 | } 93 | 94 | func TestCreateTask(t *testing.T) { 95 | serv, client := setUp(t, ServerOptions{}) 96 | defer tearDown(t, serv) 97 | 98 | createdQueue := createTestQueue(t, client) 99 | defer tearDownQueue(t, client, createdQueue) 100 | 101 | createTaskRequest := taskspb.CreateTaskRequest{ 102 | Parent: createdQueue.GetName(), 103 | Task: &taskspb.Task{ 104 | MessageType: &taskspb.Task_HttpRequest{ 105 | HttpRequest: &taskspb.HttpRequest{ 106 | Url: "http://www.google.com", 107 | }, 108 | }, 109 | }, 110 | } 111 | 112 | createdTask, err := client.CreateTask(context.Background(), &createTaskRequest) 113 | require.NoError(t, err) 114 | assert.NotEmpty(t, createdTask.GetName()) 115 | assert.Contains(t, createdTask.GetName(), "projects/TestProject/locations/TestLocation/queues/test/tasks/") 116 | assert.Equal(t, "http://www.google.com", createdTask.GetHttpRequest().GetUrl()) 117 | assert.Equal(t, taskspb.HttpMethod_POST, createdTask.GetHttpRequest().GetHttpMethod()) 118 | assert.EqualValues(t, 0, createdTask.GetDispatchCount()) 119 | } 120 | 121 | func TestCreateTaskRejectsDuplicateName(t *testing.T) { 122 | serv, client := setUp(t, ServerOptions{}) 123 | defer tearDown(t, serv) 124 | 125 | createdQueue := createTestQueue(t, client) 126 | defer tearDownQueue(t, client, createdQueue) 127 | 128 | srv, receivedRequests := startTestServer() 129 | defer srv.Shutdown(context.Background()) 130 | 131 | createTaskRequest := taskspb.CreateTaskRequest{ 132 | Parent: createdQueue.GetName(), 133 | Task: &taskspb.Task{ 134 | Name: createdQueue.GetName() + "/tasks/dedupe-this-task", 135 | MessageType: &taskspb.Task_HttpRequest{ 136 | HttpRequest: &taskspb.HttpRequest{ 137 | Url: "http://localhost:5000/success", 138 | }, 139 | }, 140 | }, 141 | } 142 | 143 | createdTask, err := client.CreateTask(context.Background(), &createTaskRequest) 144 | require.NoError(t, err) 145 | 146 | // First creation worked OK 147 | 148 | dupeTask, err := client.CreateTask(context.Background(), &createTaskRequest) 149 | 150 | assert.Nil(t, dupeTask) 151 | assertIsGrpcError(t, "^Requested entity already exists", grpcCodes.AlreadyExists, err) 152 | 153 | // Wait for it to perform the http request 154 | _, err = awaitHttpRequest(receivedRequests) 155 | require.NoError(t, err) 156 | 157 | // Check the task has been removed now (to ensure state is valid for the 158 | // recreate-even-after-executed-and-removed case following) 159 | getTaskRequest := taskspb.GetTaskRequest{ 160 | Name: createdTask.GetName(), 161 | } 162 | gettedTask, err := client.GetTask(context.Background(), &getTaskRequest) 163 | assert.Error(t, err) 164 | assert.Nil(t, gettedTask) 165 | 166 | // Check still can't create even after removal 167 | _, err = client.CreateTask(context.Background(), &createTaskRequest) 168 | assertIsGrpcError(t, "^Requested entity already exists", grpcCodes.AlreadyExists, err) 169 | 170 | // Verify that it only sent the original HTTP request, nothing after that 171 | _, err = awaitHttpRequestWithTimeout(receivedRequests, 1*time.Second) 172 | assert.Error(t, err, "Should not receive any further HTTP requests within timeout") 173 | } 174 | 175 | func TestCreateTaskRejectsInvalidName(t *testing.T) { 176 | serv, client := setUp(t, ServerOptions{}) 177 | defer tearDown(t, serv) 178 | 179 | createdQueue := createTestQueue(t, client) 180 | defer tearDownQueue(t, client, createdQueue) 181 | 182 | createTaskRequest := taskspb.CreateTaskRequest{ 183 | Parent: createdQueue.GetName(), 184 | Task: &taskspb.Task{ 185 | Name: "is-this-a-name", 186 | MessageType: &taskspb.Task_HttpRequest{ 187 | HttpRequest: &taskspb.HttpRequest{ 188 | Url: "http://www.google.com", 189 | }, 190 | }, 191 | }, 192 | } 193 | 194 | createdTask, err := client.CreateTask(context.Background(), &createTaskRequest) 195 | 196 | assert.Nil(t, createdTask) 197 | assertIsGrpcError(t, "^Task name must be formatted", grpcCodes.InvalidArgument, err) 198 | } 199 | 200 | func TestCreateTaskRejectsNameForOtherQueue(t *testing.T) { 201 | serv, client := setUp(t, ServerOptions{}) 202 | defer tearDown(t, serv) 203 | 204 | createdQueue := createTestQueue(t, client) 205 | defer tearDownQueue(t, client, createdQueue) 206 | 207 | createTaskRequest := taskspb.CreateTaskRequest{ 208 | Parent: createdQueue.GetName(), 209 | Task: &taskspb.Task{ 210 | Name: "projects/TestProject/locations/TestLocation/queues/SomeOtherQueue/tasks/valid-name", 211 | MessageType: &taskspb.Task_HttpRequest{ 212 | HttpRequest: &taskspb.HttpRequest{ 213 | Url: "http://www.google.com", 214 | }, 215 | }, 216 | }, 217 | } 218 | 219 | createdTask, err := client.CreateTask(context.Background(), &createTaskRequest) 220 | 221 | assert.Nil(t, createdTask) 222 | assertIsGrpcError(t, "^The queue name from request", grpcCodes.InvalidArgument, err) 223 | } 224 | 225 | func TestGetQueueExists(t *testing.T) { 226 | serv, client := setUp(t, ServerOptions{}) 227 | defer tearDown(t, serv) 228 | 229 | createdQueue := createTestQueue(t, client) 230 | 231 | getQueueRequest := taskspb.GetQueueRequest{ 232 | Name: createdQueue.GetName(), 233 | } 234 | 235 | gettedQueue, err := client.GetQueue(context.Background(), &getQueueRequest) 236 | 237 | assert.NoError(t, err) 238 | assert.Equal(t, createdQueue.GetName(), gettedQueue.GetName()) 239 | } 240 | 241 | func TestGetQueueNeverExisted(t *testing.T) { 242 | serv, client := setUp(t, ServerOptions{}) 243 | defer tearDown(t, serv) 244 | 245 | getQueueRequest := taskspb.GetQueueRequest{ 246 | Name: "hello_q", 247 | } 248 | 249 | gettedQueue, err := client.GetQueue(context.Background(), &getQueueRequest) 250 | 251 | assert.Nil(t, gettedQueue) 252 | st, _ := grpcStatus.FromError(err) 253 | assert.Equal(t, grpcCodes.NotFound, st.Code()) 254 | } 255 | 256 | func TestGetQueuePreviouslyExisted(t *testing.T) { 257 | serv, client := setUp(t, ServerOptions{}) 258 | defer tearDown(t, serv) 259 | 260 | createdQueue := createTestQueue(t, client) 261 | 262 | deleteQueueRequest := taskspb.DeleteQueueRequest{ 263 | Name: createdQueue.GetName(), 264 | } 265 | 266 | err := client.DeleteQueue(context.Background(), &deleteQueueRequest) 267 | 268 | assert.NoError(t, err) 269 | 270 | getQueueRequest := taskspb.GetQueueRequest{ 271 | Name: createdQueue.GetName(), 272 | } 273 | 274 | gettedQueue, err := client.GetQueue(context.Background(), &getQueueRequest) 275 | 276 | assert.Nil(t, gettedQueue) 277 | st, _ := grpcStatus.FromError(err) 278 | assert.Equal(t, grpcCodes.NotFound, st.Code()) 279 | } 280 | 281 | func TestPurgeQueueDoesNotReleaseTaskNamesByDefault(t *testing.T) { 282 | serv, client := setUp(t, ServerOptions{}) 283 | defer tearDown(t, serv) 284 | 285 | createdQueue := createTestQueue(t, client) 286 | defer tearDownQueue(t, client, createdQueue) 287 | 288 | srv, receivedRequests := startTestServer() 289 | defer srv.Shutdown(context.Background()) 290 | 291 | createTaskRequest := taskspb.CreateTaskRequest{ 292 | Parent: createdQueue.GetName(), 293 | Task: &taskspb.Task{ 294 | Name: createdQueue.GetName() + "/tasks/any-task", 295 | MessageType: &taskspb.Task_HttpRequest{ 296 | HttpRequest: &taskspb.HttpRequest{ 297 | // Use the not_found handler to prove that purge stops any further retries 298 | Url: "http://localhost:5000/not_found", 299 | }, 300 | }, 301 | }, 302 | } 303 | createdTask, err := client.CreateTask(context.Background(), &createTaskRequest) 304 | require.NoError(t, err) 305 | 306 | // Task was created OK, verify that the first HTTP request was sent 307 | _, err = awaitHttpRequest(receivedRequests) 308 | require.NoError(t, err) 309 | 310 | // Now purge the queue 311 | purgeQueueRequest := taskspb.PurgeQueueRequest{ 312 | Name: createdQueue.GetName(), 313 | } 314 | _, err = client.PurgeQueue(context.Background(), &purgeQueueRequest) 315 | require.NoError(t, err) 316 | 317 | // Wait a moment for that to work, then verify nothing in the list and cannot retrieve by name 318 | time.Sleep(100 * time.Millisecond) 319 | assertTaskListIsEmpty(t, client, createdQueue) 320 | assertGetTaskFails(t, grpcCodes.FailedPrecondition, client, createdTask.GetName()) 321 | 322 | // BUT - Verify that the task name is still not available for new tasks 323 | _, err = client.CreateTask(context.Background(), &createTaskRequest) 324 | assertIsGrpcError(t, "^Requested entity already exists", grpcCodes.AlreadyExists, err) 325 | 326 | // Verify that it only sent the original HTTP request, it purged before the retries 327 | _, err = awaitHttpRequestWithTimeout(receivedRequests, 1*time.Second) 328 | assert.Error(t, err, "Should not receive any further HTTP requests within timeout") 329 | } 330 | 331 | func TestPurgeQueueOptionallyPerformsHardReset(t *testing.T) { 332 | serv, client := setUp(t, ServerOptions{HardResetOnPurgeQueue: true}) 333 | defer tearDown(t, serv) 334 | 335 | createdQueue := createTestQueue(t, client) 336 | defer tearDownQueue(t, client, createdQueue) 337 | 338 | srv, receivedRequests := startTestServer() 339 | defer srv.Shutdown(context.Background()) 340 | 341 | createTaskRequest := taskspb.CreateTaskRequest{ 342 | Parent: createdQueue.GetName(), 343 | Task: &taskspb.Task{ 344 | Name: createdQueue.GetName() + "/tasks/any-task", 345 | MessageType: &taskspb.Task_HttpRequest{ 346 | HttpRequest: &taskspb.HttpRequest{ 347 | // Use the not_found handler to prove that purge stops any further retries 348 | Url: "http://localhost:5000/not_found", 349 | }, 350 | }, 351 | }, 352 | } 353 | createdTask, err := client.CreateTask(context.Background(), &createTaskRequest) 354 | require.NoError(t, err) 355 | 356 | // Task was created OK, verify that the first HTTP request was sent 357 | _, err = awaitHttpRequest(receivedRequests) 358 | require.NoError(t, err) 359 | 360 | // Now purge the queue 361 | purgeQueueRequest := taskspb.PurgeQueueRequest{ 362 | Name: createdQueue.GetName(), 363 | } 364 | _, err = client.PurgeQueue(context.Background(), &purgeQueueRequest) 365 | require.NoError(t, err) 366 | 367 | // In this mode, purging the queue is synchronous so we should be in the empty state straight away 368 | time.Sleep(1 * time.Second) 369 | assertTaskListIsEmpty(t, client, createdQueue) 370 | assertGetTaskFails(t, grpcCodes.NotFound, client, createdTask.GetName()) 371 | 372 | // And verify that we can now create the task with that name again and it will fire again 373 | _, err = client.CreateTask(context.Background(), &createTaskRequest) 374 | require.NoError(t, err) 375 | 376 | // Verify that it has now sent the request from the new task 377 | receivedRequest, err := awaitHttpRequest(receivedRequests) 378 | require.NotNil(t, receivedRequest, "Request was received") 379 | require.NoError(t, err) 380 | // Note that the execution count is reset to 0 381 | assertHeadersMatch( 382 | t, 383 | map[string]string{ 384 | "X-CloudTasks-TaskExecutionCount": "0", 385 | "X-CloudTasks-TaskRetryCount": "0", 386 | }, 387 | receivedRequest, 388 | ) 389 | } 390 | 391 | func TestListTasks(t *testing.T) { 392 | serv, client := setUp(t, ServerOptions{}) 393 | defer tearDown(t, serv) 394 | 395 | srv, _ := startTestServer() 396 | defer srv.Shutdown(context.Background()) 397 | 398 | createdQueue := createTestQueue(t, client) 399 | 400 | createTaskRequest := taskspb.CreateTaskRequest{ 401 | Parent: createdQueue.GetName(), 402 | Task: &taskspb.Task{ 403 | Name: createdQueue.GetName() + "/tasks/my-test-task", 404 | MessageType: &taskspb.Task_HttpRequest{ 405 | HttpRequest: &taskspb.HttpRequest{ 406 | Url: "http://localhost:5000/success", 407 | }, 408 | }, 409 | }, 410 | } 411 | 412 | createdTask, err := client.CreateTask(context.Background(), &createTaskRequest) 413 | require.NoError(t, err) 414 | 415 | listTasksRequest := taskspb.ListTasksRequest{ 416 | Parent: createdQueue.GetName(), 417 | } 418 | 419 | tasksIterator := client.ListTasks(context.Background(), &listTasksRequest) 420 | assert.NoError(t, err) 421 | 422 | listedTask, err := tasksIterator.Next() 423 | assert.NoError(t, err) 424 | assert.Equal(t, listedTask.GetName(), createdTask.GetName()) 425 | _, err = tasksIterator.Next() 426 | assert.EqualError(t, err, "no more items in iterator") 427 | 428 | deleteQueueRequest := taskspb.DeleteQueueRequest{ 429 | Name: createdQueue.GetName(), 430 | } 431 | err = client.DeleteQueue(context.Background(), &deleteQueueRequest) 432 | require.NoError(t, err) 433 | 434 | tasksIterator = client.ListTasks(context.Background(), &listTasksRequest) 435 | assert.NoError(t, err) 436 | 437 | listedTask, err = tasksIterator.Next() 438 | assertIsGrpcError(t, "^Queue does not exist", grpcCodes.NotFound, err) 439 | assert.Nil(t, listedTask) 440 | } 441 | 442 | func TestSuccessTaskExecution(t *testing.T) { 443 | serv, client := setUp(t, ServerOptions{}) 444 | defer tearDown(t, serv) 445 | 446 | srv, receivedRequests := startTestServer() 447 | defer srv.Shutdown(context.Background()) 448 | 449 | createdQueue := createTestQueue(t, client) 450 | defer tearDownQueue(t, client, createdQueue) 451 | 452 | createTaskRequest := taskspb.CreateTaskRequest{ 453 | Parent: createdQueue.GetName(), 454 | Task: &taskspb.Task{ 455 | Name: createdQueue.GetName() + "/tasks/my-test-task", 456 | MessageType: &taskspb.Task_HttpRequest{ 457 | HttpRequest: &taskspb.HttpRequest{ 458 | Url: "http://localhost:5000/success", 459 | }, 460 | }, 461 | }, 462 | } 463 | createdTask, err := client.CreateTask(context.Background(), &createTaskRequest) 464 | require.NoError(t, err) 465 | 466 | getTaskRequest := taskspb.GetTaskRequest{ 467 | Name: createdTask.GetName(), 468 | } 469 | 470 | receivedRequest, err := awaitHttpRequest(receivedRequests) 471 | require.NoError(t, err) 472 | 473 | gettedTask, err := client.GetTask(context.Background(), &getTaskRequest) 474 | assert.Error(t, err) 475 | assert.Nil(t, gettedTask) 476 | 477 | // Validate that the call was actually made properly 478 | require.NotNil(t, receivedRequest, "Request was received") 479 | 480 | // Simple predictable headers 481 | assertHeadersMatch( 482 | t, 483 | map[string]string{ 484 | "X-CloudTasks-TaskExecutionCount": "0", 485 | "X-CloudTasks-TaskRetryCount": "0", 486 | "X-CloudTasks-TaskName": "my-test-task", 487 | "X-CloudTasks-QueueName": "test", 488 | }, 489 | receivedRequest, 490 | ) 491 | assertIsRecentTimestamp(t, receivedRequest.Header.Get("X-CloudTasks-TaskETA")) 492 | } 493 | 494 | func TestSuccessAppEngineTaskExecution(t *testing.T) { 495 | serv, client := setUp(t, ServerOptions{}) 496 | defer tearDown(t, serv) 497 | 498 | defer os.Unsetenv("APP_ENGINE_EMULATOR_HOST") 499 | os.Setenv("APP_ENGINE_EMULATOR_HOST", "http://localhost:5000") 500 | 501 | srv, receivedRequests := startTestServer() 502 | defer srv.Shutdown(context.Background()) 503 | 504 | createdQueue := createTestQueue(t, client) 505 | defer tearDownQueue(t, client, createdQueue) 506 | 507 | createTaskRequest := taskspb.CreateTaskRequest{ 508 | Parent: createdQueue.GetName(), 509 | Task: &taskspb.Task{ 510 | Name: createdQueue.GetName() + "/tasks/my-test-task", 511 | MessageType: &taskspb.Task_AppEngineHttpRequest{ 512 | AppEngineHttpRequest: &taskspb.AppEngineHttpRequest{ 513 | RelativeUri: "/success", 514 | }, 515 | }, 516 | }, 517 | } 518 | 519 | createdTask, err := client.CreateTask(context.Background(), &createTaskRequest) 520 | require.NoError(t, err) 521 | assert.NotNil(t, createdTask) 522 | 523 | // Wait for it to perform the http request 524 | receivedRequest, err := awaitHttpRequest(receivedRequests) 525 | require.NoError(t, err) 526 | 527 | require.NotNil(t, receivedRequest, "Request was received") 528 | assertHeadersMatch( 529 | t, 530 | map[string]string{ 531 | "X-AppEngine-TaskExecutionCount": "0", 532 | "X-AppEngine-TaskRetryCount": "0", 533 | "X-AppEngine-TaskName": "my-test-task", 534 | "X-AppEngine-QueueName": "test", 535 | }, 536 | receivedRequest, 537 | ) 538 | 539 | assertIsRecentTimestamp(t, receivedRequest.Header.Get("X-AppEngine-TaskETA")) 540 | } 541 | 542 | func TestErrorTaskExecution(t *testing.T) { 543 | serv, client := setUp(t, ServerOptions{}) 544 | defer tearDown(t, serv) 545 | 546 | srv, receivedRequests := startTestServer() 547 | defer srv.Shutdown(context.Background()) 548 | 549 | createdQueue := createTestQueue(t, client) 550 | defer tearDownQueue(t, client, createdQueue) 551 | 552 | createTaskRequest := taskspb.CreateTaskRequest{ 553 | Parent: createdQueue.GetName(), 554 | Task: &taskspb.Task{ 555 | MessageType: &taskspb.Task_HttpRequest{ 556 | HttpRequest: &taskspb.HttpRequest{ 557 | Url: "http://localhost:5000/not_found", 558 | }, 559 | }, 560 | }, 561 | } 562 | 563 | start := time.Now() 564 | 565 | createdTask, err := client.CreateTask(context.Background(), &createTaskRequest) 566 | require.NoError(t, err) 567 | 568 | // With the default retry backoff, we expect 4 calls within the first second: 569 | // at t=0, 0.1, 0.3 (+0.2), 0.7 (+0.4) seconds (plus some buffer) ==> 4 calls 570 | receivedRequest, err := awaitHttpRequest(receivedRequests) 571 | require.NoError(t, err, "Should have received request 1") 572 | assertHeadersMatch( 573 | t, 574 | map[string]string{ 575 | "X-CloudTasks-TaskExecutionCount": "0", 576 | "X-CloudTasks-TaskRetryCount": "0", 577 | }, 578 | receivedRequest, 579 | ) 580 | 581 | receivedRequest, err = awaitHttpRequest(receivedRequests) 582 | require.NoError(t, err, "Should have received request 2") 583 | assertHeadersMatch( 584 | t, 585 | map[string]string{ 586 | "X-CloudTasks-TaskExecutionCount": "1", 587 | "X-CloudTasks-TaskRetryCount": "1", 588 | }, 589 | receivedRequest, 590 | ) 591 | 592 | receivedRequest, err = awaitHttpRequest(receivedRequests) 593 | require.NoError(t, err, "Should have received request 3") 594 | assertHeadersMatch( 595 | t, 596 | map[string]string{ 597 | "X-CloudTasks-TaskExecutionCount": "2", 598 | "X-CloudTasks-TaskRetryCount": "2", 599 | }, 600 | receivedRequest, 601 | ) 602 | 603 | receivedRequest, err = awaitHttpRequest(receivedRequests) 604 | require.NoError(t, err, "Should have received request 4") 605 | assertHeadersMatch( 606 | t, 607 | map[string]string{ 608 | "X-CloudTasks-TaskExecutionCount": "3", 609 | "X-CloudTasks-TaskRetryCount": "3", 610 | }, 611 | receivedRequest, 612 | ) 613 | 614 | expectedCompleteBy := start.Add(700 * time.Millisecond) 615 | assert.WithinDuration( 616 | t, 617 | expectedCompleteBy, 618 | time.Now(), 619 | 200*time.Millisecond, 620 | "4 retries should take roughly 0.7 seconds", 621 | ) 622 | 623 | // Check the state of the task has been updated with the number of dispatches 624 | getTaskRequest := taskspb.GetTaskRequest{ 625 | Name: createdTask.GetName(), 626 | } 627 | gettedTask, err := client.GetTask(context.Background(), &getTaskRequest) 628 | require.NoError(t, err) 629 | assert.EqualValues(t, 4, gettedTask.GetDispatchCount()) 630 | } 631 | 632 | func TestOIDCAuthenticatedTaskExecution(t *testing.T) { 633 | serv, client := setUp(t, ServerOptions{}) 634 | defer tearDown(t, serv) 635 | 636 | OpenIDConfig.IssuerURL = "http://localhost:8980" 637 | 638 | srv, receivedRequests := startTestServer() 639 | defer srv.Shutdown(context.Background()) 640 | 641 | createdQueue := createTestQueue(t, client) 642 | defer tearDownQueue(t, client, createdQueue) 643 | 644 | createTaskRequest := taskspb.CreateTaskRequest{ 645 | Parent: createdQueue.GetName(), 646 | Task: &taskspb.Task{ 647 | MessageType: &taskspb.Task_HttpRequest{ 648 | HttpRequest: &taskspb.HttpRequest{ 649 | Url: "http://localhost:5000/success?foo=bar", 650 | AuthorizationHeader: &taskspb.HttpRequest_OidcToken{ 651 | OidcToken: &taskspb.OidcToken{ 652 | ServiceAccountEmail: "emulator@service.test", 653 | }, 654 | }, 655 | }, 656 | }, 657 | }, 658 | } 659 | _, err := client.CreateTask(context.Background(), &createTaskRequest) 660 | require.NoError(t, err) 661 | 662 | // Wait for it to perform the http request 663 | receivedRequest, err := awaitHttpRequest(receivedRequests) 664 | require.NoError(t, err) 665 | 666 | // Validate that the call was actually made properly 667 | require.NotNil(t, receivedRequest, "Request was received") 668 | authHeader := receivedRequest.Header.Get("Authorization") 669 | assert.NotNil(t, authHeader, "Has Authorization header") 670 | assert.Regexp(t, "^Bearer [a-zA-Z0-9_-]+\\.[a-zA-Z0-9_-]+\\.[a-zA-Z0-9_-]+$", authHeader) 671 | tokenStr := strings.Replace(authHeader, "Bearer ", "", 1) 672 | 673 | // Full token validation is done in the docker smoketests and the oidc internal tests 674 | token, _, err := new(jwt.Parser).ParseUnverified(tokenStr, &OpenIDConnectClaims{}) 675 | require.NoError(t, err) 676 | 677 | claims := token.Claims.(*OpenIDConnectClaims) 678 | assert.Equal(t, "http://localhost:5000/success?foo=bar", claims.Audience, "Specifies audience") 679 | assert.Equal(t, "emulator@service.test", claims.Email, "Specifies email") 680 | assert.Equal(t, "http://localhost:8980", claims.Issuer, "Specifies issuer") 681 | } 682 | 683 | func newQueue(formattedParent, name string) *taskspb.Queue { 684 | return &taskspb.Queue{Name: formatQueueName(formattedParent, name)} 685 | } 686 | 687 | func formatQueueName(formattedParent, name string) string { 688 | return fmt.Sprintf("%s/queues/%s", formattedParent, name) 689 | } 690 | 691 | func formatParent(project, location string) string { 692 | return fmt.Sprintf("projects/%s/locations/%s", project, location) 693 | } 694 | 695 | func assertHeadersMatch(t *testing.T, expectHeaders map[string]string, request *http.Request) { 696 | actualHeaders := make(map[string]string) 697 | 698 | for hdr := range expectHeaders { 699 | actualHeaders[hdr] = request.Header.Get(hdr) 700 | } 701 | 702 | assert.Equal(t, expectHeaders, actualHeaders) 703 | } 704 | 705 | func assertIsRecentTimestamp(t *testing.T, etaString string) { 706 | assert.Regexp(t, "^[0-9]+\\.[0-9]+$", etaString) 707 | float, err := strconv.ParseFloat(etaString, 64) 708 | require.NoError(t, err) 709 | seconds, fraction := math.Modf(float) 710 | etaTime := time.Unix(int64(seconds), int64(fraction*1e9)) 711 | 712 | assert.WithinDuration( 713 | t, 714 | time.Now(), 715 | etaTime, 716 | 2*time.Second, 717 | "task eta should be within last few seconds", 718 | ) 719 | } 720 | 721 | func assertIsGrpcError(t *testing.T, expectMessageRegexp string, expectCode grpcCodes.Code, err error) { 722 | require.Error(t, err, "Should return error") 723 | rsp, ok := grpcStatus.FromError(err) 724 | require.True(t, ok, "Should be grpc error") 725 | assert.Regexp(t, expectMessageRegexp, rsp.Message()) 726 | assert.Equal(t, expectCode, rsp.Code(), "Expected code %s, got %s", expectCode.String(), rsp.Code().String()) 727 | } 728 | 729 | func assertTaskListIsEmpty(t *testing.T, client *Client, queue *taskspb.Queue) { 730 | listTasksRequest := taskspb.ListTasksRequest{ 731 | Parent: queue.GetName(), 732 | } 733 | tasksIterator := client.ListTasks(context.Background(), &listTasksRequest) 734 | firstTask, err := tasksIterator.Next() 735 | assert.Nil(t, firstTask, "Should not get a task in the tasks list") 736 | assert.Same(t, iterator.Done, err, "task iterator should be done") 737 | } 738 | 739 | func assertGetTaskFails(t *testing.T, expectCode grpcCodes.Code, client *Client, name string) { 740 | getTaskRequest := taskspb.GetTaskRequest{ 741 | Name: name, 742 | } 743 | gettedTask, err := client.GetTask(context.Background(), &getTaskRequest) 744 | if assert.Error(t, err) { 745 | rsp, ok := grpcStatus.FromError(err) 746 | assert.True(t, ok, "Should be grpc error") 747 | assert.Equal(t, expectCode, rsp.Code()) 748 | } 749 | assert.Nil(t, gettedTask) 750 | } 751 | 752 | func createTestQueue(t *testing.T, client *Client) *taskspb.Queue { 753 | queue := newQueue(formattedParent, "test") 754 | 755 | createQueueRequest := taskspb.CreateQueueRequest{ 756 | Parent: formattedParent, 757 | Queue: queue, 758 | } 759 | 760 | createdQueue, err := client.CreateQueue(context.Background(), &createQueueRequest) 761 | require.NoError(t, err) 762 | 763 | return createdQueue 764 | } 765 | 766 | func awaitHttpRequest(receivedRequests <-chan *http.Request) (*http.Request, error) { 767 | return awaitHttpRequestWithTimeout(receivedRequests, 1*time.Second) 768 | } 769 | 770 | func awaitHttpRequestWithTimeout(receivedRequests <-chan *http.Request, timeout time.Duration) (*http.Request, error) { 771 | select { 772 | case request := <-receivedRequests: 773 | // Wait a few ticks for the emulator to receive & process the http response (the request 774 | // was written to the channel before we sent the response back) 775 | time.Sleep(20 * time.Millisecond) 776 | return request, nil 777 | case <-time.After(timeout): 778 | return nil, fmt.Errorf("Timed out waiting for HTTP request after %s", timeout) 779 | } 780 | } 781 | 782 | func startTestServer() (*http.Server, <-chan *http.Request) { 783 | mux := http.NewServeMux() 784 | requestChannel := make(chan *http.Request, 1) 785 | mux.HandleFunc("/success", func(w http.ResponseWriter, r *http.Request) { 786 | w.WriteHeader(200) 787 | requestChannel <- r 788 | }) 789 | mux.HandleFunc("/not_found", func(w http.ResponseWriter, r *http.Request) { 790 | w.WriteHeader(404) 791 | requestChannel <- r 792 | }) 793 | 794 | srv := &http.Server{Addr: "localhost:5000", Handler: mux} 795 | 796 | go srv.ListenAndServe() 797 | 798 | return srv, requestChannel 799 | } 800 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/aertje/cloud-tasks-emulator 2 | 3 | go 1.13 4 | 5 | require ( 6 | cloud.google.com/go v0.49.0 7 | github.com/golang-jwt/jwt v3.2.1+incompatible 8 | github.com/golang/protobuf v1.3.2 9 | github.com/stretchr/testify v1.5.1 10 | golang.org/x/net v0.0.0-20200226121028-0de0cce0169b // indirect 11 | google.golang.org/api v0.14.0 12 | google.golang.org/genproto v0.0.0-20191115221424-83cc0476cb11 13 | google.golang.org/grpc v1.25.1 14 | ) 15 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= 2 | cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= 3 | cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU= 4 | cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU= 5 | cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY= 6 | cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc= 7 | cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0= 8 | cloud.google.com/go v0.49.0 h1:CH+lkubJzcPYB1Ggupcq0+k8Ni2ILdG2lYjDIgavDBQ= 9 | cloud.google.com/go v0.49.0/go.mod h1:hGvAdzcWNbyuxS3nWhD7H2cIJxjRRTRLQVB0bdputVY= 10 | cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o= 11 | cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE= 12 | cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I= 13 | cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw= 14 | dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= 15 | github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= 16 | github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= 17 | github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= 18 | github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= 19 | github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= 20 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 21 | github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= 22 | github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= 23 | github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= 24 | github.com/golang-jwt/jwt v3.2.1+incompatible h1:73Z+4BJcrTC+KczS6WvTPvRGOp1WmfEP4Q1lOd9Z/+c= 25 | github.com/golang-jwt/jwt v3.2.1+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= 26 | github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b h1:VKtxabqXZkF25pY9ekfRL6a582T4P37/31XEstQ5p58= 27 | github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= 28 | github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= 29 | github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= 30 | github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y= 31 | github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 32 | github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 33 | github.com/golang/protobuf v1.3.2 h1:6nsPYzhq5kReh6QImI3k5qWzO4PEbvbIW2cwSfR/6xs= 34 | github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 35 | github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= 36 | github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= 37 | github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= 38 | github.com/google/go-cmp v0.3.0 h1:crn/baboCvb5fXaQ0IJ1SGTsTVrWpDsCWC8EGETZijY= 39 | github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= 40 | github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= 41 | github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= 42 | github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= 43 | github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= 44 | github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= 45 | github.com/googleapis/gax-go/v2 v2.0.5 h1:sjZBwGj9Jlw33ImPtvFviGYvseOtDM7hkSKB7+Tv3SM= 46 | github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= 47 | github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= 48 | github.com/hashicorp/golang-lru v0.5.1 h1:0hERBMJE1eitiLkihrMvRVBYAkpHzc/J3QdDN+dAcgU= 49 | github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= 50 | github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= 51 | github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= 52 | github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= 53 | github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= 54 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 55 | github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= 56 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 57 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 58 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 59 | github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= 60 | github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= 61 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 62 | github.com/stretchr/testify v1.5.1 h1:nOGnQDM7FYENwehXlg/kFVnos3rEvtKTjRvOWSzb6H4= 63 | github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= 64 | go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU= 65 | go.opencensus.io v0.22.0 h1:C9hSCOW830chIVkdja34wa6Ky+IzWllkUinR+BtRZd4= 66 | go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8= 67 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 68 | golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 69 | golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 70 | golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= 71 | golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= 72 | golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= 73 | golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek= 74 | golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY= 75 | golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= 76 | golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= 77 | golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= 78 | golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= 79 | golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= 80 | golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= 81 | golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= 82 | golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= 83 | golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= 84 | golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE= 85 | golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o= 86 | golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= 87 | golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY= 88 | golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 89 | golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 90 | golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 91 | golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 92 | golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 93 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 94 | golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 95 | golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 96 | golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= 97 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 98 | golang.org/x/net v0.0.0-20200226121028-0de0cce0169b h1:0mm1VjtFUOIlE1SbDlwjYaDxZVDP2S5ou6y0gSgXHu8= 99 | golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 100 | golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= 101 | golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= 102 | golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45 h1:SVwTIAaPC2U/AvvLNZ2a7OVsmBpC8L5BlwK1whH3hm0= 103 | golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= 104 | golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 105 | golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 106 | golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 107 | golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 108 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 109 | golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 110 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 111 | golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 112 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 113 | golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 114 | golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 115 | golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 116 | golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0 h1:HyfiK1WMnHj5FXFXatD+Qs1A/xC2Run6RzeW1SyHxpc= 117 | golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 118 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 119 | golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 120 | golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs= 121 | golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= 122 | golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= 123 | golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= 124 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 125 | golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 126 | golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= 127 | golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= 128 | golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= 129 | golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= 130 | golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= 131 | golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= 132 | golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= 133 | golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= 134 | golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= 135 | golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= 136 | golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 137 | golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 138 | golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 139 | golang.org/x/tools v0.0.0-20191115202509-3a792d9c32b2/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 140 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 141 | google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE= 142 | google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M= 143 | google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= 144 | google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= 145 | google.golang.org/api v0.14.0 h1:uMf5uLi4eQMRrMKhCplNik4U4H8Z6C1br3zOtAa/aDE= 146 | google.golang.org/api v0.14.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= 147 | google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= 148 | google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= 149 | google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= 150 | google.golang.org/appengine v1.6.1 h1:QzqyMA1tlu6CgqCDUtU9V+ZKhLFT2dkJuANu5QaxI3I= 151 | google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0= 152 | google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= 153 | google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= 154 | google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= 155 | google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= 156 | google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= 157 | google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= 158 | google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= 159 | google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8= 160 | google.golang.org/genproto v0.0.0-20191115194625-c23dd37a84c9/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= 161 | google.golang.org/genproto v0.0.0-20191115221424-83cc0476cb11 h1:51D++eCgOHufw5VfDE9Uzqyyc+OyQIjb9hkYy9LN5Fk= 162 | google.golang.org/genproto v0.0.0-20191115221424-83cc0476cb11/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= 163 | google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= 164 | google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= 165 | google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= 166 | google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= 167 | google.golang.org/grpc v1.25.1 h1:wdKvqQk7IttEw92GoRyKG2IDrUIpgpj6H6m81yfeMW0= 168 | google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= 169 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 170 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= 171 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 172 | gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= 173 | gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw= 174 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 175 | honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= 176 | honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= 177 | honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= 178 | honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= 179 | honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= 180 | rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= 181 | -------------------------------------------------------------------------------- /oidc.cert: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIDTTCCAjWgAwIBAgIUF+fUW7F4N/YDl/Ok6o5ogVKstmgwDQYJKoZIhvcNAQEL 3 | BQAwNTEzMDEGA1UEAwwqb2lkYy5mZWRlcmF0ZWQtc2lnbm9uLmNsb3VkLXRhc2tz 4 | LWVtdWxhdG9yMCAXDTI0MDYyNTIwNTMwNloYDzIwNTExMTExMjA1MzA2WjA1MTMw 5 | MQYDVQQDDCpvaWRjLmZlZGVyYXRlZC1zaWdub24uY2xvdWQtdGFza3MtZW11bGF0 6 | b3IwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC+EePjNlISDurX4F1J 7 | vNKK+2afgRYX89kgXuAAf7iKqbu/bYw37bC+eak0tAb/4t4nkzf2QMda3Z6LccSz 8 | E/FsR54dHMKbbcCBcMZOSO5RReLsY/WdCZZxmfJyQPSOvyRk7vz2lq5yTrUa+dCG 9 | 12XiY/ckIJc8jR0m9uSvvqeL6EyeOkHsbIKESCUgCuyFM0/CEeb7ozRzhHe5W/NB 10 | Sm4TsIRyKw0fW7wczRo6dApdhzjZrc/jKWWkPvSM23TTxK1fLIgjA3gsVP37m8z0 11 | WsESljiT0QCCBTZHsUSh2eTLp7yCs9XZvTPZ5Eu7iOAhM8zPLKphzotxwQ+yf31e 12 | qQXXAgMBAAGjUzBRMB0GA1UdDgQWBBQM0EC+S8XGgwTe9cqXmJFGEaiuzjAfBgNV 13 | HSMEGDAWgBQM0EC+S8XGgwTe9cqXmJFGEaiuzjAPBgNVHRMBAf8EBTADAQH/MA0G 14 | CSqGSIb3DQEBCwUAA4IBAQAf36NX9kdGdBwQppY9lO5ElcxVbGg8RG8ieOFM86eg 15 | 1TJ14I8tKBdR2wPd/N+diRhsnctrVGEXulgItGvZjIKjnoWwVi/sPte5WJcMoR3Y 16 | csiLHBCW9tL6O8NaZuchSoKxlkE/qk2R1QLZtBaGXOjKm1+vIhNzNcdrMKinIfze 17 | OqbKJ0UDNapy59o65Eix8gZoeIc70WICWn3yKHcAah7FIKAVw2yA11QyuYa2xB9h 18 | 1SuHbUN8voSaFaNdF3GIHxktLB7UU1yz6WDxbz1dBmWNK7FxyeeWrtjBKikQDZZu 19 | hxrkYARnT2CySwuUk5IvxzTYeebRoBQzGD8SuTqIvCzZ 20 | -----END CERTIFICATE----- 21 | -------------------------------------------------------------------------------- /oidc.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "crypto/rsa" 5 | "encoding/base64" 6 | "encoding/json" 7 | "fmt" 8 | "io/ioutil" 9 | "log" 10 | "net/http" 11 | "net/url" 12 | "strings" 13 | "time" 14 | 15 | "github.com/golang-jwt/jwt" 16 | ) 17 | 18 | const jwksUriPath = "/jwks" 19 | const certsUriPath = "/certs" 20 | 21 | var OpenIDConfig struct { 22 | IssuerURL string 23 | KeyID string 24 | PrivateKey *rsa.PrivateKey 25 | } 26 | 27 | type OpenIDConnectClaims struct { 28 | Email string `json:"email"` 29 | EmailVerified bool `json:"email_verified"` 30 | jwt.StandardClaims 31 | } 32 | 33 | func init() { 34 | var err error 35 | openIdPrivateKeyStr2, err := ioutil.ReadFile("oidc.key") 36 | if err != nil { 37 | panic(err) 38 | } 39 | OpenIDConfig.PrivateKey, err = jwt.ParseRSAPrivateKeyFromPEM([]byte(openIdPrivateKeyStr2)) 40 | if err != nil { 41 | panic(err) 42 | } 43 | 44 | OpenIDConfig.IssuerURL = "http://cloud-tasks-emulator" 45 | OpenIDConfig.KeyID = "cloudtasks-emulator-test" 46 | } 47 | 48 | func createOIDCToken(serviceAccountEmail string, handlerUrl string, audience string) string { 49 | if audience == "" { 50 | audience = handlerUrl 51 | } 52 | now := time.Now() 53 | claims := OpenIDConnectClaims{ 54 | Email: serviceAccountEmail, 55 | EmailVerified: true, 56 | StandardClaims: jwt.StandardClaims{ 57 | Subject: serviceAccountEmail, 58 | Audience: audience, 59 | Issuer: OpenIDConfig.IssuerURL, 60 | IssuedAt: now.Unix(), 61 | NotBefore: now.Unix(), 62 | ExpiresAt: now.Add(5 * time.Minute).Unix(), 63 | }, 64 | } 65 | 66 | token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims) 67 | token.Header["kid"] = OpenIDConfig.KeyID 68 | 69 | tokenString, err := token.SignedString(OpenIDConfig.PrivateKey) 70 | 71 | if err != nil { 72 | log.Fatalf("Failed to create OIDC token: %v", err) 73 | } 74 | 75 | return tokenString 76 | } 77 | 78 | func openIDConfigHttpHandler(w http.ResponseWriter, r *http.Request) { 79 | config := map[string]interface{}{ 80 | "issuer": OpenIDConfig.IssuerURL, 81 | "jwks_uri": OpenIDConfig.IssuerURL + jwksUriPath, 82 | "id_token_signing_alg_values_supported": []string{"RS256"}, 83 | "claims_supported": []string{"aud", "email", "email_verified", "exp", "iat", "iss", "nbf"}, 84 | } 85 | 86 | respondJSON(w, config, 24*time.Hour) 87 | } 88 | 89 | func respondJSON(w http.ResponseWriter, body interface{}, expiresAfter time.Duration) { 90 | json, err := json.Marshal(body) 91 | if err != nil { 92 | http.Error(w, err.Error(), http.StatusInternalServerError) 93 | return 94 | } 95 | 96 | utc, _ := time.LoadLocation("UTC") 97 | expires := time.Now().In(utc).Add(expiresAfter).Format(http.TimeFormat) 98 | w.Header().Set("Content-Type", "application/json") 99 | w.Header().Set("Cache-Control", "public") 100 | w.Header().Set("Expires", expires) 101 | w.Write(json) 102 | } 103 | 104 | func openIDJWKSHttpHandler(w http.ResponseWriter, r *http.Request) { 105 | publicKey := OpenIDConfig.PrivateKey.Public().(*rsa.PublicKey) 106 | b64Url := base64.URLEncoding.WithPadding(base64.NoPadding) 107 | 108 | config := map[string]interface{}{ 109 | "keys": []map[string]string{ 110 | { 111 | // Ideally we would export the exponent from the key too but frankly 112 | // it's always AQAB in practice and I lost the will to live trying to 113 | // base64url encode a 2-bytes int in go! 114 | "e": "AQAB", 115 | "n": b64Url.EncodeToString(publicKey.N.Bytes()), 116 | "kid": OpenIDConfig.KeyID, 117 | "use": "sig", 118 | "alg": "RSA256", 119 | "kty": "RSA", 120 | }, 121 | }, 122 | } 123 | 124 | respondJSON(w, config, 24*time.Hour) 125 | } 126 | 127 | func openIDCertsHttpHandler(w http.ResponseWriter, r *http.Request) { 128 | var err error 129 | openIdcert, err := ioutil.ReadFile("oidc.cert") 130 | if err != nil { 131 | panic(err) 132 | } 133 | 134 | config := map[string]interface{}{ 135 | OpenIDConfig.KeyID: string(openIdcert), 136 | } 137 | 138 | respondJSON(w, config, 24*time.Hour) 139 | } 140 | 141 | func serveOpenIDConfigurationEndpoint(listenAddr string, listenPort string) *http.Server { 142 | mux := http.NewServeMux() 143 | mux.HandleFunc("/.well-known/openid-configuration", openIDConfigHttpHandler) 144 | mux.HandleFunc(jwksUriPath, openIDJWKSHttpHandler) 145 | mux.HandleFunc(certsUriPath, openIDCertsHttpHandler) 146 | 147 | server := &http.Server{Addr: listenAddr + ":" + listenPort, Handler: mux} 148 | go server.ListenAndServe() 149 | 150 | return server 151 | } 152 | 153 | func configureOpenIdIssuer(issuerUrl string) (*http.Server, error) { 154 | url, err := url.ParseRequestURI(issuerUrl) 155 | if err != nil { 156 | return nil, fmt.Errorf("-openid-issuer must be a base URL e.g. http://any-host:8237") 157 | } 158 | 159 | if url.Scheme != "http" { 160 | return nil, fmt.Errorf("-openid-issuer only supports http protocol") 161 | } 162 | 163 | if url.Path != "" { 164 | return nil, fmt.Errorf("-openid-issuer must not contain a path") 165 | } 166 | 167 | OpenIDConfig.IssuerURL = issuerUrl 168 | 169 | hostParts := strings.Split(url.Host, ":") 170 | var port string 171 | if len(hostParts) > 1 { 172 | port = hostParts[1] 173 | } else { 174 | port = "80" 175 | } 176 | 177 | listenAddr := "0.0.0.0" 178 | fmt.Printf("Issuing OpenID tokens as %v - running endpoint on %v:%v\n", issuerUrl, listenAddr, port) 179 | return serveOpenIDConfigurationEndpoint(listenAddr, port), nil 180 | } 181 | -------------------------------------------------------------------------------- /oidc.key: -------------------------------------------------------------------------------- 1 | -----BEGIN PRIVATE KEY----- 2 | MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQC+EePjNlISDurX 3 | 4F1JvNKK+2afgRYX89kgXuAAf7iKqbu/bYw37bC+eak0tAb/4t4nkzf2QMda3Z6L 4 | ccSzE/FsR54dHMKbbcCBcMZOSO5RReLsY/WdCZZxmfJyQPSOvyRk7vz2lq5yTrUa 5 | +dCG12XiY/ckIJc8jR0m9uSvvqeL6EyeOkHsbIKESCUgCuyFM0/CEeb7ozRzhHe5 6 | W/NBSm4TsIRyKw0fW7wczRo6dApdhzjZrc/jKWWkPvSM23TTxK1fLIgjA3gsVP37 7 | m8z0WsESljiT0QCCBTZHsUSh2eTLp7yCs9XZvTPZ5Eu7iOAhM8zPLKphzotxwQ+y 8 | f31eqQXXAgMBAAECggEAGqcbk7L8UzfwSpFVw49M3txeCaPqWzWAjv9+3dMLJ7ah 9 | cziDXxxfmnYo+hD8oklH6bjFMiznR6CoKNmtQYdcZVitnVt5Fp6PThdoV3X2pULt 10 | jUR/HqRHimqSCt9867919QlmQ5XhpHnQ/5VkXmQ6D0MBVvmS+5S2L86TRumvSPjt 11 | xkcsFryxMwyhHiv3Dx+Vqz0RcSWqBe3AJAEUCDsqXL8OMUOoyDcsD34iRQdV7O5m 12 | sjRzU+od9a5b3dLrY9ufrlkcvrn5SbDZPMfwMXvrH5Y+XpGLHAxsMjqktVBitesV 13 | njHiO57RQePbvtQ8sgxTLFe9sbPT51kI+R2urS7f8QKBgQD8oYxQ4NyjUB5SgQ02 14 | /KA5FLcDlkI6wQK5C2hMEmW7Q2+DRQ70KjoSdLVowkRuAk3MX3RxfRVLTq+Cgkjn 15 | dgW2msjqAqOjpZ6Smw01hjEbMMcVMrwRHjSWwG4vIGMaNQqVpzaAR9Pu48jCHyMX 16 | LnsdGbcD8L1jLcSDuE1ComJFRQKBgQDAmsQMoCBH824Q8PhTj2jH0hra+jZg1Tje 17 | 42br87FtHovpfUjVYalCg4oiQWAqapeIbagjgA5eMqzf2JOFbu7VgebYr15v3Nc1 18 | WJzwMmE7fAojopo1fOYQ1HTddbvf3LTJcnwnAggcGq4ENysFcbfRD+ldTm1RLoLO 19 | Ny7yuwHqawKBgQCmZkYE88eAboI6d7RblpR2ZJWTcEJZbs47Ui81hByr9uQZg8Aw 20 | xSuRAnyG7wahqzTRO8J4ChqfismB3gzlIFDtERDrSie835cOG8Dck3H+5ecLqGpF 21 | oC6laURqGBwOpAc/wW7dmfIXdMPEUTwMxdnjtg9dMhGcpQW+eQOys0ClPQKBgCOP 22 | b4r1NYCTTUsLco3a+HmMLTEo6UlPlMRyL9p4j9WZwjNF0mCzO1DwgFx6vYqXS4sA 23 | 0/5Z8k0qBgj+L55/MNFyvnBbUJBOsd1DkxY19wXIjQavStF9UezhjQImbp2SXj6j 24 | SJDbKywlMOPOW78Rk+KhkXCMvloywCvavGxMYropAoGBAK7ECAs0AZLlUPkXuYmL 25 | U1GzFKUl3xDgczMSof5nPJCHcUm0fl02883IhEFEBvzqo5fu8pIzKGKpVwrNud7E 26 | /cLTJUkejD5e0h4V5ykcTUs9yDrxopQ54NW0lj7Se00e5MAUH0SRwbjbFdzQ3AYd 27 | FSkhEKj2YXWlriv3hyPIC8Aq 28 | -----END PRIVATE KEY----- -------------------------------------------------------------------------------- /oidc_internal_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "crypto/rsa" 6 | "crypto/x509" 7 | "encoding/json" 8 | "encoding/pem" 9 | "fmt" 10 | "net/http" 11 | "net/http/httptest" 12 | "os" 13 | "testing" 14 | "time" 15 | 16 | "github.com/golang-jwt/jwt" 17 | "github.com/stretchr/testify/assert" 18 | "github.com/stretchr/testify/require" 19 | ) 20 | 21 | func TestCreateOIDCTokenSetsCorrectData(t *testing.T) { 22 | tokenStr := createOIDCToken("foobar@service.com", "http://my.service/foo?bar=v", "") 23 | parser := new(jwt.Parser) 24 | token, _, err := parser.ParseUnverified(tokenStr, &OpenIDConnectClaims{}) 25 | require.NoError(t, err) 26 | assert.Equal(t, "RS256", token.Header["alg"], "Uses RS256") 27 | assert.Equal(t, OpenIDConfig.KeyID, token.Header["kid"], "Specifies kid") 28 | 29 | claims := token.Claims.(*OpenIDConnectClaims) 30 | 31 | assert.Equal(t, "http://my.service/foo?bar=v", claims.Audience, "Specifies audience") 32 | assert.Equal(t, OpenIDConfig.IssuerURL, claims.Issuer, "Specifies issuer") 33 | assert.Equal(t, "foobar@service.com", claims.Email, "Specifies email") 34 | assert.Equal(t, "foobar@service.com", claims.Subject, "Specifies subject") 35 | assert.True(t, claims.EmailVerified, "Specifies email") 36 | assertRoughTimestamp(t, 0*time.Second, claims.IssuedAt, "Issued now") 37 | assertRoughTimestamp(t, 0*time.Second, claims.NotBefore, "Not before now") 38 | assertRoughTimestamp(t, 5*time.Minute, claims.ExpiresAt, "Expires in 5 mins") 39 | } 40 | 41 | func TestCreateOIDCTokenWithCustomAudienceSetsCorrectData(t *testing.T) { 42 | tokenStr := createOIDCToken("foobar@service.com", "http://my.service/foo?bar=v", "http://my.api") 43 | parser := new(jwt.Parser) 44 | token, _, err := parser.ParseUnverified(tokenStr, &OpenIDConnectClaims{}) 45 | require.NoError(t, err) 46 | assert.Equal(t, "RS256", token.Header["alg"], "Uses RS256") 47 | assert.Equal(t, OpenIDConfig.KeyID, token.Header["kid"], "Specifies kid") 48 | 49 | claims := token.Claims.(*OpenIDConnectClaims) 50 | 51 | assert.Equal(t, "http://my.api", claims.Audience, "Specifies audience") 52 | assert.Equal(t, OpenIDConfig.IssuerURL, claims.Issuer, "Specifies issuer") 53 | assert.Equal(t, "foobar@service.com", claims.Email, "Specifies email") 54 | assert.True(t, claims.EmailVerified, "Specifies email") 55 | assertRoughTimestamp(t, 0*time.Second, claims.IssuedAt, "Issued now") 56 | assertRoughTimestamp(t, 0*time.Second, claims.NotBefore, "Not before now") 57 | assertRoughTimestamp(t, 5*time.Minute, claims.ExpiresAt, "Expires in 5 mins") 58 | } 59 | 60 | func TestCreateOIDCTokenSignatureIsValidAgainstKey(t *testing.T) { 61 | // Sanity check that the token is valid if we have the private key in go format 62 | tokenStr := createOIDCToken("foobar@service.com", "http://any.service/foo", "") 63 | _, err := new(jwt.Parser).ParseWithClaims( 64 | tokenStr, 65 | &OpenIDConnectClaims{}, 66 | func(token *jwt.Token) (interface{}, error) { 67 | // Can safely skip kid checking as we check it in the data test above 68 | assert.IsType(t, jwt.SigningMethodRS256, token.Method) 69 | return OpenIDConfig.PrivateKey.Public(), nil 70 | }, 71 | ) 72 | require.NoError(t, err) 73 | } 74 | 75 | func TestOpenIdConfigHttpHandler(t *testing.T) { 76 | OpenIDConfig.IssuerURL = "http://foo.bar:8080" 77 | 78 | resp := performRequest("GET", "/.well-known/openid-configuration", openIDConfigHttpHandler) 79 | 80 | assert.Equal(t, http.StatusOK, resp.Code) 81 | body := parseJSONResponse(t, resp) 82 | 83 | assert.Equal(t, "http://foo.bar:8080", body["issuer"], "Provides issuer") 84 | assert.Equal(t, "http://foo.bar:8080/jwks", body["jwks_uri"], "Provides jwks") 85 | assert.ElementsMatch(t, []string{"RS256"}, body["id_token_signing_alg_values_supported"]) 86 | } 87 | 88 | func TestOpenIdJWKSHttpHandler(t *testing.T) { 89 | OpenIDConfig.KeyID = "any-key-id" 90 | 91 | resp := performRequest("GET", "/jwks", openIDJWKSHttpHandler) 92 | 93 | assert.Equal(t, http.StatusOK, resp.Code) 94 | 95 | expires, err := time.Parse(http.TimeFormat, resp.Result().Header.Get("Expires")) 96 | require.NoError(t, err) 97 | assertRoughTimestamp(t, 24*time.Hour, expires.Unix(), "Expect future expires") 98 | 99 | // Verifies against the expected public key based on the private key const 100 | // As this seems the easiest way to assert the JSON 101 | assert.JSONEq( 102 | t, 103 | ` 104 | { 105 | "keys": [ 106 | { 107 | "e": "AQAB", 108 | "n": "vhHj4zZSEg7q1-BdSbzSivtmn4EWF_PZIF7gAH-4iqm7v22MN-2wvnmpNLQG_-LeJ5M39kDHWt2ei3HEsxPxbEeeHRzCm23AgXDGTkjuUUXi7GP1nQmWcZnyckD0jr8kZO789pauck61GvnQhtdl4mP3JCCXPI0dJvbkr76ni-hMnjpB7GyChEglIArshTNPwhHm-6M0c4R3uVvzQUpuE7CEcisNH1u8HM0aOnQKXYc42a3P4yllpD70jNt008StXyyIIwN4LFT9-5vM9FrBEpY4k9EAggU2R7FEodnky6e8grPV2b0z2eRLu4jgITPMzyyqYc6LccEPsn99XqkF1w", 109 | "kid": "any-key-id", 110 | "use": "sig", 111 | "alg": "RSA256", 112 | "kty": "RSA" 113 | } 114 | ] 115 | } 116 | `, 117 | resp.Body.String(), 118 | ) 119 | } 120 | 121 | func TestOpenIdCertsHttpHandler(t *testing.T) { 122 | OpenIDConfig.KeyID = "any-key-id" 123 | 124 | resp := performRequest("GET", "/certs", openIDCertsHttpHandler) 125 | 126 | assert.Equal(t, http.StatusOK, resp.Code) 127 | 128 | var err error 129 | 130 | expires, err := time.Parse(http.TimeFormat, resp.Result().Header.Get("Expires")) 131 | require.NoError(t, err) 132 | assertRoughTimestamp(t, 24*time.Hour, expires.Unix(), "Expect future expires") 133 | 134 | openIdcert, err := os.ReadFile("oidc.cert") 135 | require.NoError(t, err) 136 | 137 | certs := map[string]interface{}{ 138 | OpenIDConfig.KeyID: string(openIdcert), 139 | } 140 | 141 | certsJSON, err := json.Marshal(certs) 142 | require.NoError(t, err) 143 | assert.JSONEq(t, string(certsJSON), resp.Body.String()) 144 | } 145 | 146 | func TestValidateOIDCTokenWithCertPem(t *testing.T) { 147 | var err error 148 | 149 | tokenStr := createOIDCToken("foobar@service.com", "http://my.service/foo?bar=v", "http://my.api") 150 | 151 | openIdcert, err := os.ReadFile("oidc.cert") 152 | require.NoError(t, err) 153 | 154 | parsePEMCert := func(pemCert []byte) (*rsa.PublicKey, error) { 155 | block, _ := pem.Decode(pemCert) 156 | if block == nil || block.Type != "CERTIFICATE" { 157 | return nil, fmt.Errorf("failed to decode PEM block containing certificate") 158 | } 159 | 160 | cert, err := x509.ParseCertificate(block.Bytes) 161 | if err != nil { 162 | return nil, fmt.Errorf("failed to parse certificate: %v", err) 163 | } 164 | 165 | rsaPub, ok := cert.PublicKey.(*rsa.PublicKey) 166 | if !ok { 167 | return nil, fmt.Errorf("not an RSA public key") 168 | } 169 | 170 | return rsaPub, nil 171 | } 172 | 173 | // Load the public key 174 | pubKey, err := parsePEMCert(openIdcert) 175 | require.NoError(t, err) 176 | 177 | // Parse the token 178 | parser := new(jwt.Parser) 179 | _, _, err = parser.ParseUnverified(tokenStr, &jwt.MapClaims{}) 180 | require.NoError(t, err) 181 | 182 | // Verify the token 183 | token, err := jwt.Parse(tokenStr, func(token *jwt.Token) (interface{}, error) { 184 | // Validate the algorithm - this is optional but recommended 185 | if _, ok := token.Method.(*jwt.SigningMethodRSA); !ok { 186 | return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) 187 | } 188 | return pubKey, nil 189 | }) 190 | require.NoError(t, err) 191 | 192 | assert.True(t, token.Valid, "Token is valid") 193 | } 194 | 195 | func TestConfigureOpenIdIssuerRejectsInvalidUrl(t *testing.T) { 196 | var err error 197 | _, err = configureOpenIdIssuer("junk") 198 | assert.Error(t, err, "-openid-issuer must be a base URL e.g. http://any-host:8237") 199 | 200 | _, err = configureOpenIdIssuer("https://foo:8900") 201 | assert.Error(t, err, "-openid-issuer only supports http protocol") 202 | 203 | _, err = configureOpenIdIssuer("http://foo:8900/deep") 204 | assert.Error(t, err, "-openid-issuer must not contain a path") 205 | } 206 | 207 | func TestConfigureOpenIdIssuerSetsConfigAndRunsServer(t *testing.T) { 208 | srv, err := configureOpenIdIssuer("http://my-external.route.to.me:8200") 209 | require.NoError(t, err) 210 | assert.Equal(t, "http://my-external.route.to.me:8200", OpenIDConfig.IssuerURL) 211 | assert.Equal(t, "0.0.0.0:8200", srv.Addr) 212 | srv.Shutdown(context.Background()) 213 | } 214 | 215 | func TestConfigureOpenIdIssuerSupportsPort80(t *testing.T) { 216 | srv, err := configureOpenIdIssuer("http://my-external.route.to.me") 217 | require.NoError(t, err) 218 | assert.Equal(t, "http://my-external.route.to.me", OpenIDConfig.IssuerURL) 219 | assert.Equal(t, "0.0.0.0:80", srv.Addr) 220 | srv.Shutdown(context.Background()) 221 | } 222 | 223 | func assertRoughTimestamp(t *testing.T, expectOffset time.Duration, timestamp int64, msg string) { 224 | // Ensures that the timestamp is roughly correct, and that it is *less* than 225 | // the expected value. So e.g. a timestamp that should be 5 minutes in the 226 | // future might be slightly under due to the clock ticking since creation, 227 | // but it should not be over. 228 | actual := time.Unix(timestamp, 0) 229 | expect := time.Now().Add(expectOffset) 230 | assert.WithinDuration(t, expect, actual, 1*time.Second, msg) 231 | assert.LessOrEqual(t, expect.Unix(), actual.Unix(), msg+"(must be less than expected)") 232 | } 233 | 234 | func parseJSONResponse(t *testing.T, resp *httptest.ResponseRecorder) map[string]interface{} { 235 | assert.Equal(t, "application/json", resp.Result().Header.Get("Content-Type")) 236 | 237 | var body map[string]interface{} 238 | err := json.Unmarshal(resp.Body.Bytes(), &body) 239 | require.NoError(t, err) 240 | return body 241 | } 242 | 243 | func performRequest(method string, url string, handler func(w http.ResponseWriter, r *http.Request)) *httptest.ResponseRecorder { 244 | req, err := http.NewRequest(method, url, nil) 245 | if err != nil { 246 | panic(err) 247 | } 248 | 249 | resp := httptest.NewRecorder() 250 | http.HandlerFunc(handler).ServeHTTP(resp, req) 251 | 252 | return resp 253 | } 254 | -------------------------------------------------------------------------------- /protohelpers.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "net/http" 5 | 6 | tasks "google.golang.org/genproto/googleapis/cloud/tasks/v2" 7 | rpccode "google.golang.org/genproto/googleapis/rpc/code" 8 | ) 9 | 10 | func toHTTPMethod(taskMethod tasks.HttpMethod) string { 11 | switch taskMethod { 12 | case tasks.HttpMethod_GET: 13 | return http.MethodGet 14 | case tasks.HttpMethod_POST: 15 | return http.MethodPost 16 | case tasks.HttpMethod_DELETE: 17 | return http.MethodDelete 18 | case tasks.HttpMethod_HEAD: 19 | return http.MethodHead 20 | case tasks.HttpMethod_OPTIONS: 21 | return http.MethodOptions 22 | case tasks.HttpMethod_PATCH: 23 | return http.MethodPatch 24 | case tasks.HttpMethod_PUT: 25 | return http.MethodPut 26 | default: 27 | panic("Unsupported http method") 28 | } 29 | } 30 | 31 | func toRPCStatusCode(statusCode int) int32 { 32 | switch statusCode { 33 | case 200: 34 | return int32(rpccode.Code_OK) 35 | case 400: 36 | // TODO: or rpccode.Code_FAILED_PRECONDITION 37 | // TODO: or rpcCode.Code_OUT_OF_RANGE 38 | return int32(rpccode.Code_INVALID_ARGUMENT) 39 | case 401: 40 | return int32(rpccode.Code_UNAUTHENTICATED) 41 | case 403: 42 | return int32(rpccode.Code_PERMISSION_DENIED) 43 | case 404: 44 | return int32(rpccode.Code_NOT_FOUND) 45 | case 409: 46 | // TODO: or rpccde.Code_ABORTED 47 | return int32(rpccode.Code_ALREADY_EXISTS) 48 | case 429: 49 | return int32(rpccode.Code_RESOURCE_EXHAUSTED) 50 | case 499: 51 | return int32(rpccode.Code_CANCELLED) 52 | case 500: 53 | //TODO: or rpccode.Code_DATA_LOSS 54 | return int32(rpccode.Code_INTERNAL) 55 | case 501: 56 | return int32(rpccode.Code_UNIMPLEMENTED) 57 | case 503: 58 | return int32(rpccode.Code_UNAVAILABLE) 59 | case 504: 60 | return int32(rpccode.Code_DEADLINE_EXCEEDED) 61 | default: 62 | return int32(rpccode.Code_UNKNOWN) 63 | } 64 | } 65 | 66 | func toCodeName(rpcCode int32) string { 67 | return rpccode.Code_name[rpcCode] 68 | } 69 | -------------------------------------------------------------------------------- /queue.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log" 5 | "sync" 6 | "time" 7 | 8 | "github.com/golang/protobuf/proto" 9 | pduration "github.com/golang/protobuf/ptypes/duration" 10 | 11 | tasks "google.golang.org/genproto/googleapis/cloud/tasks/v2" 12 | ) 13 | 14 | // Queue holds all internals for a task queue 15 | type Queue struct { 16 | name string 17 | 18 | state *tasks.Queue 19 | 20 | fire chan *Task 21 | 22 | work chan *Task 23 | 24 | ts map[string]*Task 25 | 26 | tsMux sync.Mutex 27 | 28 | tokenBucket chan bool 29 | 30 | maxDispatchesPerSecond float64 31 | 32 | cancelTokenGenerator chan bool 33 | 34 | cancelDispatcher chan bool 35 | 36 | cancelWorkers chan bool 37 | 38 | cancelled bool 39 | 40 | paused bool 41 | 42 | onTaskDone func(task *Task) 43 | } 44 | 45 | // NewQueue creates a new task queue 46 | func NewQueue(name string, state *tasks.Queue, onTaskDone func(task *Task)) (*Queue, *tasks.Queue) { 47 | setInitialQueueState(state) 48 | 49 | queue := &Queue{ 50 | name: name, 51 | state: state, 52 | fire: make(chan *Task), 53 | work: make(chan *Task), 54 | ts: make(map[string]*Task), 55 | onTaskDone: onTaskDone, 56 | tokenBucket: make(chan bool, state.GetRateLimits().GetMaxBurstSize()), 57 | maxDispatchesPerSecond: state.GetRateLimits().GetMaxDispatchesPerSecond(), 58 | cancelTokenGenerator: make(chan bool, 1), 59 | cancelDispatcher: make(chan bool, 1), 60 | cancelWorkers: make(chan bool, 1), 61 | } 62 | // Fill the token bucket 63 | for i := 0; i < int(state.GetRateLimits().GetMaxBurstSize()); i++ { 64 | queue.tokenBucket <- true 65 | } 66 | 67 | return queue, state 68 | } 69 | 70 | func (queue *Queue) setTask(taskName string, task *Task) { 71 | queue.tsMux.Lock() 72 | defer queue.tsMux.Unlock() 73 | queue.ts[taskName] = task 74 | } 75 | 76 | func (queue *Queue) removeTask(taskName string) { 77 | queue.setTask(taskName, nil) 78 | } 79 | 80 | func setInitialQueueState(queueState *tasks.Queue) { 81 | if queueState.GetRateLimits() == nil { 82 | queueState.RateLimits = &tasks.RateLimits{} 83 | } 84 | if queueState.GetRateLimits().GetMaxDispatchesPerSecond() == 0 { 85 | queueState.RateLimits.MaxDispatchesPerSecond = 500.0 86 | } 87 | if queueState.GetRateLimits().GetMaxBurstSize() == 0 { 88 | queueState.RateLimits.MaxBurstSize = 100 89 | } 90 | if queueState.GetRateLimits().GetMaxConcurrentDispatches() == 0 { 91 | queueState.RateLimits.MaxConcurrentDispatches = 1000 92 | } 93 | 94 | if queueState.GetRetryConfig() == nil { 95 | queueState.RetryConfig = &tasks.RetryConfig{} 96 | } 97 | if queueState.GetRetryConfig().GetMaxAttempts() == 0 { 98 | queueState.RetryConfig.MaxAttempts = 100 99 | } 100 | if queueState.GetRetryConfig().GetMaxDoublings() == 0 { 101 | queueState.RetryConfig.MaxDoublings = 16 102 | } 103 | if queueState.GetRetryConfig().GetMinBackoff() == nil { 104 | queueState.RetryConfig.MinBackoff = &pduration.Duration{ 105 | Nanos: 100000000, 106 | } 107 | } 108 | if queueState.GetRetryConfig().GetMaxBackoff() == nil { 109 | queueState.RetryConfig.MaxBackoff = &pduration.Duration{ 110 | Seconds: 3600, 111 | } 112 | } 113 | 114 | queueState.State = tasks.Queue_RUNNING 115 | } 116 | 117 | func (queue *Queue) runWorkers() { 118 | for i := 0; i < int(queue.state.GetRateLimits().GetMaxConcurrentDispatches()); i++ { 119 | go queue.runWorker() 120 | } 121 | } 122 | 123 | func (queue *Queue) runWorker() { 124 | for { 125 | select { 126 | case task := <-queue.work: 127 | task.Attempt() 128 | case <-queue.cancelWorkers: 129 | // Forward for next worker 130 | queue.cancelWorkers <- true 131 | return 132 | } 133 | } 134 | } 135 | 136 | func (queue *Queue) runTokenGenerator() { 137 | period := time.Second / time.Duration(queue.maxDispatchesPerSecond) 138 | // Use Timer with Reset() in place of time.Ticker as the latter was causing high CPU usage in Docker 139 | t := time.NewTimer(period) 140 | 141 | for { 142 | select { 143 | case <-t.C: 144 | select { 145 | case queue.tokenBucket <- true: 146 | // Added token 147 | t.Reset(period) 148 | case <-queue.cancelTokenGenerator: 149 | return 150 | } 151 | case <-queue.cancelTokenGenerator: 152 | if !t.Stop() { 153 | <-t.C 154 | } 155 | return 156 | } 157 | } 158 | } 159 | 160 | func (queue *Queue) runDispatcher() { 161 | for { 162 | select { 163 | // Consume a token 164 | case <-queue.tokenBucket: 165 | select { 166 | // Wait for task 167 | case task := <-queue.fire: 168 | // Pass on to workers 169 | queue.work <- task 170 | case <-queue.cancelDispatcher: 171 | return 172 | } 173 | case <-queue.cancelDispatcher: 174 | return 175 | } 176 | } 177 | } 178 | 179 | // Run starts the queue (workers, token generator and dispatcher) 180 | func (queue *Queue) Run() { 181 | go queue.runWorkers() 182 | go queue.runTokenGenerator() 183 | go queue.runDispatcher() 184 | } 185 | 186 | // NewTask creates a new task on the queue 187 | func (queue *Queue) NewTask(newTaskState *tasks.Task) (*Task, *tasks.Task) { 188 | task := NewTask(queue, newTaskState, func(task *Task) { 189 | queue.removeTask(task.state.GetName()) 190 | queue.onTaskDone(task) 191 | }) 192 | 193 | taskState := proto.Clone(task.state).(*tasks.Task) 194 | 195 | queue.setTask(taskState.GetName(), task) 196 | 197 | task.Schedule() 198 | 199 | return task, taskState 200 | } 201 | 202 | // Delete stops, purges and removes the queue 203 | func (queue *Queue) Delete() { 204 | if !queue.cancelled { 205 | queue.cancelled = true 206 | log.Println("Stopping queue") 207 | queue.cancelTokenGenerator <- true 208 | queue.cancelDispatcher <- true 209 | queue.cancelWorkers <- true 210 | 211 | queue.Purge() 212 | } 213 | } 214 | 215 | // Purge purges all tasks from the queue 216 | // - Normally this is a fire-and-forget operation, but it returns a WaitGroup to allow HardReset to wait for completion 217 | func (queue *Queue) Purge() *sync.WaitGroup { 218 | waitGroup := sync.WaitGroup{} 219 | waitGroup.Add(1) 220 | 221 | go func() { 222 | defer waitGroup.Done() 223 | 224 | queue.tsMux.Lock() 225 | defer queue.tsMux.Unlock() 226 | 227 | for _, task := range queue.ts { 228 | // Avoid task firing 229 | if task != nil { 230 | task.Delete() 231 | } 232 | } 233 | }() 234 | 235 | return &waitGroup 236 | } 237 | 238 | // Goes beyond `Purge` behaviour to synchronously delete all tasks and their name handles 239 | func (queue *Queue) HardReset(s *Server) { 240 | waitGroup := queue.Purge() 241 | waitGroup.Wait() 242 | 243 | // This is still a bit awkward - we can't *guarantee* the task is fully deleted even after the WaitGroup because: 244 | // - Purge() calls task.Delete() 245 | // - task.Delete() writes to a buffered `cancel` channel 246 | // - task.Schedule() reads from that buffered channel in a separate goroutine 247 | // - When that goroutine sees the task is cancelled, it sets the task value to nil in the tasks map 248 | // 249 | // We need to be certain that we only remove the task from map *after* that completes, otherwise the task name will 250 | // be reinserted with the nil value. At the moment the only easy way I can think of is to sleep for a very short 251 | // period to allow the tasks' internal goroutines to fire first. 252 | time.Sleep(10 * time.Millisecond) 253 | 254 | queue.tsMux.Lock() 255 | defer queue.tsMux.Unlock() 256 | for taskName, task := range queue.ts { 257 | if task != nil { 258 | // The naive "sleep till it deletes" approach described above is too naive... 259 | panic("Expected task to be deleted by now!") 260 | } 261 | 262 | delete(queue.ts, taskName) 263 | s.hardDeleteTask(taskName) 264 | } 265 | } 266 | 267 | // Pause pauses the queue 268 | func (queue *Queue) Pause() { 269 | if !queue.paused { 270 | queue.paused = true 271 | queue.state.State = tasks.Queue_PAUSED 272 | 273 | queue.cancelDispatcher <- true 274 | queue.cancelWorkers <- true 275 | } 276 | } 277 | 278 | // Resume resumes a paused queue 279 | func (queue *Queue) Resume() { 280 | if queue.paused { 281 | queue.paused = false 282 | queue.state.State = tasks.Queue_RUNNING 283 | 284 | go queue.runDispatcher() 285 | go queue.runWorkers() 286 | } 287 | } 288 | -------------------------------------------------------------------------------- /readme-owncert.md: -------------------------------------------------------------------------------- 1 | You can create you own private key and self-signed certificate using OpenSSL to use in you own emulator. Here's how you can do it: 2 | 3 | 1. **Generate a Private Key:** 4 | Use OpenSSL to generate a new private key file. Here’s how you can do it: 5 | 6 | ```bash 7 | openssl genpkey -algorithm RSA -out oidc.key 8 | ``` 9 | 10 | This command generates an RSA private key and saves it to `oidc.key`. 11 | 12 | 2. **Generate a Self-Signed Certificate:** 13 | Once you have the private key, you can generate a self-signed certificate using the `req` command, as you've shown: 14 | 15 | ```bash 16 | openssl req -new -x509 -key oidc.key -out oidc.cert -days 10000 -subj "/CN=oidc.federated-signon.cloud-tasks-emulator" -config "path/to/openssl.cnf" 17 | ``` 18 | 19 | - `-new`: Generate a new certificate request. 20 | - `-x509`: Create a self-signed certificate. 21 | - `-key oidc.key`: Use the private key file `oidc.key`. 22 | - `-out oidc.cert`: Output the certificate to `oidc.cert`. 23 | - `-days 10000`: Validity of the certificate in days. 24 | - `-subj "/CN=oidc.federated-signon.cloud-tasks-emulator"`: Subject of the certificate. Adjust the Common Name (CN) as needed. 25 | - `-config "path/to/openssl.cnf"`: Path to your OpenSSL configuration file. Adjust this path according to your setup. 26 | 27 | Make sure to replace `"path/to/openssl.cnf"` with the actual path to your OpenSSL configuration file on your system. This configuration file typically contains settings like default certificate extensions and other parameters relevant to certificate generation. Adjust the CN (Common Name) parameter (`/CN=`) to match your specific domain or server name. -------------------------------------------------------------------------------- /readme.MD: -------------------------------------------------------------------------------- 1 | # Cloud tasks emulator 2 | 3 | ## Introduction 4 | This emulator tries to emulate the behaviour of Google Cloud Tasks. 5 | As of this writing, Google does not provide a Cloud Tasks emulator, which makes local development and testing a bit tricky. This project aims to help you out until they do release an official emulator. 6 | 7 | This project is not associated with Google. 8 | 9 | ## Status and features 10 | This project uses the v2 version of cloud tasks, to support both http and appengine requests. 11 | 12 | It supports the following: 13 | - Targeting normal http and appengine endpoints. 14 | - Rate limiting and honors rate limiting configuration (max burst, max concurrent, and dispatch rate) 15 | - Retries and honors retry configuration (max attempts, max doublings, backoff) 16 | - Self-signed, verifiable, OIDC authentication tokens for HTTP requests 17 | 18 | It also has a few outstanding things to address; 19 | - Updating of queues 20 | - Use of context / cleaning up of the signaling 21 | - Certain headers and response formats. 22 | 23 | ## Running the emulator 24 | Fire it up; you can specify host and port (defaults to localhost:8123): 25 | ```sh 26 | go run ./ -host localhost -port 8000 27 | ``` 28 | 29 | You can also optionally specify one or more queues to create automatically on startup: 30 | 31 | ```sh 32 | go run ./ -host localhost \ 33 | -port 8000 \ 34 | -queue projects/dev/locations/here/queues/firstq \ 35 | -queue projects/dev/locations/here/queues/anotherq 36 | ``` 37 | 38 | Alternatively, you can define environment variables and then run the shell script `./emulator_from_env.sh` to start the emulator. The following environment variables are supported: 39 | 40 | ```sh 41 | export PORT=8124 42 | export HOST=localhost 43 | export HARD_RESET_ON_PURGE=true 44 | export INITIAL_QUEUES=projects/dev/locations/here/queues/1,projects/dev/locations/here/queues/2 45 | export OPENID_ISSUER=http://localhost:8080 46 | 47 | ./emulator_from_env.sh 48 | ``` 49 | 50 | Once running, you connect to it using the standard google cloud tasks GRPC libraries. 51 | 52 | ### Docker 53 | You can use the dockerfile if you don't want to install a Go build environment: 54 | ```sh 55 | docker build ./ -t tasks_emulator 56 | docker run -p 8123:8123 tasks_emulator -host 0.0.0.0 -port 8123 -queue projects/dev/locations/here/queues/anotherq 57 | ``` 58 | 59 | ### Docker image 60 | Or even easier - pull and run it directly from GitHub Container Registry: 61 | ```sh 62 | docker run ghcr.io/aertje/cloud-tasks-emulator:latest 63 | ``` 64 | 65 | ### Docker Compose 66 | If you are planning on using docker-compose the above configuration translates to : 67 | ```yml 68 | gcloud-tasks-emulator: 69 | image: ghcr.io/aertje/cloud-tasks-emulator:latest 70 | command: -host 0.0.0.0 -port 8123 -queue "projects/dev/locations/here/queues/anotherq" 71 | ports: 72 | - "${TASKS_PORT:-8123}:8123" 73 | environment: 74 | APP_ENGINE_EMULATOR_HOST: http://localhost:8080 75 | ``` 76 | 77 | 78 | ## App Engine 79 | If you want to use it to make calls to a local [App Engine emulator](https://cloud.google.com/appengine/docs/standard/python3/testing-and-deploying-your-app#local-dev-server) instance, you'll need to set the appropriate environment variable, e.g.: 80 | ```sh 81 | export APP_ENGINE_EMULATOR_HOST=http://localhost:8080 82 | ``` 83 | 84 | ### Targeting services 85 | Since the App Engine emulator runs services on individual localhost ports (e.g. `default` on `http://localhost:8080`, `worker` on `http://localhost:8081`), and the task emulator targets subdomains when specified (e.g. `http://worker.localhost:8080`), you can use one of these workarounds: 86 | - Use a proxy that will map the subdomain to the right destination, and set the `APP_ENGINE_EMULATOR_HOST` to match the proxy. A straightforward way is to leverage the docker-compose networking to route the task emulator traffic through an nginx instance and pass the traffic on to the container(s) running the AppEngine service(s). I.e. target `http://worker.my-proxy`. 87 | - Update your code to use `relative_uri` instead of the `service`, and include a `dispatch.yaml` in your AppEngine configuration. I.e. target `http://localhost:8080/worker`. 88 | 89 | The following methods will also work, but are not recommended as they will likely result in different code for your local testing and cloud deployment: 90 | - If you are only targeting one App Engine service with the cloud tasks emulator, update the `APP_ENGINE_EMULATOR_HOST` to match that service. I.e. target `http://localhost:8081`. 91 | - Use `http_request` instead of `app_engine_http_request` and simply specify the target URL. I.e. target `http://localhost:8081`. 92 | 93 | ## OIDC authentication 94 | The emulator supports [OIDC token](https://cloud.google.com/tasks/docs/creating-http-target-tasks#token) 95 | authentication for HTTP target tasks. Tokens will be issued and signed by the 96 | emulator's (insecure) private key. The emulator will accept, and issue tokens 97 | for, **any** ServiceAccountEmail provided by the client. 98 | 99 | By default, the JWT `iss` (issuer) field is `http://cloud-tasks-emulator`. 100 | 101 | Optionally, the emulator can host an HTTP OIDC discovery endpoint. This allows 102 | your application to verify tokens at runtime with the full online flow. 103 | To enable this, specify an issuer value at startup: 104 | 105 | ```sh 106 | go run ./ -openid-issuer http://localhost:8980 107 | ``` 108 | 109 | With this flag: 110 | 111 | * JWTs will have an `iss` field of `http://localhost:8980` 112 | * The [discovery document](https://developers.google.com/identity/protocols/oauth2/openid-connect#discovery) 113 | will be available at `http://localhost:8980/.well-known/openid-configuration` 114 | * The emulator's public key(s) (in JWK format) will be available at 115 | `http://localhost:8980/jwks` 116 | * The emulator's public key(s) (in PEM format) will be available at 117 | `http://localhost:8980/certs` 118 | 119 | The `-openid-issuer` URL can be any `http://hostname:port` value that your 120 | application code can route to. The endpoint listens on `0.0.0.0` for easy 121 | use in docker / k8s environments. 122 | 123 | You can, of course, export the content of the `/jwks` url if you prefer to 124 | hardcode the public keys in your application. 125 | 126 | Optionally, if you wish, you can use your own private key and self-signed certificate to 127 | sign the tokens. Here's how you can do it: [`readme-owncert.md`](./readme-owncert.md). 128 | 129 | 130 | ## Flushing task state 131 | 132 | By default, the emulator tracks the names of every task created since the emulator launched. The list 133 | of task names survives task completion, deletion, and purge queue operations. Completed / removed tasks 134 | do not appear in ListTasks, but calling GetTask or CreateTask with a name that has been used in the 135 | past will return an error. This mirrors the behaviour of Cloud Tasks - although note that unlike 136 | Cloud Tasks the emulator does not attempt to garbage collect the list of task names over time. 137 | 138 | For some usecases, you may want to completely reset the list of task names without restarting the 139 | emulator - e.g. between each scenario in a test run. 140 | 141 | The optional `hard-reset-on-purge-queue` flag configures the emulator so that calling `PurgeQueue` 142 | will remove all record of past tasks. It also switches `PurgeQueue` to be a synchronous operation 143 | which only returns once all tasks have been cancelled and the queue is empty. Queued tasks may, of 144 | course, still fire during the PurgeQueue operation - but they cannot fire after PurgeQueue has 145 | returned. 146 | 147 | ```sh 148 | go run ./ --hard-reset-on-purge-queue 149 | ``` 150 | 151 | ## Examples 152 | 153 | ### Python example 154 | Here's a little snippet of python code that you can use to talk to the emulator. 155 | 156 | ```python 157 | import grpc 158 | from google.cloud.tasks_v2 import CloudTasksClient 159 | from google.cloud.tasks_v2.services.cloud_tasks.transports import CloudTasksGrpcTransport 160 | 161 | channel = grpc.insecure_channel('localhost:8123') 162 | 163 | # Before v2.0.0 of the client 164 | # client = CloudTasksClient(channel=channel) 165 | 166 | transport = CloudTasksGrpcTransport(channel=channel) 167 | client = CloudTasksClient(transport=transport) 168 | 169 | parent = 'projects/my-sandbox/locations/us-central1' 170 | queue_name = parent + '/queues/test' 171 | client.create_queue(queue={'name': queue_name}, parent=parent) 172 | 173 | # Create a normal http task that should succeed 174 | client.create_task(task={'http_request': {'http_method': 'GET', 'url': 'https://www.google.com'}}, parent=queue_name) # 200 175 | # Create a normal http task that will throw 405s and will get retried 176 | client.create_task(task={'http_request': {'http_method': 'POST', 'url': 'https://www.google.com'}}, parent=queue_name) # 405 177 | # Create an appengine task that will target `/` 178 | client.create_task(task={'app_engine_http_request': {}}, parent=queue_name) 179 | ``` 180 | 181 | ### Go example 182 | In Go it would go something like this. 183 | 184 | ```go 185 | import ( 186 | "context" 187 | 188 | taskspb "google.golang.org/genproto/googleapis/cloud/tasks/v2" 189 | "google.golang.org/grpc" 190 | ) 191 | 192 | conn, _ := grpc.Dial("localhost:8123", grpc.WithInsecure()) 193 | clientOpt := option.WithGRPCConn(conn) 194 | client, _ := NewClient(context.Background(), clientOpt) 195 | 196 | parent := "projects/test-project/locations/us-central1" 197 | createQueueRequest := taskspb.CreateQueueRequest{ 198 | Parent: parent, 199 | Queue: parent + "/queues/test", 200 | } 201 | 202 | createQueueResp, _ := client.CreateQueue(context.Background(), &createQueueRequest) 203 | 204 | createTaskRequest := taskspb.CreateTaskRequest{ 205 | Parent: createQueueResp.GetName(), 206 | Task: &taskspb.Task{ 207 | PayloadType: &taskspb.Task_HttpRequest{ 208 | HttpRequest: &taskspb.HttpRequest{ 209 | Url: "http://www.google.com", 210 | }, 211 | }, 212 | }, 213 | } 214 | createdTaskResp, _ := client.CreateTask(context.Background(), &createTaskRequest) 215 | ``` 216 | 217 | ### PHP example 218 | The following example can be used for PHP. 219 | ```php 220 | use Grpc\ChannelCredentials; 221 | use Google\Cloud\Core\InsecureCredentialsWrapper; 222 | use Google\Cloud\Tasks\V2\Task; 223 | use Google\Cloud\Tasks\V2\HttpMethod; 224 | use Google\Cloud\Tasks\V2\HttpRequest; 225 | use Google\Cloud\Tasks\V2\CloudTasksClient; 226 | 227 | $client = new CloudTasksClient([ 228 | 'apiEndpoint' => 'localhost:8123', 229 | 'transport' => 'grpc', 230 | 'credentials' => new InsecureCredentialsWrapper(), 231 | 'transportConfig' => [ 232 | 'grpc' => [ 233 | 'stubOpts' => [ 234 | 'credentials' => ChannelCredentials::createInsecure() 235 | ] 236 | ] 237 | ] 238 | ]); 239 | 240 | $http = new HttpRequest(); 241 | $http->setHttpMethod(HttpMethod::GET)->setUrl('https://google.com'); 242 | 243 | $task = new Task(); 244 | 245 | $task->setHttpRequest($http); 246 | $queuePath = $client->queueName('dev', 'here', 'tasks'); 247 | 248 | $response = $client->createTask($queuePath, $task); 249 | ``` 250 | 251 | ### JavaScript example 252 | The following example can be used for JavaScript. 253 | ```js 254 | import { CloudTasksClient } from '@google-cloud/tasks'; 255 | import { credentials } from '@grpc/grpc-js'; 256 | 257 | const client = new CloudTasksClient({ 258 | port: 8123, 259 | servicePath: 'localhost', 260 | sslCreds: credentials.createInsecure(), 261 | }); 262 | 263 | const parent = 'projects/my-sandbox/locations/us-central1'; 264 | const queueName = `${parent}/queues/test`; 265 | client.createQueue({ parent, queue: { name: queueName } }); 266 | 267 | // Create a normal http task that should succeed 268 | await client.createTask({ 269 | parent: queueName, 270 | task: { httpRequest: { httpMethod: 'GET', url: 'https://www.google.com' } }, 271 | }); 272 | // Create a normal http task that will throw 405s and will get retried 273 | await client.createTask({ 274 | parent: queueName, 275 | task: { httpRequest: { httpMethod: 'POST', url: 'https://www.google.com' } }, 276 | }); 277 | 278 | // create task with OIDC token 279 | const payload = { foo: "bar" }; 280 | const serviceAccountEmail = "account@project_id.iam.gserviceaccount.com" 281 | await client.createTask({ 282 | parent: queueName, 283 | task: { 284 | httpRequest: { 285 | url: "https://myapp.example.com/worker", 286 | httpMethod: "POST", 287 | body: Buffer.from(JSON.stringify(payload)).toString("base64"), 288 | headers: {"Content-Type": "application/json"}, 289 | oidcToken: { 290 | serviceAccountEmail, 291 | }, 292 | }, 293 | }, 294 | }); 295 | ``` 296 | 297 | Receiving HTTP calls from the emulator and verifying OIDC tokens. 298 | ```js 299 | // at this point you started the emulator with the -openid-issuer flag 300 | // and created a http task with oidc token 301 | // in this example we are assuming that the issuer is http://localhost:8980 302 | import { OAuth2Client } from "google-auth-library"; 303 | 304 | const client = new OAuth2Client({ 305 | endpoints: { 306 | // PEM certs for node.js environment 307 | oauth2FederatedSignonPemCertsUrl: "http://localhost:8980/certs", 308 | 309 | // JWK certs for browser environment 310 | oauth2FederatedSignonJwkCertsUrl: "http://localhost:8980/jwks", 311 | }, 312 | issuers: ["http://localhost:8980"], 313 | }); 314 | 315 | // function using node.js 316 | // to handling the http request 317 | // to https://myapp.example.com/worker 318 | // the is webhook used in task creation 319 | // that is protected by oidc token 320 | function httpRequestHandler() { 321 | 322 | // data from Authorization header 323 | const idToken = "..."; 324 | 325 | const ticket = await client.verifyIdToken({ 326 | idToken, 327 | audience: "https://myapp.example.com/worker", 328 | }); 329 | const payload = ticket.getPayload(); 330 | console.info("Payload", payload); 331 | } 332 | -------------------------------------------------------------------------------- /task.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "log" 7 | "math/rand" 8 | "net/http" 9 | "net/url" 10 | "os" 11 | "regexp" 12 | "strconv" 13 | "sync" 14 | "time" 15 | 16 | "github.com/golang/protobuf/proto" 17 | "github.com/golang/protobuf/ptypes" 18 | pduration "github.com/golang/protobuf/ptypes/duration" 19 | ptimestamp "github.com/golang/protobuf/ptypes/timestamp" 20 | tasks "google.golang.org/genproto/googleapis/cloud/tasks/v2" 21 | rpcstatus "google.golang.org/genproto/googleapis/rpc/status" 22 | ) 23 | 24 | var r *regexp.Regexp 25 | 26 | func init() { 27 | // Format requirements as per https://cloud.google.com/tasks/docs/reference/rest/v2/projects.locations.queues.tasks#Task.FIELDS.name 28 | r = regexp.MustCompile("projects/([a-zA-Z0-9:.-]+)/locations/([a-zA-Z0-9-]+)/queues/([a-zA-Z0-9-]+)/tasks/([a-zA-Z0-9_-]+)") 29 | } 30 | 31 | func parseTaskName(task *tasks.Task) TaskNameParts { 32 | matches := r.FindStringSubmatch(task.GetName()) 33 | return TaskNameParts{ 34 | project: matches[1], 35 | location: matches[2], 36 | queueId: matches[3], 37 | taskId: matches[4], 38 | } 39 | } 40 | 41 | func isValidTaskName(name string) bool { 42 | return r.MatchString(name) 43 | } 44 | 45 | type TaskNameParts struct { 46 | project string 47 | location string 48 | queueId string 49 | taskId string 50 | } 51 | 52 | // Task holds all internals for a task 53 | type Task struct { 54 | queue *Queue 55 | 56 | state *tasks.Task 57 | 58 | cancel chan bool 59 | 60 | onDone func(*Task) 61 | 62 | stateMutex sync.Mutex 63 | 64 | cancelOnce sync.Once 65 | } 66 | 67 | // NewTask creates a new task for the specified queue 68 | func NewTask(queue *Queue, taskState *tasks.Task, onDone func(task *Task)) *Task { 69 | setInitialTaskState(taskState, queue.name) 70 | 71 | task := &Task{ 72 | queue: queue, 73 | state: taskState, 74 | onDone: onDone, 75 | cancel: make(chan bool, 1), // Buffered in case cancel comes when task is not scheduled 76 | } 77 | 78 | return task 79 | } 80 | 81 | func setInitialTaskState(taskState *tasks.Task, queueName string) { 82 | if taskState.GetName() == "" { 83 | taskID := strconv.FormatUint(uint64(rand.Uint64()), 10) 84 | taskState.Name = queueName + "/tasks/" + taskID 85 | } 86 | 87 | taskState.CreateTime = ptypes.TimestampNow() 88 | // For some reason the cloud does not set nanos 89 | taskState.CreateTime.Nanos = 0 90 | 91 | if taskState.GetScheduleTime() == nil { 92 | taskState.ScheduleTime = ptypes.TimestampNow() 93 | } 94 | if taskState.GetDispatchDeadline() == nil { 95 | taskState.DispatchDeadline = &pduration.Duration{Seconds: 600} 96 | } 97 | 98 | // This should probably be set somewhere else? 99 | taskState.View = tasks.Task_BASIC 100 | 101 | httpRequest := taskState.GetHttpRequest() 102 | 103 | if httpRequest != nil { 104 | if httpRequest.GetHttpMethod() == tasks.HttpMethod_HTTP_METHOD_UNSPECIFIED { 105 | httpRequest.HttpMethod = tasks.HttpMethod_POST 106 | } 107 | if httpRequest.GetHeaders() == nil { 108 | httpRequest.Headers = make(map[string]string) 109 | } 110 | // Override 111 | httpRequest.Headers["User-Agent"] = "Google-Cloud-Tasks" 112 | } 113 | 114 | appEngineHTTPRequest := taskState.GetAppEngineHttpRequest() 115 | 116 | if appEngineHTTPRequest != nil { 117 | if appEngineHTTPRequest.GetHttpMethod() == tasks.HttpMethod_HTTP_METHOD_UNSPECIFIED { 118 | appEngineHTTPRequest.HttpMethod = tasks.HttpMethod_POST 119 | } 120 | if appEngineHTTPRequest.GetHeaders() == nil { 121 | appEngineHTTPRequest.Headers = make(map[string]string) 122 | } 123 | 124 | appEngineHTTPRequest.Headers["User-Agent"] = "AppEngine-Google; (+http://code.google.com/appengine)" 125 | 126 | if appEngineHTTPRequest.GetBody() != nil { 127 | if _, ok := appEngineHTTPRequest.GetHeaders()["Content-Type"]; !ok { 128 | appEngineHTTPRequest.Headers["Content-Type"] = "application/octet-stream" 129 | } 130 | } 131 | 132 | if appEngineHTTPRequest.GetAppEngineRouting() == nil { 133 | appEngineHTTPRequest.AppEngineRouting = &tasks.AppEngineRouting{} 134 | } 135 | 136 | if appEngineHTTPRequest.GetAppEngineRouting().Host == "" { 137 | var host, domainSeparator string 138 | 139 | emulatorHost := os.Getenv("APP_ENGINE_EMULATOR_HOST") 140 | 141 | if emulatorHost == "" { 142 | // TODO: the new route format for appengine is ..r.appspot.com 143 | // TODO: support custom domains 144 | // https://cloud.google.com/appengine/docs/standard/python/how-requests-are-routed 145 | host = "https://" + parseTaskName(taskState).project + ".appspot.com" 146 | domainSeparator = "-dot-" 147 | } else { 148 | host = emulatorHost 149 | domainSeparator = "." 150 | } 151 | 152 | hostURL, err := url.Parse(host) 153 | if err != nil { 154 | panic(err) 155 | } 156 | 157 | if appEngineHTTPRequest.GetAppEngineRouting().GetService() != "" { 158 | hostURL.Host = appEngineHTTPRequest.GetAppEngineRouting().GetService() + domainSeparator + hostURL.Host 159 | } 160 | if appEngineHTTPRequest.GetAppEngineRouting().GetVersion() != "" { 161 | hostURL.Host = appEngineHTTPRequest.GetAppEngineRouting().GetVersion() + domainSeparator + hostURL.Host 162 | } 163 | if appEngineHTTPRequest.GetAppEngineRouting().GetInstance() != "" { 164 | hostURL.Host = appEngineHTTPRequest.GetAppEngineRouting().GetInstance() + domainSeparator + hostURL.Host 165 | } 166 | 167 | appEngineHTTPRequest.GetAppEngineRouting().Host = hostURL.String() 168 | } 169 | 170 | if appEngineHTTPRequest.GetRelativeUri() == "" { 171 | appEngineHTTPRequest.RelativeUri = "/" 172 | } 173 | } 174 | } 175 | 176 | func updateStateForReschedule(task *Task) *tasks.Task { 177 | // The lock is to ensure a consistent state when updating 178 | task.stateMutex.Lock() 179 | taskState := task.state 180 | queueState := task.queue.state 181 | 182 | retryConfig := queueState.GetRetryConfig() 183 | 184 | minBackoff, _ := ptypes.Duration(retryConfig.GetMinBackoff()) 185 | maxBackoff, _ := ptypes.Duration(retryConfig.GetMaxBackoff()) 186 | 187 | doubling := taskState.GetDispatchCount() - 1 188 | if doubling > retryConfig.MaxDoublings { 189 | doubling = retryConfig.MaxDoublings 190 | } 191 | backoff := minBackoff * time.Duration(1< maxBackoff { 193 | backoff = maxBackoff 194 | } 195 | protoBackoff := ptypes.DurationProto(backoff) 196 | prevScheduleTime := taskState.GetScheduleTime() 197 | 198 | // Avoid int32 nanos overflow 199 | scheduleNanos := int64(prevScheduleTime.GetNanos()) + int64(protoBackoff.GetNanos()) 200 | scheduleSeconds := prevScheduleTime.GetSeconds() + protoBackoff.GetSeconds() 201 | if scheduleNanos >= 1e9 { 202 | scheduleSeconds++ 203 | scheduleNanos -= 1e9 204 | } 205 | 206 | taskState.ScheduleTime = &ptimestamp.Timestamp{ 207 | Nanos: int32(scheduleNanos), 208 | Seconds: scheduleSeconds, 209 | } 210 | 211 | frozenTaskState := proto.Clone(taskState).(*tasks.Task) 212 | task.stateMutex.Unlock() 213 | 214 | return frozenTaskState 215 | } 216 | 217 | func updateStateForDispatch(task *Task) *tasks.Task { 218 | task.stateMutex.Lock() 219 | taskState := task.state 220 | 221 | dispatchTime := ptypes.TimestampNow() 222 | 223 | taskState.LastAttempt = &tasks.Attempt{ 224 | ScheduleTime: &ptimestamp.Timestamp{ 225 | Nanos: taskState.GetScheduleTime().GetNanos(), 226 | Seconds: taskState.GetScheduleTime().GetSeconds(), 227 | }, 228 | DispatchTime: dispatchTime, 229 | } 230 | 231 | taskState.DispatchCount++ 232 | 233 | if taskState.GetFirstAttempt() == nil { 234 | taskState.FirstAttempt = &tasks.Attempt{ 235 | DispatchTime: dispatchTime, 236 | } 237 | } 238 | 239 | frozenTaskState := proto.Clone(taskState).(*tasks.Task) 240 | task.stateMutex.Unlock() 241 | 242 | return frozenTaskState 243 | } 244 | 245 | func updateStateAfterDispatch(task *Task, statusCode int) *tasks.Task { 246 | task.stateMutex.Lock() 247 | 248 | taskState := task.state 249 | 250 | rpcCode := toRPCStatusCode(statusCode) 251 | rpcCodeName := toCodeName(rpcCode) 252 | 253 | lastAttempt := taskState.GetLastAttempt() 254 | 255 | lastAttempt.ResponseTime = ptypes.TimestampNow() 256 | lastAttempt.ResponseStatus = &rpcstatus.Status{ 257 | Code: rpcCode, 258 | Message: fmt.Sprintf("%s(%d): HTTP status code %d", rpcCodeName, rpcCode, statusCode), 259 | } 260 | 261 | taskState.ResponseCount++ 262 | 263 | frozenTaskState := proto.Clone(taskState).(*tasks.Task) 264 | task.stateMutex.Unlock() 265 | 266 | return frozenTaskState 267 | } 268 | 269 | func (task *Task) reschedule(retry bool, statusCode int) { 270 | if statusCode >= 200 && statusCode <= 299 { 271 | log.Println("Task done") 272 | task.onDone(task) 273 | } else { 274 | log.Println("Task exec error with status " + strconv.Itoa(statusCode)) 275 | if retry { 276 | retryConfig := task.queue.state.GetRetryConfig() 277 | 278 | if task.state.DispatchCount >= retryConfig.GetMaxAttempts() { 279 | log.Println("Ran out of attempts") 280 | } else { 281 | updateStateForReschedule(task) 282 | task.Schedule() 283 | } 284 | } 285 | } 286 | } 287 | 288 | func dispatch(retry bool, taskState *tasks.Task) int { 289 | client := &http.Client{} 290 | client.Timeout, _ = ptypes.Duration(taskState.GetDispatchDeadline()) 291 | 292 | var req *http.Request 293 | var headers map[string]string 294 | 295 | httpRequest := taskState.GetHttpRequest() 296 | appEngineHTTPRequest := taskState.GetAppEngineHttpRequest() 297 | 298 | scheduled, _ := ptypes.Timestamp(taskState.GetScheduleTime()) 299 | nameParts := parseTaskName(taskState) 300 | 301 | headerQueueName := nameParts.queueId 302 | headerTaskName := nameParts.taskId 303 | headerTaskRetryCount := fmt.Sprintf("%v", taskState.GetDispatchCount()-1) 304 | headerTaskExecutionCount := fmt.Sprintf("%v", taskState.GetResponseCount()) 305 | headerTaskETA := fmt.Sprintf("%f", float64(scheduled.UnixNano())/1e9) 306 | 307 | if httpRequest != nil { 308 | method := toHTTPMethod(httpRequest.GetHttpMethod()) 309 | 310 | req, _ = http.NewRequest(method, httpRequest.GetUrl(), bytes.NewBuffer(httpRequest.GetBody())) 311 | 312 | headers = httpRequest.GetHeaders() 313 | 314 | if auth := httpRequest.GetOidcToken(); auth != nil { 315 | tokenStr := createOIDCToken(auth.ServiceAccountEmail, httpRequest.GetUrl(), auth.Audience) 316 | headers["Authorization"] = "Bearer " + tokenStr 317 | } 318 | 319 | // Headers as per https://cloud.google.com/tasks/docs/creating-http-target-tasks#handler 320 | // TODO: optional headers 321 | headers["X-CloudTasks-QueueName"] = headerQueueName 322 | headers["X-CloudTasks-TaskName"] = headerTaskName 323 | headers["X-CloudTasks-TaskExecutionCount"] = headerTaskExecutionCount 324 | headers["X-CloudTasks-TaskRetryCount"] = headerTaskRetryCount 325 | headers["X-CloudTasks-TaskETA"] = headerTaskETA 326 | } else if appEngineHTTPRequest != nil { 327 | method := toHTTPMethod(appEngineHTTPRequest.GetHttpMethod()) 328 | 329 | host := appEngineHTTPRequest.GetAppEngineRouting().GetHost() 330 | 331 | url := host + appEngineHTTPRequest.GetRelativeUri() 332 | 333 | req, _ = http.NewRequest(method, url, bytes.NewBuffer(appEngineHTTPRequest.GetBody())) 334 | 335 | headers = appEngineHTTPRequest.GetHeaders() 336 | 337 | // These headers are only set on dispatch, see https://cloud.google.com/tasks/docs/reference/rpc/google.cloud.tasks.v2#google.cloud.tasks.v2.AppEngineHttpRequest 338 | // TODO: optional headers 339 | headers["X-AppEngine-QueueName"] = headerQueueName 340 | headers["X-AppEngine-TaskName"] = headerTaskName 341 | headers["X-AppEngine-TaskRetryCount"] = headerTaskRetryCount 342 | headers["X-AppEngine-TaskExecutionCount"] = headerTaskExecutionCount 343 | headers["X-AppEngine-TaskETA"] = headerTaskETA 344 | } 345 | 346 | for k, v := range headers { 347 | // Uses a direct set to maintain capitalization 348 | // TODO: figure out a way to test these, as the Go net/http client lib overrides the incoming header capitalization 349 | req.Header[k] = []string{v} 350 | } 351 | 352 | resp, err := client.Do(req) 353 | if err != nil { 354 | fmt.Fprintf(os.Stderr, "%v\n", err) 355 | return -1 356 | } 357 | defer resp.Body.Close() 358 | 359 | return resp.StatusCode 360 | } 361 | 362 | func (task *Task) doDispatch(retry bool) { 363 | respCode := dispatch(retry, task.state) 364 | 365 | updateStateAfterDispatch(task, respCode) 366 | task.reschedule(retry, respCode) 367 | } 368 | 369 | // Attempt tries to execute a task 370 | func (task *Task) Attempt() { 371 | updateStateForDispatch(task) 372 | 373 | task.doDispatch(true) 374 | } 375 | 376 | // Run runs the task outside of the normal queueing mechanism. 377 | // This method is called directly by request. 378 | func (task *Task) Run() *tasks.Task { 379 | taskState := updateStateForDispatch(task) 380 | 381 | go task.doDispatch(false) 382 | 383 | return taskState 384 | } 385 | 386 | // Delete cancels the task if it is queued for execution. 387 | // This method is called directly by request. 388 | func (task *Task) Delete() { 389 | task.cancelOnce.Do(func() { 390 | task.cancel <- true 391 | }) 392 | } 393 | 394 | // Schedule schedules the task for execution. 395 | // It is initially called by the queue, later by the task reschedule. 396 | func (task *Task) Schedule() { 397 | scheduled, _ := ptypes.Timestamp(task.state.GetScheduleTime()) 398 | 399 | fromNow := time.Until(scheduled) 400 | 401 | go func() { 402 | select { 403 | case <-time.After(fromNow): 404 | task.queue.fire <- task 405 | return 406 | case <-task.cancel: 407 | task.onDone(task) 408 | return 409 | } 410 | }() 411 | } 412 | -------------------------------------------------------------------------------- /task_internal_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | taskspb "google.golang.org/genproto/googleapis/cloud/tasks/v2" 9 | ) 10 | 11 | func TestSetInitialTaskStateAppEngineNoEmulatorDefaults(t *testing.T) { 12 | taskState := &taskspb.Task{ 13 | MessageType: &taskspb.Task_AppEngineHttpRequest{ 14 | AppEngineHttpRequest: &taskspb.AppEngineHttpRequest{}, 15 | }, 16 | } 17 | setInitialTaskState(taskState, "projects/bluebook/locations/us-east1/queues/agentq") 18 | 19 | assert.Equal(t, "https://bluebook.appspot.com", taskState.GetAppEngineHttpRequest().GetAppEngineRouting().GetHost()) 20 | } 21 | 22 | func TestInitialTaskStateAppEngineNoEmulatorTargeted(t *testing.T) { 23 | taskState := &taskspb.Task{ 24 | MessageType: &taskspb.Task_AppEngineHttpRequest{ 25 | AppEngineHttpRequest: &taskspb.AppEngineHttpRequest{ 26 | AppEngineRouting: &taskspb.AppEngineRouting{ 27 | Service: "worker", 28 | Version: "v1", 29 | Instance: "2", 30 | }, 31 | }, 32 | }, 33 | } 34 | setInitialTaskState(taskState, "projects/bluebook/locations/us-east1/queues/agentq") 35 | 36 | assert.Equal(t, "https://2-dot-v1-dot-worker-dot-bluebook.appspot.com", taskState.GetAppEngineHttpRequest().GetAppEngineRouting().GetHost()) 37 | } 38 | 39 | func TestSetInitialTaskStateAppEngineEmulatorDefaults(t *testing.T) { 40 | defer os.Unsetenv("APP_ENGINE_EMULATOR_HOST") 41 | os.Setenv("APP_ENGINE_EMULATOR_HOST", "http://localhost:1234") 42 | 43 | taskState := &taskspb.Task{ 44 | MessageType: &taskspb.Task_AppEngineHttpRequest{ 45 | AppEngineHttpRequest: &taskspb.AppEngineHttpRequest{}, 46 | }, 47 | } 48 | setInitialTaskState(taskState, "projects/bluebook/locations/us-east1/queues/agentq") 49 | 50 | assert.Equal(t, "http://localhost:1234", taskState.GetAppEngineHttpRequest().GetAppEngineRouting().GetHost()) 51 | } 52 | 53 | func TestSetInitialTaskStateAppEngineEmulatorTargeted(t *testing.T) { 54 | defer os.Unsetenv("APP_ENGINE_EMULATOR_HOST") 55 | os.Setenv("APP_ENGINE_EMULATOR_HOST", "http://nginx") 56 | 57 | taskState := &taskspb.Task{ 58 | MessageType: &taskspb.Task_AppEngineHttpRequest{ 59 | AppEngineHttpRequest: &taskspb.AppEngineHttpRequest{ 60 | AppEngineRouting: &taskspb.AppEngineRouting{ 61 | Service: "worker", 62 | Version: "v1", 63 | Instance: "2", 64 | }, 65 | }, 66 | }, 67 | } 68 | setInitialTaskState(taskState, "projects/bluebook/locations/us-east1/queues/agentq") 69 | 70 | assert.Equal(t, "http://2.v1.worker.nginx", taskState.GetAppEngineHttpRequest().GetAppEngineRouting().GetHost()) 71 | } 72 | --------------------------------------------------------------------------------