├── .github └── workflows │ ├── docker-integration-test.yaml │ ├── docker-release.yaml │ ├── go-releaser.yaml │ ├── go-unit-test.yaml │ ├── kubernetes-integration-test.yaml │ └── nomad-integration-test.yaml ├── .gitignore ├── .gitpod.yml ├── .goreleaser.yaml ├── CHANGELOG.md ├── Dockerfile ├── LICENSE ├── README.md ├── cmd ├── client.go ├── config.go ├── exporter.go ├── provider.go ├── provider_list.go └── root.go ├── docs ├── github_app.png ├── github_app_id.png ├── github_app_install.png ├── github_app_install_repo.png ├── github_app_private_key.png ├── github_repo_secret.png ├── homebrew.md ├── process_ember_data.md └── release.md ├── examples ├── carbonintensityuk │ └── main.go ├── electricitymap │ └── main.go ├── ember │ └── main.go └── watttime │ └── main.go ├── go.mod ├── go.sum ├── hack ├── countries.csv └── country_codes.go ├── helm └── grid-intensity-exporter │ ├── Chart.yaml │ ├── templates │ ├── configmap.yaml │ ├── deployment.yaml │ ├── secret.yaml │ ├── service-account.yaml │ └── service.yaml │ └── values.yaml ├── install.sh ├── integration └── test │ └── exporter │ └── metric_test.go ├── main.go ├── nomad └── grid-intensity-exporter.nomad └── pkg ├── internal └── data │ ├── co2-intensities-ember-2021.csv │ ├── ember.go │ └── schema.go └── provider ├── cache.go ├── carbon_intensity_uk.go ├── carbon_intensity_uk_test.go ├── electricity_maps.go ├── electricity_maps_test.go ├── ember.go ├── ember_test.go ├── error.go ├── provider.go ├── watt_time.go └── watt_time_test.go /.github/workflows/docker-integration-test.yaml: -------------------------------------------------------------------------------- 1 | name: docker-integration-test 2 | on: [pull_request, push] 3 | jobs: 4 | docker-integration-test: 5 | runs-on: ubuntu-latest 6 | steps: 7 | - 8 | name: Checkout code 9 | uses: actions/checkout@v4 10 | - 11 | name: Set up Go 12 | uses: actions/setup-go@v5 13 | - 14 | name: Go build 15 | run: CGO_ENABLED=0 GOOS=linux go build -o grid-intensity . 16 | - 17 | name: Set up QEMU 18 | uses: docker/setup-qemu-action@v3 19 | - 20 | name: Set up Docker Buildx 21 | uses: docker/setup-buildx-action@v3 22 | - 23 | name: Docker build 24 | uses: docker/build-push-action@v5 25 | with: 26 | context: . 27 | load: true 28 | tags: thegreenwebfoundation/grid-intensity:integration-test 29 | - 30 | name: Docker run 31 | run: docker run -d -e GRID_INTENSITY_LOCATION=GBR -p 8000:8000 thegreenwebfoundation/grid-intensity:integration-test exporter 32 | - 33 | name: Run integration test 34 | run: go test -v -tags=dockerrequired ./integration/test/exporter 35 | -------------------------------------------------------------------------------- /.github/workflows/docker-release.yaml: -------------------------------------------------------------------------------- 1 | name: docker_release 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | paths-ignore: 7 | - 'README.md' 8 | - 'CHANGELOG.md' 9 | tags: [ 'v*.*.*' ] 10 | 11 | jobs: 12 | build_and_push: 13 | name: build_and_push_docker_image 14 | runs-on: ubuntu-latest 15 | steps: 16 | - name: Checkout 17 | uses: actions/checkout@v4 18 | - name: Set up Go 19 | uses: actions/setup-go@v5 20 | - name: Go build 21 | run: CGO_ENABLED=0 GOOS=linux go build -o grid-intensity . 22 | - name: Docker metadata 23 | id: meta 24 | uses: docker/metadata-action@v4 25 | with: 26 | images: | 27 | rg.fr-par.scw.cloud/funcscwtgwfiljyy2ym/grid-intensity 28 | tags: | 29 | type=ref,event=branch 30 | type=semver,pattern={{version}} 31 | - name: Set up QEMU 32 | uses: docker/setup-qemu-action@v3 33 | - name: Set up Docker Buildx 34 | uses: docker/setup-buildx-action@v3 35 | - name: Login to Scaleway Registry 36 | uses: docker/login-action@v2 37 | with: 38 | registry: rg.fr-par.scw.cloud/funcscwtgwfiljyy2ym 39 | username: ${{ secrets.SCALEWAY_REGISTRY_USERNAME }} 40 | password: ${{ secrets.SCALEWAY_REGISTRY_PASSWORD }} 41 | - name: Build and push 42 | id: docker_build 43 | uses: docker/build-push-action@v5 44 | with: 45 | context: . 46 | push: true 47 | tags: ${{ steps.meta.outputs.tags }} 48 | labels: ${{ steps.meta.outputs.labels }} 49 | -------------------------------------------------------------------------------- /.github/workflows/go-releaser.yaml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | tags: 4 | - '*' 5 | 6 | jobs: 7 | goreleaser: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - 11 | name: Checkout 12 | uses: actions/checkout@v4 13 | with: 14 | fetch-depth: 0 15 | - 16 | name: Set up Go 17 | uses: actions/setup-go@v4 18 | - 19 | name: Get token 20 | id: get_token 21 | uses: getsentry/action-github-app-token@main 22 | with: 23 | app_id: ${{ secrets.HOMEBREW_TAP_GITHUB_APP_ID }} 24 | private_key: ${{ secrets.HOMEBREW_TAP_GITHUB_APP_PEM }} 25 | - 26 | name: Run GoReleaser 27 | uses: goreleaser/goreleaser-action@v5 28 | with: 29 | distribution: goreleaser 30 | version: latest 31 | args: release --rm-dist 32 | env: 33 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 34 | HOMEBREW_TAP_GITHUB_TOKEN: ${{ steps.get_token.outputs.token }} 35 | -------------------------------------------------------------------------------- /.github/workflows/go-unit-test.yaml: -------------------------------------------------------------------------------- 1 | name: go-unit-test 2 | on: [push] 3 | jobs: 4 | go-unit-test: 5 | runs-on: ubuntu-latest 6 | steps: 7 | - 8 | name: Checkout code 9 | uses: actions/checkout@v4 10 | - 11 | name: Set up Go 12 | uses: actions/setup-go@v5 13 | - 14 | name: Go test 15 | run: go test ./... 16 | -------------------------------------------------------------------------------- /.github/workflows/kubernetes-integration-test.yaml: -------------------------------------------------------------------------------- 1 | name: kubernetes-integration-test 2 | on: [pull_request, push] 3 | jobs: 4 | kubernetes-integration-test: 5 | env: 6 | TEST_PLATFORM: kubernetes 7 | runs-on: ubuntu-latest 8 | steps: 9 | - 10 | name: Checkout code 11 | uses: actions/checkout@v4 12 | - 13 | name: Set up Go 14 | uses: actions/setup-go@v5 15 | - 16 | name: Go build 17 | run: CGO_ENABLED=0 GOOS=linux go build -o grid-intensity . 18 | - 19 | name: Set up QEMU 20 | uses: docker/setup-qemu-action@v3 21 | - 22 | name: Set up Docker Buildx 23 | uses: docker/setup-buildx-action@v3 24 | - 25 | name: Docker build 26 | uses: docker/build-push-action@v5 27 | with: 28 | context: . 29 | load: true 30 | tags: thegreenwebfoundation/grid-intensity:integration-test 31 | - 32 | name: Create kubernetes Kind Cluster 33 | uses: helm/kind-action@v1.5.0 34 | - 35 | name: Load image into Kind cluster 36 | run: kind load docker-image --name chart-testing thegreenwebfoundation/grid-intensity:integration-test 37 | - 38 | name: Set up Helm 39 | uses: azure/setup-helm@v3 40 | - 41 | name: Helm install 42 | run: helm install grid-intensity-exporter helm/grid-intensity-exporter --set image.tag=integration-test 43 | - 44 | name: Set up Kubectl 45 | uses: azure/setup-kubectl@v3 46 | - 47 | name: Wait for ready pod 48 | run: kubectl wait --for=condition=ready -l app.kubernetes.io/name=grid-intensity-exporter pod 49 | - 50 | name: Create port forward to metrics port 51 | run: kubectl port-forward deployment/grid-intensity-exporter 8000:8000 & 52 | - 53 | name: Run integration test 54 | run: go test -v -tags=dockerrequired ./integration/test/exporter 55 | -------------------------------------------------------------------------------- /.github/workflows/nomad-integration-test.yaml: -------------------------------------------------------------------------------- 1 | name: nomad-integration-test 2 | on: [pull_request, push] 3 | jobs: 4 | nomad-integration-test: 5 | env: 6 | TEST_PLATFORM: nomad 7 | runs-on: ubuntu-latest 8 | timeout-minutes: 10 9 | steps: 10 | - 11 | name: Checkout code 12 | uses: actions/checkout@v4 13 | - 14 | name: Set up Go 15 | uses: actions/setup-go@v5 16 | - 17 | name: Go build 18 | run: CGO_ENABLED=0 GOOS=linux go build -o grid-intensity . 19 | - 20 | name: Set up QEMU 21 | uses: docker/setup-qemu-action@v3 22 | - 23 | name: Set up Docker Buildx 24 | uses: docker/setup-buildx-action@v3 25 | - 26 | name: Docker build 27 | uses: docker/build-push-action@v5 28 | with: 29 | context: . 30 | load: true 31 | tags: thegreenwebfoundation/grid-intensity:integration-test 32 | - 33 | name: Add HashiCorp GPG key 34 | run: curl -fsSL https://apt.releases.hashicorp.com/gpg | sudo apt-key add - 35 | - 36 | name: Add HashiCorp Linux repository 37 | run: sudo apt-add-repository "deb [arch=amd64] https://apt.releases.hashicorp.com $(lsb_release -cs) main" 38 | - 39 | name: Install nomad 40 | run: sudo apt-get update && sudo apt-get install nomad 41 | - 42 | name: Start running nomad in agent mode, then background it 43 | run: sudo nomad agent --dev --node dev01 & 44 | - 45 | name: Submit the grid-intensity-exporter.nomad job to nomad 46 | run: nomad run ./nomad/grid-intensity-exporter.nomad 47 | - 48 | name: Run integration test 49 | run: go test -v -tags=dockerrequired ./integration/test/exporter 50 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | 8 | # Test binary, built with `go test -c` 9 | *.test 10 | 11 | # Output of the go coverage tool, specifically when used with LiteIDE 12 | *.out 13 | 14 | dist/ 15 | 16 | grid-intensity 17 | grid-intensity-go 18 | 19 | # Dependency directories (remove the comment below to include it) 20 | # vendor/ 21 | -------------------------------------------------------------------------------- /.gitpod.yml: -------------------------------------------------------------------------------- 1 | # Commands to start on workspace startup 2 | 3 | # Steps are: 4 | # - Compile binary 5 | # - Add local directory to the path 6 | # - Make binary executable 7 | # - Show help text 8 | tasks: 9 | - init: go build -o grid-intensity 10 | command: | 11 | export PATH="$PATH:." 12 | chmod +x grid-intensity 13 | grid-intensity --help 14 | # Expose exporter port on workspace startup 15 | ports: 16 | - port: 8000 17 | onOpen: ignore 18 | -------------------------------------------------------------------------------- /.goreleaser.yaml: -------------------------------------------------------------------------------- 1 | project_name: grid-intensity 2 | before: 3 | hooks: 4 | - go mod tidy 5 | builds: 6 | - env: 7 | - CGO_ENABLED=0 8 | goos: 9 | - linux 10 | - windows 11 | - darwin 12 | 13 | archives: 14 | - format: tar.gz 15 | name_template: >- 16 | {{ .ProjectName }}_ 17 | {{- title .Os }}_ 18 | {{- if eq .Arch "amd64" }}x86_64 19 | {{- else if eq .Arch "386" }}i386 20 | {{- else }}{{ .Arch }}{{ end }} 21 | {{- if .Arm }}v{{ .Arm }}{{ end }} 22 | format_overrides: 23 | - goos: windows 24 | format: zip 25 | 26 | brews: 27 | - tap: 28 | owner: thegreenwebfoundation 29 | name: homebrew-carbon-aware-tools 30 | branch: main 31 | token: "{{ .Env.HOMEBREW_TAP_GITHUB_TOKEN }}" 32 | homepage: https://github.com/thegreenwebfoundation/grid-intensity-go 33 | description: Get carbon intensity data for electricity grids 34 | license: Apache-2.0 35 | checksum: 36 | name_template: 'checksums.txt' 37 | snapshot: 38 | name_template: "{{ incpatch .Version }}-next" 39 | changelog: 40 | sort: asc 41 | filters: 42 | exclude: 43 | - '^docs:' 44 | - '^test:' 45 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | > **Added** for new features. 9 | > **Changed** for changes in existing functionality. 10 | > **Deprecated** for soon-to-be removed features. 11 | > **Removed** for now removed features. 12 | > **Fixed** for any bug fixes. 13 | > **Security** in case of vulnerabilities. 14 | 15 | ## Unreleased 16 | 17 | ## 0.7.0 2024-06-11 18 | 19 | ### Changed 20 | 21 | - Breaking change to add timestamps to exporter metrics with when provider 22 | last updated the value. 23 | - Update Electricity Maps provider to return both estimated and actual values. 24 | Note: There is a delay before actual values are available. 25 | - Update location parameter to support multiple locations separated with a 26 | comma. 27 | 28 | ## 0.6.0 2024-05-15 29 | 30 | ### Changed 31 | 32 | - Rename Electricity Map provider to Electricity Maps. 33 | 34 | ### Fixed 35 | 36 | - Building URLs now handles if base URL has trailing slashes or not. 37 | 38 | ## 0.5.0 2023-05-05 39 | 40 | ### Added 41 | 42 | - Push container image to scaleway registry. 43 | - Support Electricity Maps free tier. 44 | 45 | ### Changed 46 | 47 | - Update go version to 1.20, go module versions and github actions. 48 | 49 | ### Fixed 50 | 51 | - Fix nomad integration test by using sudo to run agent. 52 | 53 | ## 0.4.1 2023-05-05 54 | 55 | ### Fixed 56 | 57 | - Add `auth-token` header to Electricity Maps request. 58 | 59 | ## 0.4.0 2022-08-31 60 | 61 | ### Added 62 | 63 | - Add node and region labels to prometheus metrics for carbon aware scheduling. 64 | 65 | ### Changed 66 | 67 | - Breaking change to refactor interface to return the same JSON format for all 68 | providers. 69 | - Breaking change to rename region to location since region has a different meaning 70 | in Nomad and Kubernetes. 71 | 72 | ### Fixed 73 | 74 | - Fix link to install.sh in readme. 75 | 76 | ## 0.3.0 2022-07-15 77 | 78 | ### Added 79 | 80 | - Add WattTime provider. 81 | - Add Carbon Intensity Org UK support to CLI. 82 | 83 | ## 0.2.1 2022-07-01 84 | 85 | ### Fixed 86 | 87 | - Generate installation token for GoReleaser. 88 | 89 | ## 0.2.0 2022-06-23 90 | 91 | ### Added 92 | 93 | - Add install script. 94 | - Add Ember support to exporter. 95 | 96 | ## 0.1.0 2022-06-21 97 | 98 | ### Added 99 | 100 | - Initial release. 101 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM alpine:3.18 2 | 3 | RUN apk add --no-cache ca-certificates 4 | 5 | ADD ./grid-intensity /grid-intensity 6 | 7 | EXPOSE 8000/tcp 8 | 9 | ENTRYPOINT ["/grid-intensity"] 10 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![GoDoc](https://godoc.org/github.com/thegreenwebfoundation/grid-intensity-go?status.svg)](http://godoc.org/github.com/thegreenwebfoundation/grid-intensity-go) ![go-unit-test](https://github.com/thegreenwebfoundation/grid-intensity-go/workflows/go-unit-test/badge.svg) ![docker](https://github.com/thegreenwebfoundation/grid-intensity-go/workflows/docker-integration-test/badge.svg) ![kubernetes](https://github.com/thegreenwebfoundation/grid-intensity-go/workflows/kubernetes-integration-test/badge.svg) ![nomad](https://github.com/thegreenwebfoundation/grid-intensity-go/workflows/nomad-integration-test/badge.svg) 2 | 3 | # grid-intensity-go 4 | 5 | A tool written in Go, designed to be integrated into Kubernetes, Nomad, and other schedulers, to help you factor carbon intensity into decisions about where and when to run jobs. 6 | 7 | The tool has 3 components. 8 | 9 | - The `grid-intensity` CLI for interacting with carbon intensity data. 10 | - A [Prometheus](https://prometheus.io/) exporter with carbon intensity metrics that can be deployed via 11 | Docker, Nomad, or Kubernetes. 12 | - A Go library that can be integrated into your Go code. 13 | 14 | ## Changelog 15 | 16 | See [CHANGELOG.md](/CHANGELOG.md). 17 | 18 | ## Background 19 | 20 | We know that the internet runs on electricity. That electricity comes from a mix of energy sources, including wind and solar, nuclear power, biomass, fossil gas, oil and coal and so on, 21 | 22 | We call this the fuel mix, and this fuel mix can impact on the carbon intensity of your code. 23 | 24 | ## Move your code through time and space 25 | 26 | Because the fuel mix will be different depending when and where you run your code, you can influence the carbon intensity of the code you write by moving it through time and space - either by making it run when the grid is greener, or making it run where it's greener, like a CDN running on green power. 27 | 28 | ## Inspired By 29 | 30 | This tool builds on research and tools developed from across the sustainable software community. 31 | 32 | ### Articles 33 | 34 | - A carbon aware internet - Branch magazine - https://branch.climateaction.tech/issues/issue-2/a-carbon-aware-internet/ 35 | - Carbon Aware Kubernetes - https://devblogs.microsoft.com/sustainable-software/carbon-aware-kubernetes/ 36 | - Clean energy technologies threaten to overwhelm the grid. Here’s how it can adapt. - https://www.vox.com/energy-and-environment/2018/11/30/17868620/renewable-energy-power-grid-architecture 37 | 38 | ### Papers 39 | 40 | - A Tale of Two Visions: Designing a Decentralized Transactive Electric System - https://ieeexplore.ieee.org/document/7452738 41 | - Carbon Explorer - https://github.com/facebookresearch/CarbonExplorer/ 42 | - Cucumber: Renewable-Aware Admission Control for Delay-Tolerant Cloud and Edge Workloads - https://arxiv.org/abs/2205.02895 43 | - Let's Wait Awhile: How Temporal Workload Shifting Can Reduce Carbon Emissions in the Cloud - https://arxiv.org/abs/2110.13234 44 | 45 | ### Tools 46 | 47 | - Carbon Aware Nomad - experimental branch - https://github.com/hashicorp/nomad/blob/h-carbon-meta/CARBON.md 48 | - Cloud Carbon Footprint - https://www.cloudcarbonfootprint.org/ 49 | - Scaphandre - https://github.com/hubblo-org/scaphandre 50 | - Solar Protocol - http://solarprotocol.net/ 51 | - The carbon aware scheduler - https://pypi.org/project/carbon-aware-scheduler/ 52 | 53 | ## Installing 54 | 55 | - Install via [brew](https://brew.sh/). 56 | 57 | ```sh 58 | brew install thegreenwebfoundation/carbon-aware-tools/grid-intensity 59 | ``` 60 | 61 | - Install via curl (feel free to do due diligence and check the [script](https://github.com/thegreenwebfoundation/grid-intensity-go/blob/main/install.sh) first). 62 | 63 | ```sh 64 | curl -fsSL https://raw.githubusercontent.com/thegreenwebfoundation/grid-intensity-go/main/install.sh | sudo sh 65 | ``` 66 | 67 | - Fetch a binary release from the [releases](https://github.com/thegreenwebfoundation/grid-intensity-go/releases) page. 68 | 69 | ## grid-intensity CLI 70 | 71 | The CLI allows you to interact with carbon intensity data from multiple providers. 72 | 73 | ```sh 74 | $ grid-intensity 75 | Provider ember-climate.org needs an ISO country code as a location parameter. 76 | ESP detected from your locale. 77 | ESP 78 | [ 79 | { 80 | "emissions_type": "average", 81 | "metric_type": "absolute", 82 | "provider": "Ember", 83 | "location": "ESP", 84 | "units": "gCO2e per kWh", 85 | "valid_from": "2021-01-01T00:00:00Z", 86 | "valid_to": "2021-12-31T23:59:00Z", 87 | "value": 193.737 88 | } 89 | ] 90 | ``` 91 | 92 | The `--provider` and `--location` flags allow you to select other providers and locations. 93 | You can also set the `GRID_INTENSITY_PROVIDER` and `GRID_INTENSITY_LOCATION` environment 94 | variables or edit the config file at `~/.config/grid-intensity/config.yaml`. 95 | 96 | ```sh 97 | $ grid-intensity --provider CarbonIntensityOrgUK --location UK 98 | { 99 | "from": "2022-07-14T14:30Z", 100 | "to": "2022-07-14T15:00Z", 101 | "intensity": { 102 | "forecast": 184, 103 | "actual": 194, 104 | "index": "moderate" 105 | } 106 | } 107 | ``` 108 | 109 | The [providers](#providers) section shows how to configure other providers. 110 | 111 | ## grid-intensity exporter 112 | 113 | The `exporter` subcommand starts the prometheus exporter on port 8000. 114 | 115 | ```sh 116 | $ grid-intensity exporter --provider Ember --location FR 117 | Using provider "Ember" with location "FR" 118 | Metrics available at :8000/metrics 119 | ``` 120 | 121 | View the metrics with curl. 122 | 123 | ``` 124 | $ curl -s http://localhost:8000/metrics | grep grid 125 | # HELP grid_intensity_carbon_average Average carbon intensity for the electricity grid in this location. 126 | # TYPE grid_intensity_carbon_average gauge 127 | grid_intensity_carbon_average{provider="Ember",location="FR",units="gCO2 per kWh"} 67.781 1718258400000 128 | ``` 129 | 130 | **Note about Prometheus and samples in the past** 131 | 132 | If you are using the exporter with the ElectricityMaps provider, it will return a value for estimated, which will be the most recent one, and another value for the real value, which can be a few hours in the past. Depending on your Prometheus installation, it could be that the metrics that have a timestamp in the past are not accepted, with an error such as this: 133 | 134 | `Error on ingesting samples that are too old or are too far into the future` 135 | 136 | In that case, you can configure the property `tsdb.outOfOrderTimeWindow` to extend the time window accepted, for example to `3h`. 137 | 138 | 139 | ### Docker Image 140 | 141 | Build the docker image to deploy the exporter. 142 | 143 | ```sh 144 | CGO_ENABLED=0 GOOS=linux go build -o grid-intensity . 145 | docker build -t thegreenwebfoundation/grid-intensity:latest . 146 | ``` 147 | 148 | ### Kubernetes 149 | 150 | Install the [helm](https://helm.sh/) chart in [/helm/grid-intensity-exporter](https://github.com/thegreenwebfoundation/grid-intensity-go/tree/main/helm/grid-intensity-exporter). 151 | Needs the Docker image to be available in the cluster. 152 | 153 | ```sh 154 | helm install --set gridIntensity.location=FR grid-intensity-exporter helm/grid-intensity-exporter 155 | ``` 156 | 157 | ### Nomad 158 | 159 | Edit the Nomad job in [/nomad/grid-intensity-exporter.nomad](https://github.com/thegreenwebfoundation/grid-intensity-go/blob/main/nomad/grid-intensity-exporter.nomad) to set the 160 | env vars `GRID_INTENSITY_LOCATION` and `GRID_INTENSITY_PROVIDER` 161 | 162 | Start the Nomad job. Needs the Docker image to be available in the cluster. 163 | 164 | ```sh 165 | nomad run ./nomad/grid-intensity-exporter.nomad 166 | ``` 167 | 168 | ## grid-intensity-go library 169 | 170 | See the [/examples/](https://github.com/thegreenwebfoundation/grid-intensity-go/tree/main/examples) 171 | directory for examples of how to integrate each provider. 172 | 173 | ## Providers 174 | 175 | Currently these providers of carbon intensity data are integrated. If you would like 176 | us to integrate more providers please open an [issue](https://github.com/thegreenwebfoundation/grid-intensity-go/issues). 177 | 178 | ### Electricity Maps 179 | 180 | [Electricity Maps](https://app.electricitymaps.com/map) have carbon intensity data 181 | from multiple sources. You need to get an API token and URL from their 182 | [API portal](https://api-portal.electricitymaps.com/) to use the API. You can use 183 | their free tier for non-commercial use or sign up for a 30 day trial. 184 | 185 | The `location` parameter needs to be set to a zone present in the public [zones](https://static.electricitymaps.com/api/docs/index.html#zones) endpoint. 186 | 187 | ```sh 188 | ELECTRICITY_MAPS_API_TOKEN=your-token \ 189 | ELECTRICITY_MAPS_API_URL=https://api-access.electricitymaps.com/free-tier/ \ 190 | grid-intensity --provider=ElectricityMaps --location=IN-KA 191 | ``` 192 | 193 | ### WattTime 194 | 195 | [WattTime](https://www.watttime.org/) have carbon intensity data from multiple sources. 196 | You need to [register](https://www.watttime.org/api-documentation/#authentication) to use the API. 197 | 198 | The `location` parameter should be set to a supported location. The `/ba-from-loc` 199 | endpoint allows you to provide a latitude and longitude. See the [docs](https://www.watttime.org/api-documentation/#determine-grid-region) for more details. 200 | 201 | ```sh 202 | WATT_TIME_USER=your-user \ 203 | WATT_TIME_PASSWORD=your-password \ 204 | grid-intensity --provider=WattTime --location=CAISO_NORTH 205 | ``` 206 | 207 | ### Ember 208 | 209 | Carbon intensity data from [Ember](https://ember-climate.org/), is embedded in the binary 210 | in accordance with their licensing - [CC-BY-SA 4.0](https://ember-climate.org/creative-commons/) 211 | 212 | ```sh 213 | grid-intensity --provider=Ember --location=DE 214 | ``` 215 | 216 | The `location` parameter should be set to a 2 or 3 char ISO country code. 217 | 218 | ### UK Carbon Intensity API 219 | 220 | UK Carbon Intensity API https://carbonintensity.org.uk/ this is a public API 221 | and the only location supported is `UK`. 222 | 223 | ```sh 224 | grid-intensity --provider=CarbonIntensityOrgUK --location=UK 225 | ``` 226 | -------------------------------------------------------------------------------- /cmd/client.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/thegreenwebfoundation/grid-intensity-go/pkg/provider" 8 | ) 9 | 10 | func getClient(providerName string, cacheFile string) (provider.Interface, error) { 11 | var client provider.Interface 12 | var err error 13 | 14 | switch providerName { 15 | case provider.CarbonIntensityOrgUK: 16 | c := provider.CarbonIntensityUKConfig{} 17 | client, err = provider.NewCarbonIntensityUK(c) 18 | if err != nil { 19 | return nil, fmt.Errorf("could not make carbon intensity uk provider, %w", err) 20 | } 21 | case provider.ElectricityMaps: 22 | token := os.Getenv(electricityMapAPITokenEnvVar) 23 | if token == "" { 24 | return nil, fmt.Errorf("%q env var must be set", electricityMapAPITokenEnvVar) 25 | } 26 | url := os.Getenv(electricityMapAPIURLEnvVar) 27 | if token == "" { 28 | return nil, fmt.Errorf("%q env var must be set", electricityMapAPIURLEnvVar) 29 | } 30 | 31 | c := provider.ElectricityMapsConfig{ 32 | APIURL: url, 33 | Token: token, 34 | } 35 | client, err = provider.NewElectricityMaps(c) 36 | if err != nil { 37 | return nil, fmt.Errorf("could not make electricity maps provider, %w", err) 38 | } 39 | case provider.Ember: 40 | client, err = provider.NewEmber() 41 | if err != nil { 42 | return nil, fmt.Errorf("could not make ember provider, %w", err) 43 | } 44 | case provider.WattTime: 45 | user := os.Getenv(wattTimeUserEnvVar) 46 | if user == "" { 47 | return nil, fmt.Errorf("%q env var must be set", wattTimeUserEnvVar) 48 | } 49 | 50 | password := os.Getenv(wattTimePasswordEnvVar) 51 | if password == "" { 52 | return nil, fmt.Errorf("%q env var must be set", wattTimePasswordEnvVar) 53 | } 54 | 55 | c := provider.WattTimeConfig{ 56 | APIUser: user, 57 | APIPassword: password, 58 | CacheFile: cacheFile, 59 | } 60 | client, err = provider.NewWattTime(c) 61 | if err != nil { 62 | return nil, fmt.Errorf("could not make watt time provider, %w", err) 63 | } 64 | default: 65 | return nil, fmt.Errorf("provider %q not supported", providerName) 66 | } 67 | 68 | return client, nil 69 | } 70 | -------------------------------------------------------------------------------- /cmd/config.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "errors" 5 | "os" 6 | "path/filepath" 7 | 8 | "github.com/spf13/viper" 9 | ) 10 | 11 | const ( 12 | electricityMapAPITokenEnvVar = "ELECTRICITY_MAPS_API_TOKEN" 13 | electricityMapAPIURLEnvVar = "ELECTRICITY_MAPS_API_URL" 14 | wattTimeUserEnvVar = "WATT_TIME_USER" 15 | wattTimePasswordEnvVar = "WATT_TIME_PASSWORD" 16 | ) 17 | 18 | func getConfigFile() (string, error) { 19 | homeDir, err := os.UserHomeDir() 20 | if err != nil { 21 | return "", nil 22 | } 23 | 24 | return filepath.Join(homeDir, configDir, configFileName), nil 25 | } 26 | 27 | func configFileExists(configFile string) (bool, error) { 28 | _, err := os.Stat(configFile) 29 | if err == nil { 30 | return true, nil 31 | } 32 | if errors.Is(err, os.ErrNotExist) { 33 | return false, nil 34 | } 35 | 36 | return false, err 37 | } 38 | 39 | func readConfig(key string) (string, error) { 40 | configFile, err := getConfigFile() 41 | if err != nil { 42 | return "", err 43 | } 44 | 45 | viper.SetConfigFile(configFile) 46 | 47 | err = viper.ReadInConfig() 48 | if errors.Is(err, os.ErrNotExist) { 49 | // Config file may not be available e.g. when running as a container. 50 | } else if err != nil { 51 | return "", err 52 | } 53 | 54 | value := viper.GetString(key) 55 | return value, nil 56 | } 57 | 58 | func writeConfig() error { 59 | configFile, err := getConfigFile() 60 | if err != nil { 61 | return err 62 | } 63 | 64 | fileExists, err := configFileExists(configFile) 65 | if err != nil { 66 | return err 67 | } 68 | 69 | if !fileExists { 70 | // Create config dir if it doesn't exist. 71 | err = os.MkdirAll(filepath.Dir(configFile), os.ModePerm) 72 | if err != nil && !os.IsExist(err) { 73 | return nil 74 | } 75 | 76 | // Create config file if it doesn't exist. 77 | _, err = os.Create(configFile) 78 | if err != nil { 79 | // If we can't create file don't try to write config. 80 | return nil 81 | } 82 | } 83 | 84 | err = viper.WriteConfig() 85 | if err != nil { 86 | return err 87 | } 88 | 89 | return nil 90 | } 91 | -------------------------------------------------------------------------------- /cmd/exporter.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log" 7 | "net/http" 8 | "strconv" 9 | "strings" 10 | 11 | "github.com/prometheus/client_golang/prometheus" 12 | "github.com/prometheus/client_golang/prometheus/promhttp" 13 | "github.com/spf13/cobra" 14 | "github.com/spf13/viper" 15 | 16 | "github.com/thegreenwebfoundation/grid-intensity-go/pkg/provider" 17 | ) 18 | 19 | const ( 20 | labelLocation = "location" 21 | labelNode = "node" 22 | labelProvider = "provider" 23 | labelRegion = "region" 24 | labelUnits = "units" 25 | labelIsEstimated = "is_estimated" 26 | namespace = "grid_intensity" 27 | nodeKey = "node" 28 | regionKey = "region" 29 | ) 30 | 31 | func init() { 32 | exporterCmd.Flags().StringP(locationKey, "l", "", "Location codes for provider, for multiple locations separate with a comma") 33 | exporterCmd.Flags().StringP(nodeKey, "n", "", "Node where the exporter is running") 34 | exporterCmd.Flags().StringP(providerKey, "p", provider.Ember, "Provider of carbon intensity data") 35 | exporterCmd.Flags().StringP(regionKey, "r", "", "Region where the exporter is running") 36 | 37 | // Also support environment variables. 38 | viper.SetEnvPrefix("grid_intensity") 39 | viper.BindEnv(locationKey) 40 | viper.BindEnv(providerKey) 41 | viper.BindEnv(regionKey) 42 | viper.BindEnv(nodeKey) 43 | 44 | rootCmd.AddCommand(exporterCmd) 45 | } 46 | 47 | var ( 48 | averageDesc = prometheus.NewDesc( 49 | prometheus.BuildFQName(namespace, "carbon", "average"), 50 | "Average carbon intensity for the electricity grid in this location.", 51 | []string{ 52 | labelLocation, 53 | labelNode, 54 | labelProvider, 55 | labelRegion, 56 | labelUnits, 57 | labelIsEstimated, 58 | }, 59 | nil, 60 | ) 61 | 62 | marginalDesc = prometheus.NewDesc( 63 | prometheus.BuildFQName(namespace, "carbon", "marginal"), 64 | "Marginal carbon intensity for the electricity grid in this location.", 65 | []string{ 66 | labelLocation, 67 | labelNode, 68 | labelProvider, 69 | labelRegion, 70 | labelUnits, 71 | labelIsEstimated, 72 | }, 73 | nil, 74 | ) 75 | 76 | relativeDesc = prometheus.NewDesc( 77 | prometheus.BuildFQName(namespace, "carbon", "relative"), 78 | "Relative carbon intensity for the electricity grid in this location.", 79 | []string{ 80 | labelLocation, 81 | labelNode, 82 | labelProvider, 83 | labelRegion, 84 | labelUnits, 85 | labelIsEstimated, 86 | }, 87 | nil, 88 | ) 89 | 90 | exporterCmd = &cobra.Command{ 91 | Use: "exporter", 92 | Short: "Metrics for carbon intensity data for electricity grids", 93 | Long: `A prometheus exporter for getting the carbon intensity data for 94 | electricity grids. 95 | 96 | This can be used to make your software carbon aware so it runs at times when 97 | the grid is greener or at locations where carbon intensity is lower. 98 | 99 | grid-intensity exporter --provider Ember --location IE --region eu-west-1 --node worker-1 100 | grid-intensity exporter -p Ember -l BOL`, 101 | PreRun: func(cmd *cobra.Command, args []string) { 102 | viper.BindPFlag(locationKey, cmd.Flags().Lookup(locationKey)) 103 | viper.BindPFlag(nodeKey, cmd.Flags().Lookup(nodeKey)) 104 | viper.BindPFlag(providerKey, cmd.Flags().Lookup(providerKey)) 105 | viper.BindPFlag(regionKey, cmd.Flags().Lookup(regionKey)) 106 | }, 107 | Run: func(cmd *cobra.Command, args []string) { 108 | err := runExporter() 109 | if err != nil { 110 | log.Fatal(err) 111 | } 112 | }, 113 | } 114 | ) 115 | 116 | type Exporter struct { 117 | client provider.Interface 118 | location string 119 | node string 120 | provider string 121 | region string 122 | } 123 | 124 | type ExporterConfig struct { 125 | Location string 126 | Node string 127 | Provider string 128 | Region string 129 | } 130 | 131 | func NewExporter(config ExporterConfig) (*Exporter, error) { 132 | var client provider.Interface 133 | var err error 134 | 135 | if config.Location == "" { 136 | return nil, fmt.Errorf("location must be set") 137 | } 138 | 139 | // Cache filename is empty so we use in-memory cache. 140 | client, err = getClient(config.Provider, "") 141 | if err != nil { 142 | return nil, err 143 | } 144 | 145 | e := &Exporter{ 146 | client: client, 147 | location: config.Location, 148 | node: config.Node, 149 | provider: config.Provider, 150 | region: config.Region, 151 | } 152 | 153 | return e, nil 154 | } 155 | 156 | func (e *Exporter) Collect(ch chan<- prometheus.Metric) { 157 | ctx := context.Background() 158 | 159 | var result []provider.CarbonIntensity 160 | locationCodes := strings.Split(e.location, ",") 161 | 162 | for _, locationCode := range locationCodes { 163 | res, err := e.client.GetCarbonIntensity(ctx, locationCode) 164 | if err != nil { 165 | log.Printf("could not get carbon intensity for location %s, %#v", locationCode, err) 166 | } 167 | result = append(result, res...) 168 | } 169 | 170 | for _, data := range result { 171 | desc, err := getMetricDesc(data) 172 | if err != nil { 173 | log.Printf("failed to get metric description %#v", err) 174 | continue 175 | } 176 | 177 | ch <- prometheus.NewMetricWithTimestamp(data.ValidFrom, prometheus.MustNewConstMetric( 178 | desc, 179 | prometheus.GaugeValue, 180 | data.Value, 181 | data.Location, 182 | e.node, 183 | data.Provider, 184 | e.region, 185 | data.Units, 186 | strconv.FormatBool(data.IsEstimated), 187 | )) 188 | } 189 | } 190 | 191 | func (e *Exporter) Describe(ch chan<- *prometheus.Desc) { 192 | if e.provider == provider.WattTime { 193 | ch <- marginalDesc 194 | ch <- relativeDesc 195 | } else { 196 | ch <- averageDesc 197 | } 198 | } 199 | 200 | func getMetricDesc(data provider.CarbonIntensity) (*prometheus.Desc, error) { 201 | switch data.MetricType { 202 | case provider.AbsoluteMetricType: 203 | switch data.EmissionsType { 204 | case provider.AverageEmissionsType: 205 | return averageDesc, nil 206 | case provider.MarginalEmissionsType: 207 | return marginalDesc, nil 208 | default: 209 | return nil, fmt.Errorf("unknown emissions type %s", data.EmissionsType) 210 | } 211 | case provider.RelativeMetricType: 212 | return relativeDesc, nil 213 | } 214 | 215 | return nil, fmt.Errorf("unknown metric type %s", data.MetricType) 216 | } 217 | 218 | func runExporter() error { 219 | providerName, err := readConfig(providerKey) 220 | if err != nil { 221 | return err 222 | } 223 | locationCode, err := readConfig(locationKey) 224 | if err != nil { 225 | return err 226 | } 227 | node, err := readConfig(nodeKey) 228 | if err != nil { 229 | return err 230 | } 231 | region, err := readConfig(regionKey) 232 | if err != nil { 233 | return err 234 | } 235 | 236 | c := ExporterConfig{ 237 | Location: locationCode, 238 | Node: node, 239 | Provider: providerName, 240 | Region: region, 241 | } 242 | exporter, err := NewExporter(c) 243 | if err != nil { 244 | return err 245 | } 246 | 247 | err = writeConfig() 248 | if err != nil { 249 | return err 250 | } 251 | 252 | fmt.Printf("Using provider %q with location %q\n", providerName, locationCode) 253 | fmt.Println("Metrics available at :8000/metrics") 254 | 255 | prometheus.MustRegister(exporter) 256 | 257 | http.Handle("/metrics", promhttp.Handler()) 258 | log.Fatalln(http.ListenAndServe(":8000", nil)) 259 | 260 | return nil 261 | } 262 | -------------------------------------------------------------------------------- /cmd/provider.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "github.com/spf13/cobra" 5 | ) 6 | 7 | func init() { 8 | rootCmd.AddCommand(providerCmd) 9 | } 10 | 11 | var ( 12 | providerCmd = &cobra.Command{ 13 | Use: "provider", 14 | Short: "Details about providers of carbon intensity data", 15 | Long: "Details about providers of carbon intensity data", 16 | } 17 | ) 18 | -------------------------------------------------------------------------------- /cmd/provider_list.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "log" 5 | 6 | "github.com/rodaine/table" 7 | "github.com/spf13/cobra" 8 | 9 | "github.com/thegreenwebfoundation/grid-intensity-go/pkg/provider" 10 | ) 11 | 12 | func init() { 13 | providerCmd.AddCommand(providerListCmd) 14 | } 15 | 16 | var ( 17 | providerListCmd = &cobra.Command{ 18 | Use: "list", 19 | Short: "List supported providers of carbon intensity data", 20 | Long: `List all supported providers of carbon intensity data 21 | for electricity grids. 22 | 23 | grid-intensity provider list`, 24 | Run: func(cmd *cobra.Command, args []string) { 25 | err := runProviderList() 26 | if err != nil { 27 | log.Fatal(err) 28 | } 29 | }, 30 | } 31 | ) 32 | 33 | func runProviderList() error { 34 | providers := provider.GetProviderDetails() 35 | 36 | tbl := table.New("NAME", "URL") 37 | 38 | for _, p := range providers { 39 | tbl.AddRow(p.Name, p.URL) 40 | } 41 | 42 | tbl.Print() 43 | 44 | return nil 45 | } 46 | -------------------------------------------------------------------------------- /cmd/root.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "bufio" 5 | "context" 6 | "encoding/json" 7 | "fmt" 8 | "log" 9 | "os" 10 | "path/filepath" 11 | "strings" 12 | 13 | "github.com/Xuanwo/go-locale" 14 | "github.com/spf13/cobra" 15 | "github.com/spf13/viper" 16 | 17 | "github.com/thegreenwebfoundation/grid-intensity-go/pkg/provider" 18 | ) 19 | 20 | const ( 21 | cacheDir = ".cache/grid-intensity" 22 | configDir = ".config/grid-intensity" 23 | configFileName = "config.yaml" 24 | locationKey = "location" 25 | providerKey = "provider" 26 | wattTimeCacheFileName = "watttime.org.json" 27 | ) 28 | 29 | // rootCmd represents the base command when called without any subcommands 30 | var rootCmd = &cobra.Command{ 31 | Use: "grid-intensity", 32 | Short: "Get carbon intensity data for electricity grids", 33 | Long: `A tool for getting the carbon intensity data for electricity grids. 34 | 35 | This can be used to make your software carbon aware so it runs at times when the 36 | grid is greener or at locations where carbon intensity is lower. 37 | 38 | grid-intensity --provider Ember --location ARG 39 | grid-intensity -p Ember -l BOL`, 40 | 41 | PreRun: func(cmd *cobra.Command, args []string) { 42 | viper.BindPFlag(locationKey, cmd.Flags().Lookup(locationKey)) 43 | viper.BindPFlag(providerKey, cmd.Flags().Lookup(providerKey)) 44 | }, 45 | Run: func(cmd *cobra.Command, args []string) { 46 | err := runRoot() 47 | if err != nil { 48 | log.Fatal(err) 49 | } 50 | }, 51 | } 52 | 53 | func Execute() { 54 | err := rootCmd.Execute() 55 | if err != nil { 56 | os.Exit(1) 57 | } 58 | } 59 | 60 | func init() { 61 | rootCmd.Flags().StringP(locationKey, "l", "", "Location codes for provider, for multiple locations separate with a comma") 62 | rootCmd.Flags().StringP(providerKey, "p", provider.Ember, "Provider of carbon intensity data") 63 | 64 | // Also support environment variables. 65 | viper.SetEnvPrefix("grid_intensity") 66 | viper.BindEnv(locationKey) 67 | viper.BindEnv(providerKey) 68 | } 69 | 70 | // getCountryCode prompts the user to enter a country code. We try to detect 71 | // a country code from the user's locale but the user can enter another value. 72 | func getCountryCode() (string, error) { 73 | tag, err := locale.Detect() 74 | if err != nil { 75 | return "", err 76 | } 77 | 78 | region, _ := tag.Region() 79 | country := region.ISO3() 80 | 81 | fmt.Printf("Provider %s needs an ISO country code as a location parameter.\n", provider.Ember) 82 | if country != "" { 83 | fmt.Printf("%s detected from your locale.\n", country) 84 | } 85 | 86 | var reader = bufio.NewReader(os.Stdin) 87 | country, err = reader.ReadString('\n') 88 | if err != nil { 89 | return "", err 90 | } 91 | 92 | return strings.TrimSpace(country), nil 93 | } 94 | 95 | func runRoot() error { 96 | ctx := context.Background() 97 | 98 | providerName, err := readConfig(providerKey) 99 | if err != nil { 100 | return fmt.Errorf("could not read config for %#q, %w", providerKey, err) 101 | } 102 | locationCode, err := readConfig(locationKey) 103 | if err != nil { 104 | return fmt.Errorf("could not read config for %#q, %w", locationKey, err) 105 | } 106 | locationCodes := strings.Split(locationCode, ",") 107 | var cacheFile string 108 | 109 | switch providerName { 110 | case provider.CarbonIntensityOrgUK: 111 | if locationCodes[0] == "" { 112 | locationCodes[0] = "UK" 113 | } 114 | // Since only UK is supported with this Provider we expect the user to only provide one location. 115 | if locationCodes[0] != "UK" { 116 | return fmt.Errorf("only location UK is supported") 117 | } 118 | viper.Set(locationKey, locationCodes) 119 | case provider.Ember: 120 | if len(locationCodes) == 0 { 121 | // Default to the user's locale if no LocationCodes are provided 122 | locationCodes[0], err = getCountryCode() 123 | if err != nil { 124 | return err 125 | } 126 | viper.Set(locationKey, locationCodes) 127 | } 128 | case provider.WattTime: 129 | homeDir, err := os.UserHomeDir() 130 | if err != nil { 131 | return nil 132 | } 133 | // Use file cache to avoid API rate limiting. 134 | cacheFile = filepath.Join(homeDir, cacheDir, wattTimeCacheFileName) 135 | } 136 | 137 | client, err := getClient(providerName, cacheFile) 138 | if err != nil { 139 | return fmt.Errorf("could not get client, %w", err) 140 | } 141 | 142 | var result []provider.CarbonIntensity 143 | for _, locationCode := range locationCodes { 144 | res, err := client.GetCarbonIntensity(ctx, locationCode) 145 | if err != nil { 146 | return fmt.Errorf("could not get carbon intensity for location %s, %w", locationCode, err) 147 | } 148 | result = append(result, res...) 149 | } 150 | 151 | bytes, err := json.MarshalIndent(result, "", "\t") 152 | if err != nil { 153 | return fmt.Errorf("could not marshal json, %w", err) 154 | } 155 | fmt.Println(string(bytes)) 156 | 157 | err = writeConfig() 158 | if err != nil { 159 | return fmt.Errorf("could not write config, %w", err) 160 | } 161 | 162 | return nil 163 | } 164 | -------------------------------------------------------------------------------- /docs/github_app.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thegreenwebfoundation/grid-intensity-go/e9456c2ed94f5124e0aae0bb9b269eaafbaf6a97/docs/github_app.png -------------------------------------------------------------------------------- /docs/github_app_id.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thegreenwebfoundation/grid-intensity-go/e9456c2ed94f5124e0aae0bb9b269eaafbaf6a97/docs/github_app_id.png -------------------------------------------------------------------------------- /docs/github_app_install.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thegreenwebfoundation/grid-intensity-go/e9456c2ed94f5124e0aae0bb9b269eaafbaf6a97/docs/github_app_install.png -------------------------------------------------------------------------------- /docs/github_app_install_repo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thegreenwebfoundation/grid-intensity-go/e9456c2ed94f5124e0aae0bb9b269eaafbaf6a97/docs/github_app_install_repo.png -------------------------------------------------------------------------------- /docs/github_app_private_key.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thegreenwebfoundation/grid-intensity-go/e9456c2ed94f5124e0aae0bb9b269eaafbaf6a97/docs/github_app_private_key.png -------------------------------------------------------------------------------- /docs/github_repo_secret.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thegreenwebfoundation/grid-intensity-go/e9456c2ed94f5124e0aae0bb9b269eaafbaf6a97/docs/github_repo_secret.png -------------------------------------------------------------------------------- /docs/homebrew.md: -------------------------------------------------------------------------------- 1 | # Homebrew Setup 2 | 3 | The homebrew tap is located at https://github.com/thegreenwebfoundation/homebrew-carbon-aware-tools 4 | 5 | ## Permissions 6 | 7 | The GoReleaser GitHub Action uses the secrets `HOMEBREW_TAP_GITHUB_APP_ID` and 8 | `HOMEBREW_TAP_GITHUB_APP_PEM` to generate an installation token which 9 | expires after 1 hour. 10 | 11 | This is used to authenticate with the GitHub API when publishing to the 12 | `homebrew-carbon-aware-tools` repo. 13 | 14 | To restrict access to just this repo a GitHub App has been created that can 15 | write to this repo. 16 | 17 | ## Setup 18 | 19 | - The GitHub App is named `Grid Intensity App O Tron` and can be found in 20 | the settings of the `thegreenwebfoundation` organization. 21 | 22 | ![GitHub App](github_app.png) 23 | 24 | - To create the installation token we need to create a [private key](https://docs.github.com/en/developers/apps/building-github-apps/authenticating-with-github-apps) 25 | for the app. (This can be found in 1 Password). 26 | 27 | ![GitHub App private key](github_app_private_key.png) 28 | 29 | - The app needs to be installed in the `thegreenwebfoundation` org for just the 30 | `grid-intensity-go` repository. 31 | 32 | ![GitHub App installation](github_app_install.png) 33 | ![GitHub App installation for repo](github_app_install_repo.png) 34 | 35 | - Add a secret named `HOMEBREW_TAP_GITHUB_APP_ID` with the app ID. 36 | 37 | ![GitHub App](github_app_id.png) 38 | 39 | - Add a secret named `HOMEBREW_TAP_GITHUB_APP_PEM` with the full contents of the 40 | private key file. Note: This file should NOT be base64 encoded. 41 | 42 | ![GitHub secret with token](github_repo_secret.png) 43 | -------------------------------------------------------------------------------- /docs/process_ember_data.md: -------------------------------------------------------------------------------- 1 | # Overview 2 | 3 | For the Ember provider we embed their dataset in the grid-intensity CLI. The data 4 | uses ISO 3 char country codes. However we want to also support 2 char ISO codes. 5 | 6 | Ideally this will be added by Ember in a future release of their data. Until then 7 | we can use a simple Go program in the `hack` directory to map the country codes. 8 | 9 | ## Processing the data 10 | 11 | - From the root of this repo call the program. 12 | 13 | ``` 14 | go run hack/country_codes.go ember-input.csv > ember-output.csv 15 | ``` 16 | 17 | - Update the data file in the `ember` directory. e.g. co2-intensities-ember-2021.csv 18 | - If the data has already been processed an error will be returned. 19 | 20 | ``` 21 | go run hack/country_codes.go /tmp/ember-output.csv 22 | panic: data already processed - `country_code_iso_2` should not be present 23 | ``` 24 | -------------------------------------------------------------------------------- /docs/release.md: -------------------------------------------------------------------------------- 1 | # How to create a release 2 | 3 | - Ensure [CHANGELOG.md](/CHANGELOG.md) is up to date. 4 | - Add heading with version and release date. e.g. `## 0.1.0 2022-06-21` 5 | - Push a git tag with the version you wish to release. 6 | 7 | ``` 8 | git tag -a v0.1.0 -m "v0.1.0" 9 | ``` 10 | 11 | - Once the release is created edit the description to add the changelog text. 12 | - Note: The release description will already have the commits added by GoReleaser. 13 | 14 | ## GoReleaser 15 | 16 | This project uses [GoReleaser](https://github.com/goreleaser/goreleaser) to publish 17 | GitHub releases with binaries for Linux, Mac and Windows. 18 | 19 | ## Homebrew 20 | 21 | GoReleaser also publishes `grid-intensity` to a [Homebrew Tap](https://docs.brew.sh/Taps) at https://github.com/thegreenwebfoundation/homebrew-carbon-aware-tools 22 | 23 | See [homebrew.md](homebrew.md) for more details. 24 | -------------------------------------------------------------------------------- /examples/carbonintensityuk/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "log" 8 | 9 | "github.com/thegreenwebfoundation/grid-intensity-go/pkg/provider" 10 | ) 11 | 12 | func main() { 13 | c := provider.CarbonIntensityUKConfig{} 14 | p, err := provider.NewCarbonIntensityUK(c) 15 | if err != nil { 16 | log.Fatalln("could not make provider", err) 17 | } 18 | 19 | res, err := p.GetCarbonIntensity(context.Background(), "UK") 20 | if err != nil { 21 | log.Fatalln("could not get carbon intensity", err) 22 | } 23 | 24 | bytes, err := json.MarshalIndent(res, "", "\t") 25 | if err != nil { 26 | log.Fatalln("could not get carbon intensity", err) 27 | } 28 | 29 | fmt.Println(string(bytes)) 30 | } 31 | -------------------------------------------------------------------------------- /examples/electricitymap/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "log" 8 | "os" 9 | 10 | "github.com/thegreenwebfoundation/grid-intensity-go/pkg/provider" 11 | ) 12 | 13 | func main() { 14 | // Register at https://api-portal.electricitymaps.com/ 15 | token := os.Getenv("ELECTRICITY_MAPS_API_TOKEN") 16 | if token == "" { 17 | log.Fatalln("please set the env variable `ELECTRICITY_MAPS_API_TOKEN`") 18 | } 19 | url := os.Getenv("ELECTRICITY_MAPS_API_URL") 20 | if url != "" { 21 | log.Fatalln("please set the env variable `ELECTRICITY_MAPS_API_URL`") 22 | } 23 | 24 | c := provider.ElectricityMapsConfig{ 25 | APIURL: url, 26 | Token: token, 27 | } 28 | e, err := provider.NewElectricityMaps(c) 29 | if err != nil { 30 | log.Fatalln("could not make provider", err) 31 | } 32 | 33 | res, err := e.GetCarbonIntensity(context.Background(), "AU-SA") 34 | if err != nil { 35 | log.Fatalln("could not get carbon intensity", err) 36 | } 37 | 38 | bytes, err := json.MarshalIndent(res, "", "\t") 39 | if err != nil { 40 | log.Fatalln("could not get carbon intensity", err) 41 | } 42 | 43 | fmt.Println(string(bytes)) 44 | } 45 | -------------------------------------------------------------------------------- /examples/ember/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "log" 8 | 9 | "github.com/thegreenwebfoundation/grid-intensity-go/pkg/provider" 10 | ) 11 | 12 | func main() { 13 | p, err := provider.NewEmber() 14 | if err != nil { 15 | log.Fatalln("could not make provider", err) 16 | } 17 | result, err := p.GetCarbonIntensity(context.Background(), "ESP") 18 | if err != nil { 19 | log.Fatalln("could not get carbon intesity", err) 20 | } 21 | 22 | bytes, err := json.MarshalIndent(result, "", "\t") 23 | if err != nil { 24 | log.Fatalln("could not get carbon intensity", err) 25 | } 26 | 27 | fmt.Println(string(bytes)) 28 | } 29 | -------------------------------------------------------------------------------- /examples/watttime/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "log" 8 | "os" 9 | 10 | "github.com/thegreenwebfoundation/grid-intensity-go/pkg/provider" 11 | ) 12 | 13 | func main() { 14 | // Register via the API 15 | // https://www.watttime.org/api-documentation/#register-new-user 16 | apiUser := os.Getenv("WATT_TIME_API_USER") 17 | if apiUser == "" { 18 | log.Fatalln("please set the env variable `WATT_TIME_API_USER`") 19 | } 20 | apiPassword := os.Getenv("WATT_TIME_API_PASSWORD") 21 | if apiPassword != "" { 22 | log.Fatalln("please set the env variable `WATT_TIME_API_PASSWORD`") 23 | } 24 | 25 | c := provider.WattTimeConfig{ 26 | APIUser: apiUser, 27 | APIPassword: apiPassword, 28 | } 29 | w, err := provider.NewWattTime(c) 30 | if err != nil { 31 | log.Fatalln("could not make provider", err) 32 | } 33 | 34 | res, err := w.GetCarbonIntensity(context.Background(), "CAISO_NORTH") 35 | if err != nil { 36 | log.Fatalln("could not get carbon intensity", err) 37 | } 38 | 39 | bytes, err := json.MarshalIndent(res, "", "\t") 40 | if err != nil { 41 | log.Fatalln("could not get carbon intensity", err) 42 | } 43 | 44 | fmt.Println(string(bytes)) 45 | } 46 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/thegreenwebfoundation/grid-intensity-go 2 | 3 | go 1.20 4 | 5 | require ( 6 | github.com/Xuanwo/go-locale v1.1.0 7 | github.com/cenkalti/backoff/v4 v4.2.1 8 | github.com/gofrs/flock v0.8.1 9 | github.com/google/go-cmp v0.6.0 10 | github.com/jellydator/ttlcache/v2 v2.11.1 11 | github.com/prometheus/client_golang v1.15.1 12 | github.com/rodaine/table v1.1.0 13 | github.com/spf13/cobra v1.7.0 14 | github.com/spf13/viper v1.15.0 15 | golang.org/x/exp v0.0.0-20240529005216-23cca8864a10 16 | ) 17 | 18 | require ( 19 | github.com/beorn7/perks v1.0.1 // indirect 20 | github.com/cespare/xxhash/v2 v2.2.0 // indirect 21 | github.com/fsnotify/fsnotify v1.6.0 // indirect 22 | github.com/golang/protobuf v1.5.3 // indirect 23 | github.com/hashicorp/hcl v1.0.0 // indirect 24 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 25 | github.com/magiconair/properties v1.8.7 // indirect 26 | github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect 27 | github.com/mitchellh/mapstructure v1.5.0 // indirect 28 | github.com/pelletier/go-toml/v2 v2.0.7 // indirect 29 | github.com/prometheus/client_model v0.4.0 // indirect 30 | github.com/prometheus/common v0.43.0 // indirect 31 | github.com/prometheus/procfs v0.9.0 // indirect 32 | github.com/spf13/afero v1.9.5 // indirect 33 | github.com/spf13/cast v1.5.0 // indirect 34 | github.com/spf13/jwalterweatherman v1.1.0 // indirect 35 | github.com/spf13/pflag v1.0.5 // indirect 36 | github.com/subosito/gotenv v1.4.2 // indirect 37 | golang.org/x/lint v0.0.0-20210508222113-6edffad5e616 // indirect 38 | golang.org/x/sync v0.7.0 // indirect 39 | golang.org/x/sys v0.8.0 // indirect 40 | golang.org/x/text v0.9.0 // indirect 41 | google.golang.org/protobuf v1.30.0 // indirect 42 | gopkg.in/ini.v1 v1.67.0 // indirect 43 | gopkg.in/yaml.v3 v3.0.1 // indirect 44 | ) 45 | -------------------------------------------------------------------------------- /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.44.3/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY= 7 | cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc= 8 | cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0= 9 | cloud.google.com/go v0.50.0/go.mod h1:r9sluTvynVuxRIOHXQEHMFffphuXHOMZMycpNR5e6To= 10 | cloud.google.com/go v0.52.0/go.mod h1:pXajvRH/6o3+F9jDHZWQ5PbGhn+o8w9qiu/CffaVdO4= 11 | cloud.google.com/go v0.53.0/go.mod h1:fp/UouUEsRkN6ryDKNW/Upv/JBKnv6WDthjR6+vze6M= 12 | cloud.google.com/go v0.54.0/go.mod h1:1rq2OEkV3YMf6n/9ZvGWI3GWw0VoqH/1x2nd8Is/bPc= 13 | cloud.google.com/go v0.56.0/go.mod h1:jr7tqZxxKOVYizybht9+26Z/gUq7tiRzu+ACVAMbKVk= 14 | cloud.google.com/go v0.57.0/go.mod h1:oXiQ6Rzq3RAkkY7N6t3TcE6jE+CIBBbA36lwQ1JyzZs= 15 | cloud.google.com/go v0.62.0/go.mod h1:jmCYTdRCQuc1PHIIJ/maLInMho30T/Y0M4hTdTShOYc= 16 | cloud.google.com/go v0.65.0/go.mod h1:O5N8zS7uWy9vkA9vayVHs65eM1ubvY4h553ofrNHObY= 17 | cloud.google.com/go v0.72.0/go.mod h1:M+5Vjvlc2wnp6tjzE102Dw08nGShTscUx2nZMufOKPI= 18 | cloud.google.com/go v0.74.0/go.mod h1:VV1xSbzvo+9QJOxLDaJfTjx5e+MePCpCWwvftOeQmWk= 19 | cloud.google.com/go v0.75.0/go.mod h1:VGuuCn7PG0dwsd5XPVm2Mm3wlh3EL55/79EKB6hlPTY= 20 | cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o= 21 | cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE= 22 | cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc= 23 | cloud.google.com/go/bigquery v1.5.0/go.mod h1:snEHRnqQbz117VIFhE8bmtwIDY80NLUZUMb4Nv6dBIg= 24 | cloud.google.com/go/bigquery v1.7.0/go.mod h1://okPTzCYNXSlb24MZs83e2Do+h+VXtc4gLoIoXIAPc= 25 | cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ= 26 | cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE= 27 | cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk= 28 | cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I= 29 | cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw= 30 | cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA= 31 | cloud.google.com/go/pubsub v1.3.1/go.mod h1:i+ucay31+CNRpDW4Lu78I4xXG+O1r/MAHgjpRVR+TSU= 32 | cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw= 33 | cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos= 34 | cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk= 35 | cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs= 36 | cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0= 37 | cloud.google.com/go/storage v1.14.0/go.mod h1:GrKmX003DSIwi9o29oFT7YDnHYwZoctc3fOKtUw0Xmo= 38 | dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= 39 | github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= 40 | github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= 41 | github.com/Xuanwo/go-locale v1.1.0 h1:51gUxhxl66oXAjI9uPGb2O0qwPECpriKQb2hl35mQkg= 42 | github.com/Xuanwo/go-locale v1.1.0/go.mod h1:UKrHoZB3FPIk9wIG2/tVSobnHgNnceGSH3Y8DY5cASs= 43 | github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= 44 | github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= 45 | github.com/cenkalti/backoff/v4 v4.2.1 h1:y4OZtCnogmCPw98Zjyt5a6+QwPLGkiQsYW5oUqylYbM= 46 | github.com/cenkalti/backoff/v4 v4.2.1/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= 47 | github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= 48 | github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= 49 | github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 50 | github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= 51 | github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= 52 | github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= 53 | github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= 54 | github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= 55 | github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= 56 | github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= 57 | github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= 58 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 59 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 60 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 61 | github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= 62 | github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= 63 | github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= 64 | github.com/envoyproxy/go-control-plane v0.9.7/go.mod h1:cwu0lG7PUMfa9snN8LXBig5ynNVH9qI8YYLbd1fK2po= 65 | github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk= 66 | github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= 67 | github.com/frankban/quicktest v1.14.3 h1:FJKSZTDHjyhriyC81FLQ0LY93eSai0ZyR/ZIkd3ZUKE= 68 | github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4HY= 69 | github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw= 70 | github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= 71 | github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= 72 | github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= 73 | github.com/go-logfmt/logfmt v0.5.1/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs= 74 | github.com/gofrs/flock v0.8.1 h1:+gYjHKf32LDeiEEFhQaotPbLuUXjY5ZqxKgXy7n59aw= 75 | github.com/gofrs/flock v0.8.1/go.mod h1:F1TvTiK9OcQqauNUHlbJvyl9Qa1QvF/gOUDKA14jxHU= 76 | github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= 77 | github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= 78 | github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= 79 | github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= 80 | github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= 81 | github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= 82 | github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y= 83 | github.com/golang/mock v1.4.0/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= 84 | github.com/golang/mock v1.4.1/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= 85 | github.com/golang/mock v1.4.3/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= 86 | github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4= 87 | github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 88 | github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 89 | github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 90 | github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= 91 | github.com/golang/protobuf v1.3.4/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= 92 | github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk= 93 | github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= 94 | github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= 95 | github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= 96 | github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= 97 | github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= 98 | github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= 99 | github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= 100 | github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= 101 | github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= 102 | github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= 103 | github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= 104 | github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= 105 | github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= 106 | github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= 107 | github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= 108 | github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= 109 | github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 110 | github.com/google/go-cmp v0.4.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 111 | github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 112 | github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 113 | github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 114 | github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 115 | github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 116 | github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= 117 | github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 118 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 119 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 120 | github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= 121 | github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= 122 | github.com/google/martian/v3 v3.1.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= 123 | github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= 124 | github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= 125 | github.com/google/pprof v0.0.0-20191218002539-d4f498aebedc/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= 126 | github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= 127 | github.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= 128 | github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= 129 | github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= 130 | github.com/google/pprof v0.0.0-20201023163331-3e6fc7fc9c4c/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= 131 | github.com/google/pprof v0.0.0-20201203190320-1bf35d6f28c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= 132 | github.com/google/pprof v0.0.0-20201218002935-b9804c9f04c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= 133 | github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= 134 | github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 135 | github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= 136 | github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= 137 | github.com/googleapis/google-cloud-go-testing v0.0.0-20200911160855-bcd43fbb19e8/go.mod h1:dvDLG8qkwmyD9a/MJJN3XJcT3xFxOKAvTZGvuZmac9g= 138 | github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8= 139 | github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= 140 | github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= 141 | github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= 142 | github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= 143 | github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= 144 | github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= 145 | github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= 146 | github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= 147 | github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 148 | github.com/jellydator/ttlcache/v2 v2.11.1 h1:AZGME43Eh2Vv3giG6GeqeLeFXxwxn1/qHItqWZl6U64= 149 | github.com/jellydator/ttlcache/v2 v2.11.1/go.mod h1:RtE5Snf0/57e+2cLWFYWCCsLas2Hy3c5Z4n14XmSvTI= 150 | github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4= 151 | github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= 152 | github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= 153 | github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk= 154 | github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= 155 | github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= 156 | github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM= 157 | github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= 158 | github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= 159 | github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= 160 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 161 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 162 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 163 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 164 | github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= 165 | github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= 166 | github.com/mattn/go-runewidth v0.0.9 h1:Lm995f3rfxdpd6TSmuVCHVb/QhupuXlYr8sCI/QdE+0= 167 | github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= 168 | github.com/matttproud/golang_protobuf_extensions v1.0.4 h1:mmDVorXM7PCGKw94cs5zkfA9PSy5pEvNWRP0ET0TIVo= 169 | github.com/matttproud/golang_protobuf_extensions v1.0.4/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4= 170 | github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= 171 | github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= 172 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 173 | github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= 174 | github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= 175 | github.com/pelletier/go-toml/v2 v2.0.7 h1:muncTPStnKRos5dpVKULv2FVd4bMOhNePj9CjgDb8Us= 176 | github.com/pelletier/go-toml/v2 v2.0.7/go.mod h1:eumQOmlWiOPt5WriQQqoM5y18pDHwha2N+QD+EUNTek= 177 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 178 | github.com/pkg/sftp v1.13.1/go.mod h1:3HaPG6Dq1ILlpPZRO0HVMrsydcdLt6HRDccSgb87qRg= 179 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 180 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 181 | github.com/prometheus/client_golang v1.15.1 h1:8tXpTmJbyH5lydzFPoxSIJ0J46jdh3tylbvM1xCv0LI= 182 | github.com/prometheus/client_golang v1.15.1/go.mod h1:e9yaBhRPU2pPNsZwE+JdQl0KEt1N9XgF6zxWmaC0xOk= 183 | github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= 184 | github.com/prometheus/client_model v0.4.0 h1:5lQXD3cAg1OXBf4Wq03gTrXHeaV0TQvGfUooCfx1yqY= 185 | github.com/prometheus/client_model v0.4.0/go.mod h1:oMQmHW1/JoDwqLtg57MGgP/Fb1CJEYF2imWWhWtMkYU= 186 | github.com/prometheus/common v0.43.0 h1:iq+BVjvYLei5f27wiuNiB1DN6DYQkp1c8Bx0Vykh5us= 187 | github.com/prometheus/common v0.43.0/go.mod h1:NCvr5cQIh3Y/gy73/RdVtC9r8xxrxwJnB+2lB3BxrFc= 188 | github.com/prometheus/procfs v0.9.0 h1:wzCHvIvM5SxWqYvwgVL7yJY8Lz3PKn49KQtpgMYJfhI= 189 | github.com/prometheus/procfs v0.9.0/go.mod h1:+pB4zwohETzFnmlpe6yd2lSc+0/46IYZRB/chUwxUZY= 190 | github.com/rodaine/table v1.1.0 h1:/fUlCSdjamMY8VifdQRIu3VWZXYLY7QHFkVorS8NTr4= 191 | github.com/rodaine/table v1.1.0/go.mod h1:Qu3q5wi1jTQD6B6HsP6szie/S4w1QUQ8pq22pz9iL8g= 192 | github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= 193 | github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= 194 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 195 | github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d h1:zE9ykElWQ6/NYmHa3jpm/yHnI4xSofP+UP6SpjHcSeM= 196 | github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= 197 | github.com/smartystreets/goconvey v1.6.7 h1:I6tZjLXD2Q1kjvNbIzB1wvQBsXmKXiVrhpRE8ZjP5jY= 198 | github.com/smartystreets/goconvey v1.6.7/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= 199 | github.com/spf13/afero v1.9.5 h1:stMpOSZFs//0Lv29HduCmli3GUfpFoF3Y1Q/aXj/wVM= 200 | github.com/spf13/afero v1.9.5/go.mod h1:UBogFpq8E9Hx+xc5CNTTEpTnuHVmXDwZcZcE1eb/UhQ= 201 | github.com/spf13/cast v1.5.0 h1:rj3WzYc11XZaIZMPKmwP96zkFEnnAmV8s6XbB2aY32w= 202 | github.com/spf13/cast v1.5.0/go.mod h1:SpXXQ5YoyJw6s3/6cMTQuxvgRl3PCJiyaX9p6b155UU= 203 | github.com/spf13/cobra v1.7.0 h1:hyqWnYt1ZQShIddO5kBpj3vu05/++x6tJ6dg8EC572I= 204 | github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0= 205 | github.com/spf13/jwalterweatherman v1.1.0 h1:ue6voC5bR5F8YxI5S67j9i582FU4Qvo2bmqnqMYADFk= 206 | github.com/spf13/jwalterweatherman v1.1.0/go.mod h1:aNWZUN0dPAAO/Ljvb5BEdw96iTZ0EXowPYD95IqWIGo= 207 | github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= 208 | github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 209 | github.com/spf13/viper v1.15.0 h1:js3yy885G8xwJa6iOISGFwd+qlUo5AvyXb7CiihdtiU= 210 | github.com/spf13/viper v1.15.0/go.mod h1:fFcTBJxvhhzSJiZy8n+PeW6t8l+KeT/uTARa0jHOQLA= 211 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 212 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 213 | github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= 214 | github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= 215 | github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= 216 | github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= 217 | github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 218 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 219 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 220 | github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= 221 | github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= 222 | github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= 223 | github.com/subosito/gotenv v1.4.2 h1:X1TuBLAMDFbaTAChgCBLu3DU3UPyELpnF2jjJ2cz/S8= 224 | github.com/subosito/gotenv v1.4.2/go.mod h1:ayKnFf/c6rvx/2iiLrJUk1e6plDbT3edrFNGqEflhK0= 225 | github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 226 | github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 227 | github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 228 | github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 229 | go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU= 230 | go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8= 231 | go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= 232 | go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= 233 | go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= 234 | go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk= 235 | go.uber.org/goleak v1.1.10 h1:z+mqJhf6ss6BSfSM671tgKyZBFPTTJM+HLxnhPC3wu0= 236 | go.uber.org/goleak v1.1.10/go.mod h1:8a7PlsEVH3e/a/GLqe5IIrQx6GzcnRmZEufDUTk4A7A= 237 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 238 | golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 239 | golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 240 | golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 241 | golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 242 | golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= 243 | golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= 244 | golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= 245 | golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= 246 | golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= 247 | golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek= 248 | golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY= 249 | golang.org/x/exp v0.0.0-20191129062945-2f5052295587/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= 250 | golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= 251 | golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= 252 | golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM= 253 | golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU= 254 | golang.org/x/exp v0.0.0-20240529005216-23cca8864a10 h1:vpzMC/iZhYFAjJzHU0Cfuq+w1vLLsF2vLkDrPjzKYck= 255 | golang.org/x/exp v0.0.0-20240529005216-23cca8864a10/go.mod h1:XtvwrStGgqGPLc4cjQfWqZHG1YFdYs6swckp8vpsjnc= 256 | golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= 257 | golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= 258 | golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= 259 | golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= 260 | golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= 261 | golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= 262 | golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= 263 | golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= 264 | golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= 265 | golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRuDixDT3tpyyb+LUpUlRWLxfhWrs= 266 | golang.org/x/lint v0.0.0-20200130185559-910be7a94367/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= 267 | golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= 268 | golang.org/x/lint v0.0.0-20201208152925-83fdc39ff7b5/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= 269 | golang.org/x/lint v0.0.0-20210508222113-6edffad5e616 h1:VLliZ0d+/avPrXXH+OakdXhpJuEoBZuwh1m2j7U6Iug= 270 | golang.org/x/lint v0.0.0-20210508222113-6edffad5e616/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= 271 | golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE= 272 | golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o= 273 | golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= 274 | golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY= 275 | golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= 276 | golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= 277 | golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 278 | golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 279 | golang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 280 | golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 281 | golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 282 | golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 283 | golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 284 | golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 285 | golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 286 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 287 | golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 288 | golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 289 | golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= 290 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 291 | golang.org/x/net v0.0.0-20190628185345-da137c7871d7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 292 | golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 293 | golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 294 | golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 295 | golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 296 | golang.org/x/net v0.0.0-20200222125558-5a598a2470a0/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 297 | golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 298 | golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 299 | golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= 300 | golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= 301 | golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= 302 | golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= 303 | golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= 304 | golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= 305 | golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= 306 | golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= 307 | golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= 308 | golang.org/x/net v0.0.0-20201031054903-ff519b6c9102/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= 309 | golang.org/x/net v0.0.0-20201209123823-ac852fbbde11/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 310 | golang.org/x/net v0.0.0-20201224014010-6772e930b67b/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 311 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 312 | golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= 313 | golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= 314 | golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= 315 | golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= 316 | golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= 317 | golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= 318 | golang.org/x/oauth2 v0.0.0-20200902213428-5d25da1a8d43/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= 319 | golang.org/x/oauth2 v0.0.0-20201109201403-9fd604954f58/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= 320 | golang.org/x/oauth2 v0.0.0-20201208152858-08078c50e5b5/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= 321 | golang.org/x/oauth2 v0.0.0-20210218202405-ba52d332ba99/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= 322 | golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 323 | golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 324 | golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 325 | golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 326 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 327 | golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 328 | golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 329 | golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 330 | golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 331 | golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 332 | golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 333 | golang.org/x/sync v0.2.0 h1:PUR+T4wwASmuSTYdKjYHI5TD22Wy5ogLU5qZCOLxBrI= 334 | golang.org/x/sync v0.2.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 335 | golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= 336 | golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 337 | golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 338 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 339 | golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 340 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 341 | golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 342 | golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 343 | golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 344 | golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 345 | golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 346 | golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 347 | golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 348 | golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 349 | golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 350 | golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 351 | golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 352 | golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 353 | golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 354 | golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 355 | golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 356 | golang.org/x/sys v0.0.0-20200331124033-c3d80250170d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 357 | golang.org/x/sys v0.0.0-20200501052902-10377860bb8e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 358 | golang.org/x/sys v0.0.0-20200511232937-7e40ca221e25/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 359 | golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 360 | golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 361 | golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 362 | golang.org/x/sys v0.0.0-20200905004654-be1d3432aa8f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 363 | golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 364 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 365 | golang.org/x/sys v0.0.0-20201201145000-ef89a241ccb3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 366 | golang.org/x/sys v0.0.0-20210104204734-6f8348627aad/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 367 | golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 368 | golang.org/x/sys v0.0.0-20210225134936-a50acf3fe073/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 369 | golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 370 | golang.org/x/sys v0.0.0-20210423185535-09eb48e85fd7/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 371 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 372 | golang.org/x/sys v0.0.0-20211023085530-d6a326fbbf70/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 373 | golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 374 | golang.org/x/sys v0.8.0 h1:EBmGv8NaZBZTWvrbjNoL6HVt+IVy3QDQpJs7VRIw3tU= 375 | golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 376 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 377 | golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 378 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 379 | golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 380 | golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= 381 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 382 | golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 383 | golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 384 | golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 385 | golang.org/x/text v0.9.0 h1:2sjJmO8cDvYveuX97RDLsxlyUxLl+GHoLxBiRdHllBE= 386 | golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= 387 | golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= 388 | golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= 389 | golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= 390 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 391 | golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 392 | golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= 393 | golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= 394 | golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= 395 | golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= 396 | golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= 397 | golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= 398 | golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= 399 | golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= 400 | golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= 401 | golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= 402 | golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= 403 | golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 404 | golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 405 | golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 406 | golang.org/x/tools v0.0.0-20191108193012-7d206e10da11/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 407 | golang.org/x/tools v0.0.0-20191113191852-77e3bb0ad9e7/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 408 | golang.org/x/tools v0.0.0-20191115202509-3a792d9c32b2/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 409 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 410 | golang.org/x/tools v0.0.0-20191125144606-a911d9008d1f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 411 | golang.org/x/tools v0.0.0-20191130070609-6e064ea0cf2d/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 412 | golang.org/x/tools v0.0.0-20191216173652-a0e659d51361/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= 413 | golang.org/x/tools v0.0.0-20191227053925-7b8e75db28f4/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= 414 | golang.org/x/tools v0.0.0-20200117161641-43d50277825c/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= 415 | golang.org/x/tools v0.0.0-20200122220014-bf1340f18c4a/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= 416 | golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= 417 | golang.org/x/tools v0.0.0-20200204074204-1cc6d1ef6c74/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= 418 | golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= 419 | golang.org/x/tools v0.0.0-20200212150539-ea181f53ac56/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= 420 | golang.org/x/tools v0.0.0-20200224181240-023911ca70b2/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= 421 | golang.org/x/tools v0.0.0-20200227222343-706bc42d1f0d/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= 422 | golang.org/x/tools v0.0.0-20200304193943-95d2e580d8eb/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw= 423 | golang.org/x/tools v0.0.0-20200312045724-11d5b4c81c7d/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw= 424 | golang.org/x/tools v0.0.0-20200331025713-a30bf2db82d4/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8= 425 | golang.org/x/tools v0.0.0-20200501065659-ab2804fb9c9d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= 426 | golang.org/x/tools v0.0.0-20200512131952-2bc93b1c0c88/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= 427 | golang.org/x/tools v0.0.0-20200515010526-7d3b6ebf133d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= 428 | golang.org/x/tools v0.0.0-20200618134242-20370b0cb4b2/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= 429 | golang.org/x/tools v0.0.0-20200729194436-6467de6f59a7/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= 430 | golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= 431 | golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= 432 | golang.org/x/tools v0.0.0-20200904185747-39188db58858/go.mod h1:Cj7w3i3Rnn0Xh82ur9kSqwfTHTeVxaDqrfMjpcNT6bE= 433 | golang.org/x/tools v0.0.0-20201110124207-079ba7bd75cd/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= 434 | golang.org/x/tools v0.0.0-20201201161351-ac6f37ff4c2a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= 435 | golang.org/x/tools v0.0.0-20201208233053-a543418bbed2/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= 436 | golang.org/x/tools v0.0.0-20210105154028-b0ab187a4818/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= 437 | golang.org/x/tools v0.0.0-20210108195828-e2f9c7f1fc8e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= 438 | golang.org/x/tools v0.0.0-20210112230658-8b4aab62c064/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= 439 | golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0= 440 | golang.org/x/tools v0.6.0 h1:BOw41kyTf3PuCW1pVQf8+Cyg8pMlkYB1oo9iJ6D/lKM= 441 | golang.org/x/tools v0.21.0 h1:qc0xYgIbsSDt9EyWz05J5wfa7LOVW0YTLOXrqdLAWIw= 442 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 443 | golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 444 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 445 | golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 446 | google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE= 447 | google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M= 448 | google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= 449 | google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= 450 | google.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= 451 | google.golang.org/api v0.14.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= 452 | google.golang.org/api v0.15.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= 453 | google.golang.org/api v0.17.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= 454 | google.golang.org/api v0.18.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= 455 | google.golang.org/api v0.19.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= 456 | google.golang.org/api v0.20.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= 457 | google.golang.org/api v0.22.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= 458 | google.golang.org/api v0.24.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE= 459 | google.golang.org/api v0.28.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE= 460 | google.golang.org/api v0.29.0/go.mod h1:Lcubydp8VUV7KeIHD9z2Bys/sm/vGKnG1UHuDBSrHWM= 461 | google.golang.org/api v0.30.0/go.mod h1:QGmEvQ87FHZNiUVJkT14jQNYJ4ZJjdRF23ZXz5138Fc= 462 | google.golang.org/api v0.35.0/go.mod h1:/XrVsuzM0rZmrsbjJutiuftIzeuTQcEeaYcSk/mQ1dg= 463 | google.golang.org/api v0.36.0/go.mod h1:+z5ficQTmoYpPn8LCUNVpK5I7hwkpjbcgqA7I34qYtE= 464 | google.golang.org/api v0.40.0/go.mod h1:fYKFpnQN0DsDSKRVRcQSDQNtqWPfM9i+zNPxepjRCQ8= 465 | google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= 466 | google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= 467 | google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= 468 | google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0= 469 | google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= 470 | google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= 471 | google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= 472 | google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= 473 | google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= 474 | google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= 475 | google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= 476 | google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= 477 | google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= 478 | google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= 479 | google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8= 480 | google.golang.org/genproto v0.0.0-20191108220845-16a3f7862a1a/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= 481 | google.golang.org/genproto v0.0.0-20191115194625-c23dd37a84c9/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= 482 | google.golang.org/genproto v0.0.0-20191216164720-4f79533eabd1/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= 483 | google.golang.org/genproto v0.0.0-20191230161307-f3c370f40bfb/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= 484 | google.golang.org/genproto v0.0.0-20200115191322-ca5a22157cba/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= 485 | google.golang.org/genproto v0.0.0-20200122232147-0452cf42e150/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= 486 | google.golang.org/genproto v0.0.0-20200204135345-fa8e72b47b90/go.mod h1:GmwEX6Z4W5gMy59cAlVYjN9JhxgbQH6Gn+gFDQe2lzA= 487 | google.golang.org/genproto v0.0.0-20200212174721-66ed5ce911ce/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= 488 | google.golang.org/genproto v0.0.0-20200224152610-e50cd9704f63/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= 489 | google.golang.org/genproto v0.0.0-20200228133532-8c2c7df3a383/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= 490 | google.golang.org/genproto v0.0.0-20200305110556-506484158171/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= 491 | google.golang.org/genproto v0.0.0-20200312145019-da6875a35672/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= 492 | google.golang.org/genproto v0.0.0-20200331122359-1ee6d9798940/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= 493 | google.golang.org/genproto v0.0.0-20200430143042-b979b6f78d84/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= 494 | google.golang.org/genproto v0.0.0-20200511104702-f5ebc3bea380/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= 495 | google.golang.org/genproto v0.0.0-20200515170657-fc4c6c6a6587/go.mod h1:YsZOwe1myG/8QRHRsmBRE1LrgQY60beZKjly0O1fX9U= 496 | google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= 497 | google.golang.org/genproto v0.0.0-20200618031413-b414f8b61790/go.mod h1:jDfRM7FcilCzHH/e9qn6dsT145K34l5v+OpcnNgKAAA= 498 | google.golang.org/genproto v0.0.0-20200729003335-053ba62fc06f/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= 499 | google.golang.org/genproto v0.0.0-20200804131852-c06518451d9c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= 500 | google.golang.org/genproto v0.0.0-20200825200019-8632dd797987/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= 501 | google.golang.org/genproto v0.0.0-20200904004341-0bd0a958aa1d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= 502 | google.golang.org/genproto v0.0.0-20201109203340-2640f1f9cdfb/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= 503 | google.golang.org/genproto v0.0.0-20201201144952-b05cb90ed32e/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= 504 | google.golang.org/genproto v0.0.0-20201210142538-e3217bee35cc/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= 505 | google.golang.org/genproto v0.0.0-20201214200347-8c77b98c765d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= 506 | google.golang.org/genproto v0.0.0-20210108203827-ffc7fda8c3d7/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= 507 | google.golang.org/genproto v0.0.0-20210226172003-ab064af71705/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= 508 | google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= 509 | google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= 510 | google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= 511 | google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= 512 | google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= 513 | google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= 514 | google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= 515 | google.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= 516 | google.golang.org/grpc v1.28.0/go.mod h1:rpkK4SK4GF4Ach/+MFLZUBavHOvF2JJB5uozKKal+60= 517 | google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk= 518 | google.golang.org/grpc v1.30.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= 519 | google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= 520 | google.golang.org/grpc v1.31.1/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= 521 | google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc= 522 | google.golang.org/grpc v1.34.0/go.mod h1:WotjhfgOW/POjDeRt8vscBtXq+2VjORFy659qA51WJ8= 523 | google.golang.org/grpc v1.35.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU= 524 | google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= 525 | google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= 526 | google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= 527 | google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= 528 | google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= 529 | google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= 530 | google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= 531 | google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= 532 | google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4= 533 | google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= 534 | google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= 535 | google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= 536 | google.golang.org/protobuf v1.30.0 h1:kPPoIgf3TsEvrm0PFe15JQ+570QVxYzEvvHqChK+cng= 537 | google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= 538 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 539 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 540 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 541 | gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= 542 | gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= 543 | gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= 544 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 545 | gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 546 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 547 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 548 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 549 | honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= 550 | honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= 551 | honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= 552 | honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= 553 | honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= 554 | honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= 555 | honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= 556 | rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= 557 | rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0= 558 | rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA= 559 | -------------------------------------------------------------------------------- /hack/countries.csv: -------------------------------------------------------------------------------- 1 | country_name,country_code_iso_2,country_code_iso_3 2 | Argentina,AR,ARG 3 | Armenia,AM,ARM 4 | Australia,AU,AUS 5 | Austria,AT,AUT 6 | Azerbaijan,AZ,AZE 7 | Burundi,BI,BDI 8 | Belgium,BE,BEL 9 | Bangladesh,BD,BGD 10 | Bulgaria,BG,BGR 11 | Bosnia Herzegovina,BA,BIH 12 | Belarus,BY,BLR 13 | Bolivia,BO,BOL 14 | Brazil,BR,BRA 15 | Canada,CA,CAN 16 | Switzerland,CH,CHE 17 | Chile,CL,CHL 18 | China,CN,CHN 19 | Costa Rica,CR,CRI 20 | Cyprus,CY,CYP 21 | Czechia,CZ,CZE 22 | Germany,DE,DEU 23 | Denmark,DK,DNK 24 | Ecuador,EC,ECU 25 | Egypt,EG,EGY 26 | Spain,ES,ESP 27 | Estonia,EE,EST 28 | Finland,FI,FIN 29 | France,FR,FRA 30 | United Kingdom,GB,GBR 31 | Georgia,GE,GEO 32 | Greece,GR,GRC 33 | Croatia,HR,HRV 34 | Hungary,HU,HUN 35 | India,IN,IND 36 | Ireland,IE,IRL 37 | Italy,IT,ITA 38 | Japan,JP,JPN 39 | Kazakhstan,KZ,KAZ 40 | Kenya,KE,KEN 41 | South Korea,KR,KOR 42 | Lithuania,LT,LTU 43 | Luxembourg,LU,LUX 44 | Latvia,LV,LVA 45 | Moldova,MD,MDA 46 | Mexico,MX,MEX 47 | North Macedonia,MK,MKD 48 | Malta,MT,MLT 49 | Montenegro,ME,MNE 50 | Mongolia,MN,MNG 51 | Netherlands,NL,NLD 52 | Norway,NO,NOR 53 | Pakistan,PK,PAK 54 | Peru,PE,PER 55 | Philippines (the),PH,PHL 56 | Poland,PL,POL 57 | Portugal,PT,PRT 58 | Romania,RO,ROU 59 | Russian Federation (the),RU,RUS 60 | Saudi Arabia,SA,SAU 61 | Senegal,SN,SEN 62 | Singapore,SG,SGP 63 | El Salvador,SV,SLV 64 | Serbia,RS,SRB 65 | Slovakia,SK,SVK 66 | Slovenia,SI,SVN 67 | Sweden,SE,SWE 68 | Thailand,TH,THA 69 | Tajikistan,TJ,TJK 70 | Tunisia,TN,TUN 71 | Turkey,TR,TUR 72 | Taiwan (Province of China),TW,TWN 73 | Ukraine,UA,UKR 74 | United States of America,US,USA 75 | Viet Nam,VN,VNM 76 | South Africa,ZA,ZAF 77 | Aruba,AW,ABW 78 | Afghanistan,AF,AFG 79 | Angola,AO,AGO 80 | Albania,AL,ALB 81 | United Arab Emirates,AE,ARE 82 | Argentina,AR,ARG 83 | Armenia,AM,ARM 84 | American Samoa,AS,ASM 85 | Antigua and Barbuda,AG,ATG 86 | Australia,AU,AUS 87 | Austria,AT,AUT 88 | Azerbaijan,AZ,AZE 89 | Burundi,BI,BDI 90 | Belgium,BE,BEL 91 | Benin,BJ,BEN 92 | Burkina Faso,BF,BFA 93 | Bangladesh,BD,BGD 94 | Bulgaria,BG,BGR 95 | Bahrain,BH,BHR 96 | Bahamas (the),BS,BHS 97 | Bosnia Herzegovina,BA,BIH 98 | Belarus,BY,BLR 99 | Belize,BZ,BLZ 100 | Bolivia,BO,BOL 101 | Brazil,BR,BRA 102 | Barbados,BB,BRB 103 | Brunei Darussalam,BN,BRN 104 | Bhutan,BT,BTN 105 | Botswana,BW,BWA 106 | Central African Republic (the),CF,CAF 107 | Canada,CA,CAN 108 | Switzerland,CH,CHE 109 | Chile,CL,CHL 110 | China,CN,CHN 111 | Cote d'Ivoire,CI,CIV 112 | Cameroon,CM,CMR 113 | Congo (the Democratic Republic of the),CD,COD 114 | Congo (the),CG,COG 115 | Cook Islands (the),CK,COK 116 | Colombia,CO,COL 117 | Comoros (the),KM,COM 118 | Cabo Verde,CV,CPV 119 | Costa Rica,CR,CRI 120 | Cuba,CU,CUB 121 | Cayman Islands (the),KY,CYM 122 | Cyprus,CY,CYP 123 | Czechia,CZ,CZE 124 | Germany,DE,DEU 125 | Djibouti,DJ,DJI 126 | Dominica,DM,DMA 127 | Denmark,DK,DNK 128 | Dominican Republic (the),DO,DOM 129 | Algeria,DZ,DZA 130 | Ecuador,EC,ECU 131 | Egypt,EG,EGY 132 | Eritrea,ER,ERI 133 | Spain,ES,ESP 134 | Estonia,EE,EST 135 | Ethiopia,ET,ETH 136 | Finland,FI,FIN 137 | Fiji,FJ,FJI 138 | Falkland Islands (the) [Malvinas],FK,FLK 139 | France,FR,FRA 140 | Faroe Islands (the),FO,FRO 141 | Gabon,GA,GAB 142 | United Kingdom,GB,GBR 143 | Georgia,GE,GEO 144 | Ghana,GH,GHA 145 | Guinea,GN,GIN 146 | Guadeloupe,GP,GLP 147 | Gambia (the),GM,GMB 148 | Guinea-Bissau,GW,GNB 149 | Equatorial Guinea,GQ,GNQ 150 | Greece,GR,GRC 151 | Grenada,GD,GRD 152 | Greenland,GL,GRL 153 | Guatemala,GT,GTM 154 | French Guiana,GF,GUF 155 | Guam,GU,GUM 156 | Guyana,GY,GUY 157 | Hong Kong,HK,HKG 158 | Honduras,HN,HND 159 | Croatia,HR,HRV 160 | Haiti,HT,HTI 161 | Hungary,HU,HUN 162 | Indonesia,ID,IDN 163 | India,IN,IND 164 | Ireland,IE,IRL 165 | Iran (Islamic Republic of),IR,IRN 166 | Iraq,IQ,IRQ 167 | Iceland,IS,ISL 168 | Israel,IL,ISR 169 | Italy,IT,ITA 170 | Jamaica,JM,JAM 171 | Jordan,JO,JOR 172 | Japan,JP,JPN 173 | Kazakhstan,KZ,KAZ 174 | Kenya,KE,KEN 175 | Kyrgyzstan,KG,KGZ 176 | Cambodia,KH,KHM 177 | Kiribati,KI,KIR 178 | Saint Kitts and Nevis,KN,KNA 179 | South Korea,KR,KOR 180 | Kuwait,KW,KWT 181 | Lao People's Democratic Republic (the),LA,LAO 182 | Lebanon,LB,LBN 183 | Liberia,LR,LBR 184 | Libya,LY,LBY 185 | Saint Lucia,LC,LCA 186 | Sri Lanka,LK,LKA 187 | Lesotho,LS,LSO 188 | Lithuania,LT,LTU 189 | Luxembourg,LU,LUX 190 | Latvia,LV,LVA 191 | Macao,MO,MAC 192 | Morocco,MA,MAR 193 | Moldova,MD,MDA 194 | Madagascar,MG,MDG 195 | Maldives,MV,MDV 196 | Mexico,MX,MEX 197 | North Macedonia,MK,MKD 198 | Mali,ML,MLI 199 | Malta,MT,MLT 200 | Myanmar,MM,MMR 201 | Montenegro,ME,MNE 202 | Mongolia,MN,MNG 203 | Mozambique,MZ,MOZ 204 | Mauritania,MR,MRT 205 | Montserrat,MS,MSR 206 | Martinique,MQ,MTQ 207 | Mauritius,MU,MUS 208 | Malawi,MW,MWI 209 | Malaysia,MY,MYS 210 | Namibia,NA,NAM 211 | New Caledonia,NC,NCL 212 | Niger (the),NE,NER 213 | Nigeria,NG,NGA 214 | Nicaragua,NI,NIC 215 | Netherlands,NL,NLD 216 | Norway,NO,NOR 217 | Nepal,NP,NPL 218 | Nauru,NR,NRU 219 | New Zealand,NZ,NZL 220 | Oman,OM,OMN 221 | Pakistan,PK,PAK 222 | Panama,PA,PAN 223 | Peru,PE,PER 224 | Philippines (the),PH,PHL 225 | Papua New Guinea,PG,PNG 226 | Poland,PL,POL 227 | Puerto Rico,PR,PRI 228 | Korea (the Democratic People's Republic of),KP,PRK 229 | Portugal,PT,PRT 230 | Paraguay,PY,PRY 231 | "Palestine, State of",PS,PSE 232 | French Polynesia,PF,PYF 233 | Qatar,QA,QAT 234 | Reunion,RE,REU 235 | Romania,RO,ROU 236 | Russian Federation (the),RU,RUS 237 | Rwanda,RW,RWA 238 | Saudi Arabia,SA,SAU 239 | Sudan (the),SD,SDN 240 | Senegal,SN,SEN 241 | Singapore,SG,SGP 242 | Solomon Islands,SB,SLB 243 | Sierra Leone,SL,SLE 244 | El Salvador,SV,SLV 245 | Somalia,SO,SOM 246 | Saint Pierre and Miquelon,PM,SPM 247 | Serbia,RS,SRB 248 | South Sudan,SS,SSD 249 | Sao Tome and Principe,ST,STP 250 | Suriname,SR,SUR 251 | Slovakia,SK,SVK 252 | Slovenia,SI,SVN 253 | Sweden,SE,SWE 254 | Eswatini,SZ,SWZ 255 | Seychelles,SC,SYC 256 | Syrian Arab Republic (the),SY,SYR 257 | Turks and Caicos Islands (the),TC,TCA 258 | Chad,TD,TCD 259 | Togo,TG,TGO 260 | Thailand,TH,THA 261 | Tajikistan,TJ,TJK 262 | Turkmenistan,TM,TKM 263 | Tonga,TO,TON 264 | Trinidad and Tobago,TT,TTO 265 | Tunisia,TN,TUN 266 | Turkey,TR,TUR 267 | Taiwan (Province of China),TW,TWN 268 | "Tanzania, the United Republic of",TZ,TZA 269 | Uganda,UG,UGA 270 | Ukraine,UA,UKR 271 | Uruguay,UY,URY 272 | United States of America,US,USA 273 | Uzbekistan,UZ,UZB 274 | Saint Vincent and the Grenadines,VC,VCT 275 | Venezuela (Bolivarian Republic of),VE,VEN 276 | Virgin Islands (British),VG,VGB 277 | Virgin Islands (U.S.),VI,VIR 278 | Viet Nam,VN,VNM 279 | Vanuatu,VU,VUT 280 | Samoa,WS,WSM 281 | Kosovo,XK,XKX 282 | Yemen,YE,YEM 283 | South Africa,ZA,ZAF 284 | Zambia,ZM,ZMB 285 | Zimbabwe,ZW,ZWE -------------------------------------------------------------------------------- /hack/country_codes.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/csv" 5 | "fmt" 6 | "os" 7 | "strings" 8 | ) 9 | 10 | const ( 11 | countryCode = "country_code" 12 | countryCodeISO2 = "country_code_iso_2" 13 | countryCodeISO3 = "country_code_iso_3" 14 | ) 15 | 16 | func main() { 17 | err := mapCountryCodes(os.Args[1]) 18 | if err != nil { 19 | panic(err) 20 | } 21 | } 22 | 23 | // mapCountryCodes takes in an Ember data file with country_code and 3 char 24 | // ISO codes. The data is transformed to have both 2 and 3 char ISO codes so 25 | // users of the CLI can use either format. 26 | func mapCountryCodes(inputFile string) error { 27 | countries, err := getCountryLookups() 28 | if err != nil { 29 | return err 30 | } 31 | 32 | data, err := os.Open(inputFile) 33 | if err != nil { 34 | return err 35 | } 36 | 37 | rows, err := csv.NewReader(data).ReadAll() 38 | if err != nil { 39 | return err 40 | } 41 | 42 | err = updateHeader(rows[0]) 43 | if err != nil { 44 | return err 45 | } 46 | 47 | // Skip header row as it is output by updateHeader. 48 | rows = rows[1:] 49 | 50 | for _, row := range rows { 51 | iso_3 := row[0] 52 | iso_2, ok := countries[iso_3] 53 | if !ok { 54 | if iso_3 != "" { 55 | return fmt.Errorf("country %#q not found", iso_3) 56 | } 57 | } 58 | 59 | output := []string{ 60 | iso_2, 61 | } 62 | output = append(output, row...) 63 | 64 | fmt.Println(strings.Join(output, ",")) 65 | } 66 | 67 | return nil 68 | } 69 | 70 | func getCountryLookups() (map[string]string, error) { 71 | countries := map[string]string{} 72 | 73 | data, err := os.Open("hack/countries.csv") 74 | if err != nil { 75 | return nil, err 76 | } 77 | 78 | rows, err := csv.NewReader(data).ReadAll() 79 | if err != nil { 80 | return nil, err 81 | } 82 | 83 | for _, row := range rows { 84 | iso_2 := row[1] 85 | iso_3 := row[2] 86 | countries[iso_3] = iso_2 87 | } 88 | 89 | return countries, nil 90 | } 91 | 92 | func updateHeader(header []string) error { 93 | if header[0] == countryCodeISO2 { 94 | return fmt.Errorf("data already processed - %#q should not be present", countryCodeISO2) 95 | } else if header[0] == countryCode { 96 | output := []string{ 97 | countryCodeISO2, 98 | } 99 | header[0] = countryCodeISO3 100 | output = append(output, header...) 101 | 102 | fmt.Println(strings.Join(output, ",")) 103 | } else { 104 | return fmt.Errorf("header %#q not recognized", header) 105 | } 106 | 107 | return nil 108 | } 109 | -------------------------------------------------------------------------------- /helm/grid-intensity-exporter/Chart.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: "v1" 2 | description: "A prometheus exporter for understanding the carbon intensity of compute." 3 | home: "https://github.com/thegreenwebfoundation/grid-intensity-go" 4 | name: "grid-intensity-exporter" 5 | version: "0.1.0" 6 | -------------------------------------------------------------------------------- /helm/grid-intensity-exporter/templates/configmap.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: ConfigMap 3 | metadata: 4 | name: {{ .Release.Name }} 5 | labels: 6 | app.kubernetes.io/name: {{ .Release.Name }} 7 | data: 8 | gridIntensityLocation: {{ .Values.gridIntensity.location | quote }} 9 | gridIntensityProvider: {{ .Values.gridIntensity.provider | quote }} 10 | {{- if .Values.gridIntensity.region }} 11 | gridIntensityRegion: {{ .Values.gridIntensity.region | quote }} 12 | {{- end }} 13 | -------------------------------------------------------------------------------- /helm/grid-intensity-exporter/templates/deployment.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: {{ .Release.Name }} 5 | labels: 6 | app.kubernetes.io/name: {{ .Release.Name }} 7 | spec: 8 | replicas: 1 9 | selector: 10 | matchLabels: 11 | app.kubernetes.io/name: {{ .Release.Name }} 12 | strategy: 13 | type: RollingUpdate 14 | template: 15 | metadata: 16 | labels: 17 | app.kubernetes.io/name: {{ .Release.Name }} 18 | spec: 19 | serviceAccountName: {{ .Release.Name }} 20 | containers: 21 | - name: {{ .Chart.Name }} 22 | image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" 23 | imagePullPolicy: {{ .Values.image.pullPolicy }} 24 | args: 25 | - exporter 26 | env: 27 | - name: GRID_INTENSITY_PROVIDER 28 | valueFrom: 29 | configMapKeyRef: 30 | name: {{ .Release.Name }} 31 | key: gridIntensityProvider 32 | - name: GRID_INTENSITY_LOCATION 33 | valueFrom: 34 | configMapKeyRef: 35 | name: {{ .Release.Name }} 36 | key: gridIntensityLocation 37 | - name: GRID_INTENSITY_NODE 38 | valueFrom: 39 | fieldRef: 40 | fieldPath: spec.nodeName 41 | {{- if .Values.gridIntensity.region }} 42 | - name: GRID_INTENSITY_REGION 43 | valueFrom: 44 | configMapKeyRef: 45 | name: {{ .Release.Name }} 46 | key: gridIntensityRegion 47 | {{- end }} 48 | {{- if .Values.electricityMap }} 49 | {{- if .Values.electricityMap.apiToken }} 50 | - name: ELECTRICITY_MAPS_API_TOKEN 51 | valueFrom: 52 | secretKeyRef: 53 | name: {{ .Release.Name }} 54 | key: apiToken 55 | {{- end }} 56 | {{- if .Values.electricityMap.apiURL }} 57 | - name: ELECTRICITY_MAPS_API_URL 58 | valueFrom: 59 | secretKeyRef: 60 | name: {{ .Release.Name }} 61 | key: apiURL 62 | {{- end }} 63 | {{- end }} 64 | {{- if .Values.wattTime }} 65 | {{- if .Values.wattTime.apiUser }} 66 | - name: WATT_TIME_API_USER 67 | valueFrom: 68 | secretKeyRef: 69 | name: {{ .Release.Name }} 70 | key: apiUser 71 | {{- end }} 72 | {{- if .Values.wattTime.apiPassword }} 73 | - name: WATT_TIME_API_PASSWORD 74 | valueFrom: 75 | secretKeyRef: 76 | name: {{ .Release.Name }} 77 | key: apiPassword 78 | {{- end }} 79 | {{- end }} 80 | resources: 81 | requests: 82 | cpu: 50m 83 | memory: 50Mi 84 | limits: 85 | cpu: 50m 86 | memory: 50Mi 87 | -------------------------------------------------------------------------------- /helm/grid-intensity-exporter/templates/secret.yaml: -------------------------------------------------------------------------------- 1 | {{- if or (.Values.electricityMap) (.Values.wattTime) }} 2 | apiVersion: v1 3 | kind: Secret 4 | metadata: 5 | name: {{ .Release.Name }} 6 | labels: 7 | app.kubernetes.io/name: {{ .Release.Name }} 8 | data: 9 | {{- if .Values.electricityMap }} 10 | {{- if .Values.electricityMap.apiToken }} 11 | apiToken: {{ .Values.electricityMap.apiToken | b64enc | quote }} 12 | {{- end }} 13 | {{- if .Values.electricityMap.apiURL }} 14 | apiURL: {{ .Values.electricityMap.apiURL | b64enc | quote }} 15 | {{- end }} 16 | {{- end }} 17 | {{- if .Values.wattTime }} 18 | {{- if .Values.wattTime.apiUser }} 19 | apiUser: {{ .Values.wattTime.apiUser | b64enc | quote }} 20 | {{- end }} 21 | {{- if .Values.wattTime.apiPassword }} 22 | apiPassword: {{ .Values.wattTime.apiPassword | b64enc | quote }} 23 | {{- end }} 24 | {{- end }} 25 | {{- end }} 26 | -------------------------------------------------------------------------------- /helm/grid-intensity-exporter/templates/service-account.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: ServiceAccount 3 | metadata: 4 | name: {{ .Release.Name }} 5 | labels: 6 | app.kubernetes.io/name: {{ .Release.Name }} 7 | -------------------------------------------------------------------------------- /helm/grid-intensity-exporter/templates/service.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Service 3 | metadata: 4 | name: {{ .Release.Name }} 5 | labels: 6 | app.kubernetes.io/name: {{ .Release.Name }} 7 | annotations: 8 | prometheus.io/scrape: "true" 9 | spec: 10 | type: ClusterIP 11 | clusterIP: None 12 | ports: 13 | - port: 8000 14 | protocol: TCP 15 | name: metrics 16 | selector: 17 | app.kubernetes.io/name: {{ .Release.Name }} 18 | -------------------------------------------------------------------------------- /helm/grid-intensity-exporter/values.yaml: -------------------------------------------------------------------------------- 1 | # This is the default provider with the Ember 2021 dataset. 2 | gridIntensity: 3 | provider: Ember 4 | location: GBR 5 | region: # Set to label metrics with cloud provider region. 6 | 7 | image: 8 | repository: thegreenwebfoundation/grid-intensity 9 | tag: latest 10 | pullPolicy: IfNotPresent 11 | 12 | # This provider doesn't require an API key but it only 13 | # supports the UK location. 14 | # 15 | # gridIntensity: 16 | # provider: CarbonIntensityOrgUK 17 | # location: UK 18 | 19 | # ElectricityMap provider supports multiple countries but you will need to set 20 | # an API key in electricityMap.apiToken 21 | # 22 | # gridIntensity: 23 | # provider: ElectricityMap 24 | # location: IN-KA 25 | 26 | # WattTime provider supports multiple countries but you will need to set an API 27 | # user in wattTime.apiUser and wattTime.apiPassword 28 | # 29 | # gridIntensity: 30 | # provider: WattTime 31 | # location: CAISO_NORTH 32 | 33 | # Get API token from https://electricitymaps.com/ 34 | # 35 | # electricityMap: 36 | # apiToken: your-api-key 37 | # apiURL: https://api-access.electricitymaps.com/free-tier/ 38 | 39 | # To register an API user see https://www.watttime.org/api-documentation/#authentication 40 | # 41 | # wattTime: 42 | # apiUser: your-api-user 43 | # apiPassword: your-api-password 44 | -------------------------------------------------------------------------------- /install.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # Based on Deno installer: Copyright 2019 the Deno authors. All rights reserved. MIT license. 3 | 4 | set -e 5 | 6 | case $(uname -sm) in 7 | "Darwin x86_64") target="Darwin_x86_64" ;; 8 | "Darwin arm64") target="Darwin_arm64" ;; 9 | "Linux aarch64") target="Linux_arm64" ;; 10 | *) target="Linux_i386" ;; 11 | esac 12 | 13 | version=${1} 14 | 15 | if [ $# -eq 0 ]; then 16 | version="$(curl -s https://api.github.com/repos/thegreenwebfoundation/grid-intensity-go/releases/latest | grep tag_name | cut -d '"' -f 4)" 17 | fi 18 | 19 | version="$(echo "$version" | cut -d 'v' -f 2)" 20 | grid_intensity_uri="https://github.com/thegreenwebfoundation/grid-intensity-go/releases/download/v${version}/grid-intensity_${version}_${target}.tar.gz" 21 | 22 | bin_dir="/usr/local/bin" 23 | binary="grid-intensity" 24 | exe="$bin_dir/grid-intensity" 25 | 26 | curl --fail --location --progress-bar --output "$exe.tar.gz" "$grid_intensity_uri" 27 | tar xzf "$exe.tar.gz" -C $bin_dir $binary 28 | chmod +x "$exe" 29 | rm "$exe.tar.gz" 30 | -------------------------------------------------------------------------------- /integration/test/exporter/metric_test.go: -------------------------------------------------------------------------------- 1 | //go:build dockerrequired 2 | // +build dockerrequired 3 | 4 | package exporter 5 | 6 | import ( 7 | "fmt" 8 | "io/ioutil" 9 | "net/http" 10 | "os" 11 | "strings" 12 | "testing" 13 | "time" 14 | 15 | "github.com/cenkalti/backoff/v4" 16 | ) 17 | 18 | func Test_GridIntensityMetric(t *testing.T) { 19 | var metrics string 20 | 21 | metricsURL := "http://localhost:8000/metrics" 22 | 23 | o := func() error { 24 | resp, err := http.Get(metricsURL) 25 | if err != nil { 26 | return fmt.Errorf("could not retrieve %s: %v", metricsURL, err) 27 | } 28 | if resp.StatusCode != http.StatusOK { 29 | return fmt.Errorf("expected status code %d: got %d", http.StatusOK, resp.StatusCode) 30 | } 31 | 32 | respBytes, err := ioutil.ReadAll(resp.Body) 33 | if err != nil { 34 | return fmt.Errorf("could not read body %v", err) 35 | } 36 | 37 | metrics = string(respBytes) 38 | 39 | return nil 40 | } 41 | 42 | n := func(err error, d time.Duration) { 43 | t.Logf("failed to get metrics from %s: retrying in %s", metricsURL, d) 44 | } 45 | 46 | b := backoff.NewExponentialBackOff() 47 | err := backoff.RetryNotify(o, b, n) 48 | if err != nil { 49 | t.Fatalf("expected nil got %v", err) 50 | } 51 | 52 | platform := os.Getenv("TEST_PLATFORM") 53 | 54 | var node, region string 55 | 56 | if platform == "kubernetes" { 57 | node = "chart-testing-control-plane" 58 | } else if platform == "nomad" { 59 | node = "dev01" 60 | region = "global" 61 | } 62 | 63 | expectedMetricText := fmt.Sprintf("grid_intensity_carbon_average{is_estimated=\"true\",location=\"GBR\",node=\"%s\",provider=\"Ember\",region=\"%s\",units=\"gCO2e per kWh\"}", 64 | node, region) 65 | 66 | if !strings.Contains(metrics, expectedMetricText) { 67 | t.Fatalf("expected metric text %q not found got %q", expectedMetricText, metrics) 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "github.com/thegreenwebfoundation/grid-intensity-go/cmd" 4 | 5 | func main() { 6 | cmd.Execute() 7 | } 8 | -------------------------------------------------------------------------------- /nomad/grid-intensity-exporter.nomad: -------------------------------------------------------------------------------- 1 | 2 | job "grid-intensity-exporter" { 3 | 4 | # The "datacenters" parameter specifies the list of datacenters which should 5 | # be considered when placing this task. This must be provided. 6 | datacenters = ["dc1"] 7 | 8 | # the exporter job runs as a service with a single instance that 9 | # can be scraped by prometheus. 10 | type = "service" 11 | 12 | group "grid-intensity-exporter" { 13 | 14 | count = 1 15 | 16 | network { 17 | # for testing, we can get away with having a fixed port 18 | # but in production we'd let nomad allocate a port instead 19 | port "exporter" { 20 | static = 8000 21 | to = 8000 22 | } 23 | } 24 | 25 | task "grid-intensity-exporter" { 26 | 27 | driver = "docker" 28 | 29 | config { 30 | args = [ 31 | "exporter" 32 | ] 33 | image = "thegreenwebfoundation/grid-intensity:integration-test" 34 | ports = ["exporter"] 35 | } 36 | 37 | env { 38 | GRID_INTENSITY_LOCATION = "GBR" 39 | GRID_INTENSITY_PROVIDER = "Ember" 40 | GRID_INTENSITY_NODE = "${node.unique.name}" 41 | GRID_INTENSITY_REGION = "${node.region}" 42 | } 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /pkg/internal/data/co2-intensities-ember-2021.csv: -------------------------------------------------------------------------------- 1 | country_code_iso_2,country_code_iso_3,country_or_region,year,latest_year,emissions_intensity_gco2_per_kwh 2 | ,,Africa,2021,2021,489.26 3 | AR,ARG,Argentina,2021,2021,365.292 4 | AM,ARM,Armenia,2021,2021,206.522 5 | ,,Asia,2021,2021,543.57 6 | AU,AUS,Australia,2021,2021,526.876 7 | AT,AUT,Austria,2021,2021,145.083 8 | AZ,AZE,Azerbaijan,2021,2021,536.585 9 | BD,BGD,Bangladesh,2021,2021,559.606 10 | BY,BLR,Belarus,2021,2021,472.727 11 | BE,BEL,Belgium,2021,2021,156.063 12 | BO,BOL,Bolivia,2021,2021,311.475 13 | BA,BIH,Bosnia Herzegovina,2021,2021,470.982 14 | BR,BRA,Brazil,2021,2021,144.677 15 | BG,BGR,Bulgaria,2021,2021,364.136 16 | BI,BDI,Burundi,2021,2021,275.862 17 | CA,CAN,Canada,2021,2021,123.859 18 | CL,CHL,Chile,2021,2021,395.565 19 | CN,CHN,China,2021,2021,549.288 20 | CR,CRI,Costa Rica,2021,2021,30.903 21 | HR,HRV,Croatia,2021,2021,212.161 22 | CY,CYP,Cyprus,2021,2021,601.19 23 | CZ,CZE,Czechia,2021,2021,401.272 24 | DK,DNK,Denmark,2021,2021,240.419 25 | EC,ECU,Ecuador,2021,2021,132.964 26 | EG,EGY,Egypt,2021,2021,470.879 27 | SV,SLV,El Salvador,2021,2021,180.87 28 | EE,EST,Estonia,2021,2021,488.529 29 | ,,EU,2021,2021,261.43 30 | ,,Europe,2021,2021,277.64 31 | FI,FIN,Finland,2021,2021,152.651 32 | FR,FRA,France,2021,2021,67.781 33 | ,,G20,2021,2021,445.9 34 | ,,G7,2021,2021,338.04 35 | GE,GEO,Georgia,2021,2021,105.685 36 | DE,DEU,Germany,2021,2021,363.982 37 | GR,GRC,Greece,2021,2021,363.388 38 | HU,HUN,Hungary,2021,2021,236.271 39 | IN,IND,India,2021,2021,632.656 40 | IE,IRL,Ireland,2021,2021,361.274 41 | IT,ITA,Italy,2021,2021,340.937 42 | JP,JPN,Japan,2021,2021,460.647 43 | KZ,KAZ,Kazakhstan,2021,2021,656.097 44 | KE,KEN,Kenya,2021,2021,104.0 45 | ,,Latin America and Caribbean,2021,2021,261.51 46 | LV,LVA,Latvia,2021,2021,226.351 47 | LT,LTU,Lithuania,2021,2021,247.475 48 | LU,LUX,Luxembourg,2021,2021,183.824 49 | MT,MLT,Malta,2021,2021,452.055 50 | MX,MEX,Mexico,2021,2021,391.582 51 | MD,MDA,Moldova,2021,2021,642.512 52 | MN,MNG,Mongolia,2021,2021,725.26 53 | ME,MNE,Montenegro,2021,2021,335.958 54 | NL,NLD,Netherlands,2021,2021,386.189 55 | ,,North America,2021,2021,345.38 56 | MK,MKD,North Macedonia,2021,2021,444.191 57 | NO,NOR,Norway,2021,2021,26.131 58 | ,,Oceania,2021,2021,479.98 59 | ,,OECD,2021,2021,338.24 60 | PK,PAK,Pakistan,2021,2021,363.065 61 | PE,PER,Peru,2021,2021,241.492 62 | PH,PHL,Philippines (the),2021,2021,579.689 63 | PL,POL,Poland,2021,2021,657.138 64 | PT,PRT,Portugal,2021,2021,222.632 65 | RO,ROU,Romania,2021,2021,255.718 66 | RU,RUS,Russian Federation (the),2021,2021,355.431 67 | SA,SAU,Saudi Arabia,2021,2021,568.967 68 | SN,SEN,Senegal,2021,2021,540.098 69 | RS,SRB,Serbia,2021,2021,549.083 70 | SG,SGP,Singapore,2021,2021,488.21 71 | SK,SVK,Slovakia,2021,2021,173.854 72 | SI,SVN,Slovenia,2021,2021,241.956 73 | ZA,ZAF,South Africa,2021,2021,706.991 74 | KR,KOR,South Korea,2021,2021,442.389 75 | ES,ESP,Spain,2021,2021,193.737 76 | SE,SWE,Sweden,2021,2021,43.9 77 | CH,CHE,Switzerland,2021,2021,58.952 78 | TW,TWN,Taiwan (Province of China),2021,2021,565.629 79 | TJ,TJK,Tajikistan,2021,2021,72.823 80 | TH,THA,Thailand,2021,2021,503.034 81 | TN,TUN,Tunisia,2021,2021,470.848 82 | TR,TUR,Turkey,2021,2021,432.293 83 | UA,UKR,Ukraine,2021,2021,240.28 84 | GB,GBR,United Kingdom,2021,2021,268.255 85 | US,USA,United States of America,2021,2021,378.625 86 | VN,VNM,Viet Nam,2021,2021,491.192 87 | ,,World,2021,2021,442.3 88 | -------------------------------------------------------------------------------- /pkg/internal/data/ember.go: -------------------------------------------------------------------------------- 1 | package data 2 | 3 | import ( 4 | "bytes" 5 | _ "embed" 6 | "encoding/csv" 7 | "strconv" 8 | ) 9 | 10 | //go:embed co2-intensities-ember-2021.csv 11 | var emberData []byte 12 | 13 | func GetEmberGridIntensity() (map[string]EmberGridIntensity, error) { 14 | data := map[string]EmberGridIntensity{} 15 | 16 | reader := bytes.NewReader(emberData) 17 | rows, err := csv.NewReader(reader).ReadAll() 18 | if err != nil { 19 | return nil, err 20 | } 21 | 22 | for _, row := range rows { 23 | countryCodeISO2 := row[0] 24 | countryCodeISO3 := row[1] 25 | if countryCodeISO2 == "" || countryCodeISO2 == "country_code_iso_2" { 26 | continue 27 | } 28 | 29 | year, err := strconv.Atoi(row[3]) 30 | if err != nil { 31 | return nil, err 32 | } 33 | 34 | latestYear, err := strconv.Atoi(row[4]) 35 | if err != nil { 36 | return nil, err 37 | } 38 | 39 | intensity, err := strconv.ParseFloat(row[5], 64) 40 | if err != nil { 41 | return nil, err 42 | } 43 | 44 | country := EmberGridIntensity{ 45 | CountryCodeISO2: countryCodeISO2, 46 | CountryCodeISO3: countryCodeISO3, 47 | CountryOrRegion: row[2], 48 | Year: year, 49 | LatestYear: latestYear, 50 | EmissionsIntensityGCO2PerKWH: intensity, 51 | } 52 | 53 | // Add both country codes to allow lookups with either format. 54 | data[countryCodeISO2] = country 55 | data[countryCodeISO3] = country 56 | } 57 | 58 | return data, nil 59 | } 60 | -------------------------------------------------------------------------------- /pkg/internal/data/schema.go: -------------------------------------------------------------------------------- 1 | package data 2 | 3 | type EmberGridIntensity struct { 4 | CountryCodeISO2 string `json:"country_code_iso_2"` 5 | CountryCodeISO3 string `json:"country_code_iso_3"` 6 | CountryOrRegion string `json:"country_or_region"` 7 | Year int `json:"year"` 8 | LatestYear int `json:"latest_year"` 9 | EmissionsIntensityGCO2PerKWH float64 `json:"emissions_intensity_gco2_per_kwh"` 10 | } 11 | -------------------------------------------------------------------------------- /pkg/provider/cache.go: -------------------------------------------------------------------------------- 1 | package provider 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "errors" 7 | "fmt" 8 | "io/fs" 9 | "io/ioutil" 10 | "os" 11 | "path/filepath" 12 | "time" 13 | 14 | "github.com/gofrs/flock" 15 | "github.com/jellydator/ttlcache/v2" 16 | ) 17 | 18 | type cacheConfig struct { 19 | CacheFile string 20 | LockFile string 21 | } 22 | 23 | type cacheStore struct { 24 | cache *ttlcache.Cache 25 | cacheFile string 26 | lockFile string 27 | } 28 | 29 | type cacheData struct { 30 | Data []CarbonIntensity `json:"data"` 31 | TTL time.Time `json:"ttl"` 32 | } 33 | 34 | func NewCacheStore(config cacheConfig) (*cacheStore, error) { 35 | var lockFile string 36 | 37 | if config.CacheFile != "" { 38 | lockFile = config.CacheFile + ".lock" 39 | } 40 | 41 | store := &cacheStore{ 42 | cache: ttlcache.NewCache(), 43 | cacheFile: config.CacheFile, 44 | lockFile: lockFile, 45 | } 46 | 47 | return store, nil 48 | } 49 | 50 | func (c *cacheStore) getCacheData(ctx context.Context, location string) ([]CarbonIntensity, error) { 51 | var result *cacheData 52 | 53 | if c.cacheFile == "" { 54 | raw, err := c.cache.Get(location) 55 | if errors.Is(ttlcache.ErrNotFound, err) { 56 | // Cache miss so return nil. 57 | return nil, nil 58 | } else if err != nil { 59 | return nil, err 60 | } 61 | 62 | item, ok := raw.(*cacheData) 63 | if !ok { 64 | return nil, fmt.Errorf("cannot convert %#v to %T", raw, []CarbonIntensity{}) 65 | } 66 | result = item 67 | } else { 68 | cache, err := c.loadCache(ctx) 69 | if err != nil { 70 | return nil, err 71 | } 72 | 73 | item, ok := cache[location] 74 | if !ok { 75 | // Cache miss so return nil. 76 | return nil, nil 77 | } 78 | result = item 79 | } 80 | 81 | if result.TTL.Before(time.Now()) { 82 | // Item has expired. 83 | return nil, nil 84 | } 85 | 86 | return result.Data, nil 87 | } 88 | 89 | func (c *cacheStore) setCacheData(ctx context.Context, location string, data []CarbonIntensity, ttl time.Time) error { 90 | item := &cacheData{ 91 | Data: data, 92 | TTL: ttl, 93 | } 94 | 95 | if c.cacheFile == "" { 96 | c.cache.Set(location, item) 97 | } else { 98 | err := c.saveCache(ctx, location, item) 99 | if err != nil { 100 | return nil 101 | } 102 | } 103 | 104 | return nil 105 | } 106 | 107 | func (c *cacheStore) loadCache(ctx context.Context) (map[string]*cacheData, error) { 108 | cache := make(map[string]*cacheData, 0) 109 | 110 | // Ensure the directory exists as it is required for file locking. 111 | err := os.MkdirAll(filepath.Dir(c.cacheFile), os.ModePerm) 112 | if err != nil && !os.IsExist(err) { 113 | return nil, err 114 | } 115 | 116 | lockCtx, cancel := context.WithTimeout(ctx, 30*time.Second) 117 | defer cancel() 118 | 119 | // Get a shared file lock for reading. 120 | fileLock := flock.New(c.lockFile) 121 | locked, err := fileLock.TryRLockContext(lockCtx, time.Second) 122 | if err == nil && locked { 123 | defer fileLock.Unlock() 124 | } 125 | if err != nil { 126 | return nil, err 127 | } 128 | 129 | data, err := os.ReadFile(c.cacheFile) 130 | if _, ok := err.(*fs.PathError); ok { 131 | return cache, nil 132 | } else if err != nil { 133 | return nil, err 134 | } 135 | 136 | err = json.Unmarshal(data, &cache) 137 | if err != nil { 138 | return nil, err 139 | } 140 | 141 | return cache, nil 142 | } 143 | 144 | func (c *cacheStore) saveCache(ctx context.Context, location string, item *cacheData) error { 145 | cache, err := c.loadCache(ctx) 146 | if err != nil { 147 | return err 148 | } 149 | cache[location] = item 150 | 151 | data, err := json.Marshal(cache) 152 | if err != nil { 153 | return err 154 | } 155 | 156 | lockCtx, cancel := context.WithTimeout(ctx, 30*time.Second) 157 | defer cancel() 158 | 159 | // Get an exclusive file lock for writing. 160 | fileLock := flock.New(c.lockFile) 161 | locked, err := fileLock.TryLockContext(lockCtx, time.Second) 162 | if err == nil && locked { 163 | defer fileLock.Unlock() 164 | } 165 | if err != nil { 166 | return err 167 | } 168 | 169 | err = ioutil.WriteFile(c.cacheFile, data, 0644) 170 | if err != nil { 171 | return nil 172 | } 173 | 174 | return nil 175 | } 176 | -------------------------------------------------------------------------------- /pkg/provider/carbon_intensity_uk.go: -------------------------------------------------------------------------------- 1 | package provider 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "log" 7 | "net/http" 8 | "time" 9 | ) 10 | 11 | type CarbonIntensityUKClient struct { 12 | client *http.Client 13 | apiURL string 14 | } 15 | 16 | type CarbonIntensityUKConfig struct { 17 | Client *http.Client 18 | APIURL string 19 | } 20 | 21 | func NewCarbonIntensityUK(config CarbonIntensityUKConfig) (Interface, error) { 22 | if config.Client == nil { 23 | config.Client = &http.Client{ 24 | Timeout: 5 * time.Second, 25 | } 26 | } 27 | if config.APIURL == "" { 28 | config.APIURL = "https://api.carbonintensity.org.uk/intensity/" 29 | } 30 | 31 | c := &CarbonIntensityUKClient{ 32 | client: config.Client, 33 | apiURL: config.APIURL, 34 | } 35 | 36 | return c, nil 37 | } 38 | 39 | func (a *CarbonIntensityUKClient) GetCarbonIntensity(ctx context.Context, location string) ([]CarbonIntensity, error) { 40 | if location != "UK" { 41 | return nil, ErrInvalidLocation 42 | } 43 | 44 | req, err := http.NewRequestWithContext(ctx, http.MethodGet, a.apiURL, nil) 45 | if err != nil { 46 | return nil, err 47 | } 48 | 49 | log.Printf("calling %s", req.URL) 50 | 51 | resp, err := a.client.Do(req) 52 | if err != nil { 53 | return nil, err 54 | } 55 | defer resp.Body.Close() 56 | 57 | if resp.StatusCode != http.StatusOK { 58 | return nil, errBadStatus(resp) 59 | } 60 | 61 | respObj := &carbonIntensityUKResponse{} 62 | 63 | err = json.NewDecoder(resp.Body).Decode(respObj) 64 | if err != nil { 65 | return nil, err 66 | } 67 | 68 | if len(respObj.Data) == 0 { 69 | return nil, ErrNoResponse 70 | } 71 | 72 | data := &respObj.Data[0] 73 | if data.Intensity == nil { 74 | return nil, ErrNoResponse 75 | } 76 | 77 | layout := "2006-01-02T15:04Z" 78 | validFrom, err := time.Parse(layout, data.From) 79 | if err != nil { 80 | return nil, err 81 | } 82 | validTo, err := time.Parse(layout, data.To) 83 | if err != nil { 84 | return nil, err 85 | } 86 | 87 | return []CarbonIntensity{ 88 | { 89 | EmissionsType: AverageEmissionsType, 90 | MetricType: AbsoluteMetricType, 91 | Provider: CarbonIntensityOrgUK, 92 | Location: location, 93 | Units: GramsCO2EPerkWh, 94 | ValidFrom: validFrom, 95 | ValidTo: validTo, 96 | Value: data.Intensity.Actual, 97 | IsEstimated: true, 98 | }, 99 | }, nil 100 | } 101 | 102 | type carbonIntensityUKResponse struct { 103 | Data []carbonIntensityUKData `json:"data"` 104 | } 105 | 106 | type carbonIntensityUKData struct { 107 | From string `json:"from"` 108 | To string `json:"to"` 109 | Intensity *carbonIntensityUKIntensity `json:"intensity"` 110 | } 111 | 112 | type carbonIntensityUKIntensity struct { 113 | Forecast float64 `json:"forecast"` 114 | Actual float64 `json:"actual"` 115 | Index string `json:"index"` 116 | } 117 | -------------------------------------------------------------------------------- /pkg/provider/carbon_intensity_uk_test.go: -------------------------------------------------------------------------------- 1 | package provider 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/http" 7 | "net/http/httptest" 8 | "reflect" 9 | "testing" 10 | "time" 11 | 12 | "github.com/google/go-cmp/cmp" 13 | ) 14 | 15 | var MockCarbonIntensityOrgUKResponse = `{ 16 | "data": [ 17 | { 18 | "from": "2020-01-01T00:00Z", 19 | "to": "2020-01-01T00:30Z", 20 | "intensity": { 21 | "forecast": 186, 22 | "actual": 190, 23 | "index": "moderate" 24 | } 25 | } 26 | ] 27 | }` 28 | 29 | func Test_CarbonIntensityUK_SimpleRequest(t *testing.T) { 30 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 31 | fmt.Fprintln(w, MockCarbonIntensityOrgUKResponse) 32 | })) 33 | defer ts.Close() 34 | 35 | c := CarbonIntensityUKConfig{ 36 | APIURL: ts.URL, 37 | } 38 | a, err := NewCarbonIntensityUK(c) 39 | if err != nil { 40 | t.Errorf("Could not make provider: %s", err) 41 | return 42 | } 43 | 44 | res, err := a.GetCarbonIntensity(context.Background(), "UK") 45 | if err != nil { 46 | t.Fatalf("got error on GetCarbonIntensity: %s", err) 47 | } 48 | 49 | expected := []CarbonIntensity{ 50 | { 51 | Provider: "CarbonIntensityOrgUK", 52 | EmissionsType: "average", 53 | MetricType: "absolute", 54 | Location: "UK", 55 | Units: "gCO2e per kWh", 56 | ValidFrom: time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC), 57 | ValidTo: time.Date(2020, 1, 1, 0, 30, 0, 0, time.UTC), 58 | Value: 190, 59 | IsEstimated: true, 60 | }, 61 | } 62 | if !reflect.DeepEqual(expected, res) { 63 | t.Errorf("want matching \n %s", cmp.Diff(res, expected)) 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /pkg/provider/electricity_maps.go: -------------------------------------------------------------------------------- 1 | package provider 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "log" 8 | "net/http" 9 | "time" 10 | ) 11 | 12 | type ElectricityMapsClient struct { 13 | client *http.Client 14 | apiURL string 15 | token string 16 | } 17 | 18 | type ElectricityMapsConfig struct { 19 | Client *http.Client 20 | APIURL string 21 | Token string 22 | } 23 | 24 | func NewElectricityMaps(config ElectricityMapsConfig) (Interface, error) { 25 | if config.Client == nil { 26 | config.Client = &http.Client{ 27 | Timeout: 5 * time.Second, 28 | } 29 | } 30 | if config.APIURL == "" { 31 | config.APIURL = "https://api.electricitymap.org/v3" 32 | } 33 | 34 | c := &ElectricityMapsClient{ 35 | apiURL: config.APIURL, 36 | client: config.Client, 37 | token: config.Token, 38 | } 39 | 40 | return c, nil 41 | } 42 | 43 | func (e *ElectricityMapsClient) GetCarbonIntensity(ctx context.Context, location string) ([]CarbonIntensity, error) { 44 | intensityURL, err := e.historicIntensityURLWithZone(location) 45 | if err != nil { 46 | return nil, err 47 | } 48 | 49 | req, err := http.NewRequestWithContext(ctx, http.MethodGet, intensityURL, nil) 50 | req.Header.Add("auth-token", e.token) 51 | if err != nil { 52 | return nil, err 53 | } 54 | 55 | log.Printf("calling %s", req.URL) 56 | 57 | resp, err := e.client.Do(req) 58 | if err != nil { 59 | return nil, err 60 | } 61 | defer resp.Body.Close() 62 | 63 | if resp.StatusCode != http.StatusOK { 64 | return nil, errBadStatus(resp) 65 | } 66 | 67 | historyResponse := &electricityMapsHistoryResponse{} 68 | err = json.NewDecoder(resp.Body).Decode(&historyResponse) 69 | if err != nil { 70 | return nil, err 71 | } 72 | 73 | var carbonIntensityPoints []CarbonIntensity 74 | var recentDatapoints = NewElectricityMapsDatapoints() 75 | 76 | for _, dataPoint := range historyResponse.History { 77 | // We get the most recent value (which is usually estimated) 78 | // and the most recent value which is registered (not estimated) 79 | // and they will end up in the recentDatapoints variable 80 | recentDatapoints.update(dataPoint) 81 | 82 | } 83 | // We need to check if each value exists, because sometimes there are no estimated datapoints 84 | // and sometimes there are only estimated datapoints (depending on the location): 85 | if recentDatapoints.estimatedFound { 86 | estimatedCarbonIntensity, err := toCarbonIntensity(location, recentDatapoints.estimated) 87 | if err == nil { 88 | carbonIntensityPoints = append(carbonIntensityPoints, *estimatedCarbonIntensity) 89 | } 90 | } 91 | if recentDatapoints.realFound { 92 | realCarbonIntensity, err := toCarbonIntensity(location, recentDatapoints.real) 93 | if err == nil { 94 | carbonIntensityPoints = append(carbonIntensityPoints, *realCarbonIntensity) 95 | } 96 | } 97 | 98 | return carbonIntensityPoints, nil 99 | } 100 | 101 | // Helper struct to remove clutter in the calling function 102 | // while finding the latest (and greatest) data points 103 | type electricityMapsDatapoints struct { 104 | estimated electricityMapsData 105 | real electricityMapsData 106 | estimatedFound bool 107 | realFound bool 108 | } 109 | 110 | func NewElectricityMapsDatapoints() electricityMapsDatapoints { 111 | dataPoints := electricityMapsDatapoints{} 112 | dataPoints.estimatedFound = false 113 | dataPoints.realFound = false 114 | return dataPoints 115 | } 116 | 117 | func (m *electricityMapsDatapoints) setEstimated(dataPoint electricityMapsData) { 118 | m.estimated = dataPoint 119 | m.estimatedFound = true 120 | } 121 | 122 | func (m *electricityMapsDatapoints) setReal(dataPoint electricityMapsData) { 123 | m.real = dataPoint 124 | m.realFound = true 125 | } 126 | 127 | func (m *electricityMapsDatapoints) update(dataPoint electricityMapsData) error { 128 | 129 | dataPointDateTime, err := stringToTime(dataPoint.DateTime) 130 | if err != nil { 131 | return err 132 | } 133 | 134 | // If the current datapoint is estimated, 135 | // update if it's the most recent: 136 | if dataPoint.IsEstimated { 137 | if !m.estimatedFound { 138 | m.setEstimated(dataPoint) 139 | } else { 140 | estimatedDateTime, err := stringToTime(m.estimated.DateTime) 141 | if err != nil { 142 | return err 143 | } 144 | if estimatedDateTime.Before(dataPointDateTime) { 145 | m.setEstimated(dataPoint) 146 | } 147 | } 148 | } 149 | 150 | if !dataPoint.IsEstimated { 151 | if !m.realFound { 152 | m.setReal(dataPoint) 153 | } else { 154 | realDateTime, err := stringToTime(m.real.DateTime) 155 | if err != nil { 156 | return err 157 | } 158 | if realDateTime.Before(dataPointDateTime) { 159 | m.setReal(dataPoint) 160 | } 161 | } 162 | } 163 | 164 | return nil 165 | } 166 | 167 | func stringToTime(dateTimeString string) (time.Time, error) { 168 | return time.Parse(time.RFC3339Nano, dateTimeString) 169 | } 170 | 171 | func toCarbonIntensity(location string, dataPoint electricityMapsData) (*CarbonIntensity, error) { 172 | validFrom, err := time.Parse(time.RFC3339Nano, dataPoint.DateTime) 173 | if err != nil { 174 | log.Printf("Error parsing datetime %s", dataPoint.DateTime) 175 | return nil, err 176 | } 177 | validTo := validFrom.Add(60 * time.Minute) 178 | carbonIntensityDataPoint := CarbonIntensity{ 179 | EmissionsType: AverageEmissionsType, 180 | MetricType: AbsoluteMetricType, 181 | Provider: ElectricityMaps, 182 | Location: location, 183 | Units: GramsCO2EPerkWh, 184 | ValidFrom: validFrom, 185 | ValidTo: validTo, 186 | Value: dataPoint.CarbonIntensity, 187 | IsEstimated: dataPoint.IsEstimated, 188 | } 189 | return &carbonIntensityDataPoint, nil 190 | } 191 | 192 | func (e *ElectricityMapsClient) historicIntensityURLWithZone(zone string) (string, error) { 193 | zoneURL := fmt.Sprintf("/carbon-intensity/history?zone=%s", zone) 194 | return buildURL(e.apiURL, zoneURL) 195 | } 196 | 197 | type electricityMapsData struct { 198 | Zone string `json:"zone"` 199 | CarbonIntensity float64 `json:"carbonIntensity"` 200 | DateTime string `json:"datetime"` 201 | UpdatedAt string `json:"updatedAt"` 202 | IsEstimated bool `json:"isEstimated"` 203 | } 204 | 205 | type electricityMapsHistoryResponse struct { 206 | Zone string 207 | History []electricityMapsData 208 | } 209 | -------------------------------------------------------------------------------- /pkg/provider/electricity_maps_test.go: -------------------------------------------------------------------------------- 1 | package provider 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/http" 7 | "net/http/httptest" 8 | "reflect" 9 | "testing" 10 | "time" 11 | 12 | "github.com/google/go-cmp/cmp" 13 | ) 14 | 15 | var MockElectricityMapResponse = `{ 16 | "zone": "IN-KA", 17 | "history": [ 18 | { 19 | "zone": "IN-KA", 20 | "carbonIntensity": 312, 21 | "datetime": "2020-01-01T00:00:00.000Z", 22 | "updatedAt": "2020-01-01T00:00:01.000Z", 23 | "isEstimated": true 24 | } 25 | ] 26 | }` 27 | 28 | func Test_ElectricityMaps_SimpleRequest(t *testing.T) { 29 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 30 | fmt.Fprintln(w, MockElectricityMapResponse) 31 | })) 32 | defer ts.Close() 33 | 34 | c := ElectricityMapsConfig{ 35 | APIURL: ts.URL, 36 | Token: "token", 37 | } 38 | a, err := NewElectricityMaps(c) 39 | if err != nil { 40 | t.Errorf("Could not make provider: %s", err) 41 | return 42 | } 43 | 44 | res, err := a.GetCarbonIntensity(context.Background(), "IN-KA") 45 | if err != nil { 46 | t.Fatalf("got error on GetCarbonIntensity: %s", err) 47 | } 48 | 49 | expected := []CarbonIntensity{ 50 | { 51 | EmissionsType: "average", 52 | MetricType: "absolute", 53 | Provider: "ElectricityMaps", 54 | Location: "IN-KA", 55 | Units: "gCO2e per kWh", 56 | ValidFrom: time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC), 57 | ValidTo: time.Date(2020, 1, 1, 1, 0, 0, 0, time.UTC), 58 | Value: 312, 59 | IsEstimated: true, 60 | }, 61 | } 62 | if !reflect.DeepEqual(expected, res) { 63 | t.Errorf("want matching \n %s", cmp.Diff(res, expected)) 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /pkg/provider/ember.go: -------------------------------------------------------------------------------- 1 | package provider 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "strings" 7 | "time" 8 | 9 | "github.com/thegreenwebfoundation/grid-intensity-go/pkg/internal/data" 10 | ) 11 | 12 | const ( 13 | emberDataYear = 2021 14 | ) 15 | 16 | type EmberClient struct { 17 | data map[string]data.EmberGridIntensity 18 | } 19 | 20 | func NewEmber() (Interface, error) { 21 | data, err := data.GetEmberGridIntensity() 22 | if err != nil { 23 | return nil, err 24 | } 25 | 26 | c := &EmberClient{ 27 | data: data, 28 | } 29 | 30 | return c, nil 31 | } 32 | 33 | func (a *EmberClient) GetCarbonIntensity(ctx context.Context, location string) ([]CarbonIntensity, error) { 34 | location = strings.ToUpper(location) 35 | result, ok := a.data[location] 36 | if !ok { 37 | return nil, fmt.Errorf("location %q not found", location) 38 | } 39 | 40 | validFrom := time.Date(emberDataYear, 1, 1, 0, 0, 0, 0, time.UTC) 41 | validTo := time.Date(emberDataYear, 12, 31, 23, 59, 0, 0, time.UTC) 42 | 43 | return []CarbonIntensity{ 44 | { 45 | EmissionsType: AverageEmissionsType, 46 | MetricType: AbsoluteMetricType, 47 | Provider: Ember, 48 | Location: location, 49 | Units: GramsCO2EPerkWh, 50 | ValidFrom: validFrom, 51 | ValidTo: validTo, 52 | Value: result.EmissionsIntensityGCO2PerKWH, 53 | IsEstimated: true, 54 | }, 55 | }, nil 56 | } 57 | -------------------------------------------------------------------------------- /pkg/provider/ember_test.go: -------------------------------------------------------------------------------- 1 | package provider 2 | 3 | import ( 4 | "context" 5 | "reflect" 6 | "testing" 7 | "time" 8 | 9 | "github.com/google/go-cmp/cmp" 10 | ) 11 | 12 | func Test_GetGridIntensityForCountry(t *testing.T) { 13 | ctx := context.Background() 14 | 15 | tests := []struct { 16 | name string 17 | location string 18 | result []CarbonIntensity 19 | expectedErr string 20 | }{ 21 | { 22 | name: "country exists", 23 | location: "ESP", 24 | result: []CarbonIntensity{ 25 | { 26 | EmissionsType: "average", 27 | MetricType: "absolute", 28 | Provider: "Ember", 29 | Location: "ESP", 30 | Units: "gCO2e per kWh", 31 | ValidFrom: time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC), 32 | ValidTo: time.Date(2021, 12, 31, 23, 59, 0, 0, time.UTC), 33 | Value: 193.737, 34 | IsEstimated: true, 35 | }, 36 | }, 37 | }, 38 | { 39 | name: "2 char country code", 40 | location: "ES", 41 | result: []CarbonIntensity{ 42 | { 43 | EmissionsType: "average", 44 | MetricType: "absolute", 45 | Provider: "Ember", 46 | Location: "ES", 47 | Units: "gCO2e per kWh", 48 | ValidFrom: time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC), 49 | ValidTo: time.Date(2021, 12, 31, 23, 59, 0, 0, time.UTC), 50 | Value: 193.737, 51 | IsEstimated: true, 52 | }, 53 | }, 54 | }, 55 | { 56 | name: "lower case country code", 57 | location: "gbr", 58 | result: []CarbonIntensity{ 59 | { 60 | EmissionsType: "average", 61 | MetricType: "absolute", 62 | Provider: "Ember", 63 | Location: "GBR", 64 | Units: "gCO2e per kWh", 65 | ValidFrom: time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC), 66 | ValidTo: time.Date(2021, 12, 31, 23, 59, 0, 0, time.UTC), 67 | Value: 268.255, 68 | IsEstimated: true, 69 | }, 70 | }, 71 | }, 72 | { 73 | name: "invalid country code", 74 | location: "AAA", 75 | expectedErr: "location \"AAA\" not found", 76 | }, 77 | } 78 | 79 | p, err := NewEmber() 80 | if err != nil { 81 | t.Errorf("Could not make provider: %s", err) 82 | return 83 | } 84 | 85 | for _, tc := range tests { 86 | t.Run(tc.name, func(t *testing.T) { 87 | result, err := p.GetCarbonIntensity(ctx, tc.location) 88 | switch { 89 | case err != nil && tc.expectedErr == "": 90 | t.Fatalf("error == %#v want nil", err) 91 | case err == nil && tc.expectedErr != "": 92 | t.Fatalf("error == nil want non-nil") 93 | } 94 | 95 | if result != nil && len(result) != len(tc.result) { 96 | t.Fatalf("expected %d result got %d", len(tc.result), len(result)) 97 | } 98 | if result != nil && !reflect.DeepEqual(tc.result, result) { 99 | t.Errorf("want matching \n %s", cmp.Diff(result, tc.result)) 100 | } 101 | if tc.expectedErr != "" && tc.expectedErr != err.Error() { 102 | t.Fatalf("expected error %q got %q", tc.expectedErr, err.Error()) 103 | } 104 | }) 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /pkg/provider/error.go: -------------------------------------------------------------------------------- 1 | package provider 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "io/ioutil" 7 | "net/http" 8 | ) 9 | 10 | var ( 11 | ErrInvalidLocation error = errors.New("location is not supported by this provider") 12 | ErrNoMarginalIntensityPresent error = errors.New("no marginal intensity present") 13 | ErrNoRelativeIntensityPresent error = errors.New("no relative intensity present") 14 | ErrNoResponse error = errors.New("no data was received in response, try again later") 15 | ErrUnknownResponse error = errors.New("unknown index received") 16 | ErrReceivedNon200Status error = errors.New("received non-200 status") 17 | ErrReceived403Forbidden error = errors.New("received 403 forbidden") 18 | ) 19 | 20 | func errBadStatus(resp *http.Response) error { 21 | data, err := ioutil.ReadAll(resp.Body) 22 | if err != nil { 23 | err = fmt.Errorf("could not read error response: %w", err) 24 | } else { 25 | err = errors.New(string(data)) 26 | } 27 | 28 | return fmt.Errorf("%s - %s: %w", resp.Status, err, ErrReceivedNon200Status) 29 | } 30 | -------------------------------------------------------------------------------- /pkg/provider/provider.go: -------------------------------------------------------------------------------- 1 | package provider 2 | 3 | import ( 4 | "context" 5 | "net/url" 6 | "path" 7 | "time" 8 | ) 9 | 10 | const ( 11 | // Supported emissions types. 12 | AverageEmissionsType = "average" 13 | MarginalEmissionsType = "marginal" 14 | 15 | // Supported metric types. 16 | AbsoluteMetricType = "absolute" 17 | RelativeMetricType = "relative" 18 | 19 | // Supported units. 20 | GramsCO2EPerkWh = "gCO2e per kWh" 21 | LbCO2EPerMWh = "lbCO2e per MWh" 22 | Percent = "percent" 23 | 24 | // Supported providers 25 | CarbonIntensityOrgUK = "CarbonIntensityOrgUK" 26 | ElectricityMaps = "ElectricityMaps" 27 | Ember = "Ember" 28 | WattTime = "WattTime" 29 | ) 30 | 31 | type CarbonIntensity struct { 32 | EmissionsType string `json:"emissions_type"` 33 | MetricType string `json:"metric_type"` 34 | Provider string `json:"provider"` 35 | Location string `json:"location"` 36 | Units string `json:"units"` 37 | ValidFrom time.Time `json:"valid_from"` 38 | ValidTo time.Time `json:"valid_to"` 39 | Value float64 `json:"value"` 40 | IsEstimated bool `json:"is_estimated"` 41 | } 42 | 43 | type Details struct { 44 | Name string 45 | URL string 46 | } 47 | 48 | type Interface interface { 49 | GetCarbonIntensity(ctx context.Context, location string) ([]CarbonIntensity, error) 50 | } 51 | 52 | func GetProviderDetails() []Details { 53 | return []Details{ 54 | { 55 | Name: CarbonIntensityOrgUK, 56 | URL: "carbonintensity.org.uk", 57 | }, 58 | { 59 | Name: ElectricityMaps, 60 | URL: "electricitymaps.com", 61 | }, 62 | { 63 | Name: Ember, 64 | URL: "ember-climate.org", 65 | }, 66 | { 67 | Name: WattTime, 68 | URL: "watttime.org", 69 | }, 70 | } 71 | } 72 | 73 | func buildURL(apiURL, relativePath string) (string, error) { 74 | baseURL, err := url.Parse(apiURL) 75 | if err != nil { 76 | return "", err 77 | } 78 | 79 | relativeURL, err := url.Parse(relativePath) 80 | if err != nil { 81 | return "", err 82 | } 83 | 84 | // Safely add relative path. 85 | baseURL.Path = path.Join(baseURL.Path, relativeURL.Path) 86 | 87 | // Safely merge query strings. 88 | baseQuery := baseURL.Query() 89 | 90 | for param, values := range relativeURL.Query() { 91 | for _, value := range values { 92 | baseQuery.Add(param, value) 93 | } 94 | } 95 | 96 | baseURL.RawQuery = baseQuery.Encode() 97 | return baseURL.String(), nil 98 | } 99 | -------------------------------------------------------------------------------- /pkg/provider/watt_time.go: -------------------------------------------------------------------------------- 1 | package provider 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "errors" 7 | "fmt" 8 | "io" 9 | "log" 10 | "net/http" 11 | "strconv" 12 | "time" 13 | ) 14 | 15 | type WattTimeClient struct { 16 | cache *cacheStore 17 | client *http.Client 18 | apiURL string 19 | apiUser string 20 | apiPassword string 21 | token string 22 | } 23 | 24 | type WattTimeConfig struct { 25 | Client *http.Client 26 | APIURL string 27 | APIUser string 28 | APIPassword string 29 | CacheFile string 30 | } 31 | 32 | func NewWattTime(config WattTimeConfig) (Interface, error) { 33 | if config.Client == nil { 34 | config.Client = &http.Client{ 35 | Timeout: 5 * time.Second, 36 | } 37 | } 38 | if config.APIURL == "" { 39 | config.APIURL = "https://api2.watttime.org/v2" 40 | } 41 | 42 | c := cacheConfig{ 43 | CacheFile: config.CacheFile, 44 | } 45 | cache, err := NewCacheStore(c) 46 | if err != nil { 47 | return nil, fmt.Errorf("could not make cache %v", err) 48 | } 49 | 50 | w := &WattTimeClient{ 51 | cache: cache, 52 | client: config.Client, 53 | apiURL: config.APIURL, 54 | apiUser: config.APIUser, 55 | apiPassword: config.APIPassword, 56 | } 57 | 58 | return w, nil 59 | } 60 | 61 | func (w *WattTimeClient) GetCarbonIntensity(ctx context.Context, location string) ([]CarbonIntensity, error) { 62 | result, err := w.fetchCarbonIntensityData(ctx, location) 63 | if err != nil { 64 | return nil, err 65 | } 66 | 67 | return result, nil 68 | } 69 | 70 | func (w *WattTimeClient) fetchCarbonIntensityData(ctx context.Context, location string) ([]CarbonIntensity, error) { 71 | result, err := w.cache.getCacheData(ctx, location) 72 | if err != nil { 73 | return nil, err 74 | } 75 | if result != nil { 76 | return result, nil 77 | } 78 | 79 | if w.token == "" { 80 | token, err := w.getAccessToken(ctx) 81 | if err != nil { 82 | return nil, err 83 | } 84 | w.token = token 85 | } 86 | 87 | indexData, err := w.getCarbonIntensityData(ctx, location) 88 | if errors.Is(err, ErrReceived403Forbidden) { 89 | token, err := w.getAccessToken(ctx) 90 | if err != nil { 91 | return nil, err 92 | } 93 | w.token = token 94 | 95 | indexData, err = w.getCarbonIntensityData(ctx, location) 96 | if err != nil { 97 | return nil, err 98 | } 99 | } else if err != nil { 100 | return nil, err 101 | } 102 | 103 | result, ttl, err := parseCarbonIntensityData(ctx, location, indexData) 104 | if err != nil { 105 | return nil, err 106 | } 107 | 108 | err = w.cache.setCacheData(ctx, location, result, ttl) 109 | if err != nil { 110 | return nil, err 111 | } 112 | 113 | return result, nil 114 | } 115 | 116 | func (w *WattTimeClient) getAccessToken(ctx context.Context) (string, error) { 117 | loginURL, err := w.loginURL() 118 | if err != nil { 119 | return "", err 120 | } 121 | 122 | req, err := http.NewRequestWithContext(ctx, http.MethodGet, loginURL, nil) 123 | if err != nil { 124 | return "", err 125 | } 126 | req.SetBasicAuth(w.apiUser, w.apiPassword) 127 | 128 | log.Printf("calling %s", req.URL) 129 | 130 | resp, err := w.client.Do(req) 131 | if err != nil { 132 | return "", err 133 | } 134 | defer resp.Body.Close() 135 | 136 | if resp.StatusCode != http.StatusOK { 137 | return "", errBadStatus(resp) 138 | } 139 | 140 | bytes, err := io.ReadAll(resp.Body) 141 | if err != nil { 142 | return "", nil 143 | } 144 | 145 | loginResp := wattTimeLoginResp{} 146 | err = json.Unmarshal(bytes, &loginResp) 147 | if err != nil { 148 | return "", nil 149 | } 150 | 151 | return loginResp.Token, nil 152 | } 153 | 154 | func (w *WattTimeClient) getCarbonIntensityData(ctx context.Context, location string) (*wattTimeIndexData, error) { 155 | indexURL, err := w.indexURL(location) 156 | if err != nil { 157 | return nil, err 158 | } 159 | 160 | req, err := http.NewRequestWithContext(ctx, http.MethodGet, indexURL, nil) 161 | if err != nil { 162 | return nil, err 163 | } 164 | 165 | req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", w.token)) 166 | 167 | log.Printf("calling %s", req.URL) 168 | 169 | resp, err := w.client.Do(req) 170 | if err != nil { 171 | return nil, err 172 | } 173 | defer resp.Body.Close() 174 | 175 | if resp.StatusCode == http.StatusForbidden { 176 | return nil, ErrReceived403Forbidden 177 | } else if resp.StatusCode != http.StatusOK { 178 | return nil, errBadStatus(resp) 179 | } 180 | 181 | bytes, err := io.ReadAll(resp.Body) 182 | if err != nil { 183 | return nil, err 184 | } 185 | 186 | indexData := wattTimeIndexData{} 187 | err = json.Unmarshal(bytes, &indexData) 188 | if err != nil { 189 | return nil, err 190 | } 191 | 192 | return &indexData, nil 193 | } 194 | 195 | func (w *WattTimeClient) indexURL(location string) (string, error) { 196 | indexPath := fmt.Sprintf("/index?ba=%s", location) 197 | return buildURL(w.apiURL, indexPath) 198 | } 199 | 200 | func (w *WattTimeClient) loginURL() (string, error) { 201 | return buildURL(w.apiURL, "/login") 202 | } 203 | 204 | func parseCarbonIntensityData(ctx context.Context, location string, indexData *wattTimeIndexData) ([]CarbonIntensity, time.Time, error) { 205 | freq, err := strconv.ParseInt(indexData.Freq, 0, 64) 206 | if err != nil { 207 | return nil, time.Time{}, err 208 | } 209 | 210 | validFrom := indexData.PointTime 211 | validTo := validFrom.Add(time.Duration(freq) * time.Second) 212 | 213 | ttl := validTo 214 | if ttl.Before(time.Now()) { 215 | // The TTL calculated from the point time is in the past. So reset the 216 | // TTL using the current time plus the frequency provided by the API. 217 | // UTC is used to match the WattTime API. 218 | ttl = time.Now().UTC().Add(time.Duration(freq) * time.Second) 219 | } 220 | 221 | result := []CarbonIntensity{} 222 | 223 | if indexData.Percent != "" { 224 | percent, err := strconv.ParseFloat(indexData.Percent, 64) 225 | if err != nil { 226 | return nil, time.Time{}, err 227 | } 228 | relative := CarbonIntensity{ 229 | EmissionsType: MarginalEmissionsType, 230 | MetricType: RelativeMetricType, 231 | Provider: WattTime, 232 | Location: location, 233 | Units: Percent, 234 | ValidFrom: validFrom, 235 | ValidTo: validTo, 236 | Value: percent, 237 | IsEstimated: true, 238 | } 239 | result = append(result, relative) 240 | } 241 | 242 | if indexData.MOER != "" { 243 | moer, err := strconv.ParseFloat(indexData.MOER, 64) 244 | if err != nil { 245 | return nil, time.Time{}, err 246 | } 247 | marginal := CarbonIntensity{ 248 | EmissionsType: MarginalEmissionsType, 249 | MetricType: AbsoluteMetricType, 250 | Provider: WattTime, 251 | Location: location, 252 | Units: LbCO2EPerMWh, 253 | ValidFrom: validFrom, 254 | ValidTo: validTo, 255 | Value: moer, 256 | IsEstimated: true, 257 | } 258 | result = append(result, marginal) 259 | } 260 | 261 | return result, ttl, nil 262 | } 263 | 264 | type wattTimeIndexData struct { 265 | BA string `json:"ba"` 266 | Freq string `json:"freq"` 267 | MOER string `json:"moer"` 268 | Percent string `json:"percent"` 269 | PointTime time.Time `json:"point_time"` 270 | } 271 | 272 | type wattTimeLoginResp struct { 273 | Token string `json:"token"` 274 | } 275 | -------------------------------------------------------------------------------- /pkg/provider/watt_time_test.go: -------------------------------------------------------------------------------- 1 | package provider 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/http" 7 | "net/http/httptest" 8 | "reflect" 9 | "testing" 10 | "time" 11 | 12 | "github.com/google/go-cmp/cmp" 13 | ) 14 | 15 | var MockWattTimeIndexResponse = `{ 16 | "ba": "CAISO_NORTH", 17 | "freq": "300", 18 | "moer": "916", 19 | "percent": "78", 20 | "point_time": "2022-07-06T16:25:00Z" 21 | }` 22 | var MockWattTimeLoginResponse = `{"token":"mytoken"}` 23 | 24 | func makeWattTimeTestServer(t *testing.T) *httptest.Server { 25 | return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 26 | switch r.URL.Path { 27 | case "/login": 28 | fmt.Fprintln(w, MockWattTimeLoginResponse) 29 | case "/index": 30 | fmt.Fprintln(w, MockWattTimeIndexResponse) 31 | default: 32 | t.Errorf("unknown path %#q", r.URL.Path) 33 | } 34 | })) 35 | } 36 | 37 | func Test_WattTime_SimpleRequest(t *testing.T) { 38 | ts := makeWattTimeTestServer(t) 39 | defer ts.Close() 40 | 41 | c := WattTimeConfig{ 42 | APIURL: ts.URL, 43 | APIUser: "user", 44 | APIPassword: "password", 45 | } 46 | w, err := NewWattTime(c) 47 | if err != nil { 48 | t.Errorf("Could not make provider: %s", err) 49 | return 50 | } 51 | 52 | result, err := w.GetCarbonIntensity(context.Background(), "CAISO_NORTH") 53 | if err != nil { 54 | t.Errorf("Got error on GetCarbonIntensity: %s", err) 55 | return 56 | } 57 | 58 | expected := []CarbonIntensity{ 59 | { 60 | EmissionsType: "marginal", 61 | MetricType: "relative", 62 | Provider: "WattTime", 63 | Location: "CAISO_NORTH", 64 | Units: "percent", 65 | ValidFrom: time.Date(2022, 7, 6, 16, 25, 0, 0, time.UTC), 66 | ValidTo: time.Date(2022, 7, 6, 16, 30, 0, 0, time.UTC), 67 | Value: 78, 68 | IsEstimated: true, 69 | }, 70 | { 71 | EmissionsType: "marginal", 72 | MetricType: "absolute", 73 | Provider: "WattTime", 74 | Location: "CAISO_NORTH", 75 | Units: "lbCO2e per MWh", 76 | ValidFrom: time.Date(2022, 7, 6, 16, 25, 0, 0, time.UTC), 77 | ValidTo: time.Date(2022, 7, 6, 16, 30, 0, 0, time.UTC), 78 | Value: 916, 79 | IsEstimated: true, 80 | }, 81 | } 82 | if !reflect.DeepEqual(expected, result) { 83 | t.Errorf("want matching \n %s", cmp.Diff(result, expected)) 84 | } 85 | } 86 | --------------------------------------------------------------------------------