├── .github └── workflows │ ├── build-prerelease.yml │ └── build-release.yml ├── .gitignore ├── .vscode └── launch.json ├── CHANGELOG.md ├── LICENSE ├── Magefile.go ├── README.md ├── configuration.yml ├── go.mod ├── go.sum ├── images └── dashboard-synchronizer.png └── pkg ├── internal ├── git_api.go ├── grafana_api.go └── synchronizer.go └── main.go /.github/workflows/build-prerelease.yml: -------------------------------------------------------------------------------- 1 | name: Build Prerelease Version 2 | on: 3 | push: 4 | branches: 5 | - main 6 | jobs: 7 | build-prerelease: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v2 11 | - name: Setup Go 12 | uses: actions/setup-go@v2 13 | with: 14 | go-version: '1.17.3' 15 | - name: Install Go Dependencies 16 | run: | 17 | go mod tidy 18 | - name: Install Application 19 | uses: magefile/mage-action@v1 20 | with: 21 | version: latest 22 | args: -v 23 | - uses: "marvinpinto/action-automatic-releases@latest" 24 | with: 25 | repo_token: "${{ secrets.GITHUB_TOKEN }}" 26 | automatic_release_tag: "latest" 27 | prerelease: true 28 | title: "Development Build ${{ steps.package-version.outputs.current-version}}-${{ github.run_number }}" 29 | files: | 30 | dist/* 31 | -------------------------------------------------------------------------------- /.github/workflows/build-release.yml: -------------------------------------------------------------------------------- 1 | name: Build Release Version 2 | on: 3 | push: 4 | tags: 5 | - 'v*.*.*' 6 | jobs: 7 | build-release: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v2 11 | - name: Set Release Version 12 | run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/v}" >> $GITHUB_ENV 13 | - name: Setup Go 14 | uses: actions/setup-go@v2 15 | with: 16 | go-version: '1.17.3' 17 | - name: Install Go Dependencies 18 | run: | 19 | go mod tidy 20 | - name: Install Application 21 | uses: magefile/mage-action@v1 22 | with: 23 | version: latest 24 | args: -v 25 | - uses: "marvinpinto/action-automatic-releases@latest" 26 | with: 27 | repo_token: "${{ secrets.GITHUB_TOKEN }}" 28 | automatic_release_tag: "v${{ env.RELEASE_VERSION }}" 29 | prerelease: false 30 | title: "Version ${{ env.RELEASE_VERSION }}" 31 | files: | 32 | dist/* 33 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | node_modules/ 9 | 10 | # Runtime data 11 | pids 12 | *.pid 13 | *.seed 14 | *.pid.lock 15 | 16 | # Directory for instrumented libs generated by jscoverage/JSCover 17 | lib-cov 18 | 19 | # Coverage directory used by tools like istanbul 20 | coverage 21 | 22 | # Compiled binary addons (https://nodejs.org/api/addons.html) 23 | dist/ 24 | artifacts/ 25 | work/ 26 | ci/ 27 | e2e-results/ 28 | 29 | # Editor 30 | .idea 31 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "Launch Package", 9 | "type": "go", 10 | "request": "launch", 11 | "mode": "auto", 12 | "program": "pkg", 13 | "args": [ 14 | "-c", "../configuration.yml"//, "--dry-run" 15 | ] 16 | } 17 | ] 18 | } -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 1.0.0 (Unreleased) 4 | 5 | Initial release. 6 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /Magefile.go: -------------------------------------------------------------------------------- 1 | //go:build mage 2 | // +build mage 3 | 4 | package main 5 | 6 | import ( 7 | "fmt" 8 | "os" 9 | "path/filepath" 10 | 11 | "github.com/magefile/mage/mg" 12 | "github.com/magefile/mage/sh" 13 | // mg contains helpful utility functions, like Deps 14 | ) 15 | 16 | // Default target to run when none is specified 17 | // If not set, running mage will list available targets 18 | // var Default = Build 19 | 20 | func getExecutableName(os string, arch string) string { 21 | exeName := fmt.Sprintf("%s_%s_%s", "grafana-dashboard-synchronizer", os, arch) 22 | if os == "windows" { 23 | exeName = fmt.Sprintf("%s.exe", exeName) 24 | } 25 | return exeName 26 | } 27 | 28 | // A build step that requires additional params, or platform specific steps for example 29 | func buildPlatform(os string, arch string) error { 30 | exeName := getExecutableName(os, arch) 31 | 32 | envMap := make(map[string]string) 33 | 34 | envMap["GOARCH"] = arch 35 | envMap["GOOS"] = os 36 | 37 | // TODO: Change to sh.RunWithV once available. 38 | return sh.RunWith(envMap, "go", "build", "-o", filepath.Join("dist", exeName), "./pkg") 39 | } 40 | 41 | func BuildWindows() error { 42 | return buildPlatform("windows", "amd64") 43 | } 44 | 45 | func BuildLinux() error { 46 | return buildPlatform("linux", "amd64") 47 | } 48 | 49 | func BuildLinuxARM() error { 50 | return buildPlatform("linux", "arm") 51 | } 52 | 53 | func BuildLinuxARM64() error { 54 | return buildPlatform("linux", "arm64") 55 | } 56 | 57 | func BuildDarwin() error { 58 | return buildPlatform("darwin", "amd64") 59 | } 60 | 61 | func BuildDarwinARM64() error { 62 | return buildPlatform("darwin", "arm64") 63 | } 64 | 65 | func BuildAll() { //revive:disable-line 66 | mg.Deps(Clean) 67 | 68 | fmt.Println("Building all platforms...") 69 | 70 | mg.Deps(BuildWindows, BuildLinux, BuildLinuxARM, BuildLinuxARM64, BuildDarwin, BuildDarwinARM64) 71 | } 72 | 73 | // Clean up after yourself 74 | func Clean() { 75 | fmt.Println("Cleaning...") 76 | os.RemoveAll("dist") 77 | } 78 | 79 | var Default = BuildAll 80 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Grafana Dashboard Synchronizer 2 | 3 | A small CLI tool to do a tag-base automatic synchornization and backup Grafana of dashboards across multiple Grafana instances. 4 | 5 | This application can be used to synchronize dashboards, using a Git repository, across multiple Grafana instances. 6 | A possible use cases: 7 | - Backup certain dashboards regularly using a Git repository 8 | - Push Grafana dashboards from one Grafana instance to a Git repository and import them into another Grafana instance. In addition, users can use tags to determine for themselves when a dashboard should be synchronized. 9 | - Preload new Grafana instances with predefined dashboards. 10 | 11 | #### Example Usecase 12 | 13 | This can be useful to stage dashboards from "dev" to "prod" environments. 14 | 15 | ![image](images/dashboard-synchronizer.png) 16 | 17 | ## Usage 18 | 19 | The application can be used as follows: 20 | 21 | $ ./grafana-dashboard-synchronizer [options] 22 | 23 | By default, the application will use a configuration file named `configuration.yml` next to the binary. A custom configuration file can be used using the `--config` or `-c` option flag: 24 | 25 | $ ./grafana-dashboard-synchronizer --config /custom/configuration.yml 26 | 27 | In addition, a dry-run flag can be used. When the `--dry-run` flag is used, the application does not perform any modifications. This can be useful when testing what changes would be made. 28 | 29 | $ ./grafana-dashboard-synchronizer --dry-run 30 | 31 | By default, the application logs in an easy-to-read text format. With the `--log-as-json` flag, the application generates logs in JSON format, which is convenient if the logs are processed by other tools such as Logstash: 32 | 33 | $ ./grafana-dashboard-synchronizer 34 | INFO[0000] Synchronizing Grafana dashboards... 35 | ... 36 | 37 | compared to: 38 | 39 | $ ./grafana-dashboard-synchronizer --log-as-json 40 | {"level":"info","msg":"Synchronizing Grafana dashboards...","time":"2022-02-08T16:41:26+01:00"} 41 | ... 42 | 43 | ### Configuration 44 | 45 | The configuration file can contain multiple jobs, which will be sequentially executed. Furthermore, the push (export) step of a job is executed before its pull (import) step. 46 | 47 | See the following configuration for available configuration options: 48 | 49 | - job-name: "example-job" 50 | # API token to interact with the specified Grafana instance 51 | grafana-token: "eyJrIjoiSEp4dzhGdVBxMUhBdm..." 52 | # Base URL of the Grafana instance 53 | grafana-url: "http://localhost:3000" 54 | # SSH-URL of the Git repository to use 55 | git-repository-url: "" 56 | # Private key to use for authentication against the Git repository 57 | private-key-file: "" 58 | 59 | # push (export) related configurations 60 | push-configuration: 61 | # whether to export dashboards 62 | enable: true 63 | # the branch to use for exporting dashboards 64 | git-branch: "push-branch" 65 | # only dashboards with match this pattern will be considered in the sync process 66 | filter: "" 67 | # the tag to determine which dashboards should be exported 68 | tag-pattern: "agent" 69 | # whether the sync-tag should be kept during exporting 70 | push-tags: true 71 | 72 | # pull (import) related configurations 73 | pull-configuration: 74 | # whether to import dashboards 75 | enable: true 76 | # the branch to use for importing dashboards 77 | git-branch: "pull-branch" 78 | # only dashboards with match this pattern will be considered in the sync process 79 | filter: "" 80 | 81 | ## Development 82 | 83 | ### Getting started 84 | 85 | 1. Update and get dependencies: 86 | 87 | ```bash 88 | go mod tidy 89 | ``` 90 | 91 | 2. Build binaries for Linux, Windows and Darwin: 92 | 93 | ```bash 94 | mage -v 95 | ``` 96 | 97 | 3. List all available Mage targets for additional commands: 98 | 99 | ```bash 100 | mage -l 101 | ``` 102 | 103 | ### Releasing the Application 104 | 105 | The release process of the application is automated using Github Actions. 106 | On each push to the `main` branch, a new prerelease is created and the corresponding commit is tagged "latest". 107 | Old prereleases will be deleted. 108 | 109 | To create a normal release, the commit that is used as the basis for the release must be tagged with the following format: `v*.*.*`. 110 | After that, the release is built and created with the version number extracted from the tag. 111 | Furthermore, a new commit is created, which sets the current version in the `main` branch to the version that has been released. 112 | -------------------------------------------------------------------------------- /configuration.yml: -------------------------------------------------------------------------------- 1 | ########################## 2 | # example configuration 3 | ########################## 4 | 5 | - job-name: "example-job" 6 | # API token to interact with the specified Grafana instance 7 | grafana-token: "eyJrIjoiSEp4dzhGdVBxMUhBdm5Db..." 8 | # Base URL of the Grafana instance 9 | grafana-url: "http://localhost:3000" 10 | # SSH-URL of the Git repository to use 11 | git-repository-url: "" 12 | # Private key to use for authentication against the Git repository 13 | private-key-file: "" 14 | 15 | # push (export) related configurations 16 | push-configuration: 17 | # whether to export dashboards 18 | enable: true 19 | # the branch to use for exporting dashboards 20 | git-branch: "push-branch" 21 | # only dashboards with match this pattern will be considered in the sync process. 22 | # this value is a WHITELIST in case it is not empty! 23 | filter: "" 24 | # the tag to determine which dashboards should be exported 25 | tag-pattern: "sync" 26 | # whether the sync-tag should be kept during exporting 27 | push-tags: true 28 | 29 | # pull (import) related configurations 30 | pull-configuration: 31 | # whether to import dashboards 32 | enable: true 33 | # the branch to use for importing dashboards 34 | git-branch: "pull-branch" 35 | # only dashboards with match this pattern will be considered in the sync process. 36 | # this value is a WHITELIST in case it is not empty! 37 | filter: "" -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/NovatecConsulting/grafana-dashboard-sync 2 | 3 | go 1.16 4 | 5 | require ( 6 | github.com/NovatecConsulting/grafana-api-go-sdk v0.0.0-20220202161647-eb8dd86d92dc 7 | github.com/go-git/go-git/v5 v5.4.2 8 | github.com/golobby/config v1.2.0 9 | github.com/golobby/config/v3 v3.3.1 10 | github.com/gosimple/slug v1.11.2 // indirect 11 | github.com/magefile/mage v1.11.0 12 | github.com/sergi/go-diff v1.2.0 // indirect 13 | github.com/sirupsen/logrus v1.8.1 14 | github.com/urfave/cli/v2 v2.3.0 // indirect 15 | golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b 16 | gopkg.in/src-d/go-billy.v4 v4.3.2 17 | gopkg.in/src-d/go-git.v4 v4.13.1 18 | gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b // indirect 19 | ) 20 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= 2 | github.com/BurntSushi/toml v0.4.1 h1:GaI7EiDXDRfa8VshkTj7Fym7ha+y8/XxIgD2okUIjLw= 3 | github.com/BurntSushi/toml v0.4.1/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= 4 | github.com/Microsoft/go-winio v0.4.14/go.mod h1:qXqCSQ3Xa7+6tgxaGTIe4Kpcdsi+P8jBhyzoq1bpyYA= 5 | github.com/Microsoft/go-winio v0.4.16 h1:FtSW/jqD+l4ba5iPBj9CODVtgfYAD8w2wS923g/cFDk= 6 | github.com/Microsoft/go-winio v0.4.16/go.mod h1:XB6nPKklQyQ7GC9LdcBEcBl8PF76WugXOPRXwdLnMv0= 7 | github.com/NovatecConsulting/grafana-api-go-sdk v0.0.0-20220202161647-eb8dd86d92dc h1:dcyOuC/cLAdNyDvOhB8OmpBM9fUEFbTwFnoykk4DPuY= 8 | github.com/NovatecConsulting/grafana-api-go-sdk v0.0.0-20220202161647-eb8dd86d92dc/go.mod h1:s+T3InecbeNbemc/5gVb1Qs1c5V1GGf2KBCzkXaQnmE= 9 | github.com/ProtonMail/go-crypto v0.0.0-20210428141323-04723f9f07d7 h1:YoJbenK9C67SkzkDfmQuVln04ygHj3vjZfd9FL+GmQQ= 10 | github.com/ProtonMail/go-crypto v0.0.0-20210428141323-04723f9f07d7/go.mod h1:z4/9nQmJSSwwds7ejkxaJwO37dru3geImFUdJlaLzQo= 11 | github.com/acomagu/bufpipe v1.0.3 h1:fxAGrHZTgQ9w5QqVItgzwj235/uYZYgbXitB+dLupOk= 12 | github.com/acomagu/bufpipe v1.0.3/go.mod h1:mxdxdup/WdsKVreO5GpW4+M/1CE2sMG4jeGJ2sYmHc4= 13 | github.com/alcortesm/tgz v0.0.0-20161220082320-9c5fe88206d7 h1:uSoVVbwJiQipAclBbw+8quDsfcvFjOpI5iCf4p/cqCs= 14 | github.com/alcortesm/tgz v0.0.0-20161220082320-9c5fe88206d7/go.mod h1:6zEj6s6u/ghQa61ZWa/C2Aw3RkjiTBOix7dkqa1VLIs= 15 | github.com/anmitsu/go-shlex v0.0.0-20161002113705-648efa622239 h1:kFOfPq6dUM1hTo4JG6LR5AXSUEsOjtdm0kw0FtQtMJA= 16 | github.com/anmitsu/go-shlex v0.0.0-20161002113705-648efa622239/go.mod h1:2FmKhYUyUczH0OGQWaF5ceTx0UBShxjsH6f8oGKYe2c= 17 | github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= 18 | github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= 19 | github.com/chromedp/cdproto v0.0.0-20210526005521-9e51b9051fd0/go.mod h1:At5TxYYdxkbQL0TSefRjhLE3Q0lgvqKKMSFUglJ7i1U= 20 | github.com/chromedp/cdproto v0.0.0-20210706234513-2bc298e8be7f h1:lg5k1KAxmknil6Z19LaaeiEs5Pje7hPzRfyWSSnWLP0= 21 | github.com/chromedp/cdproto v0.0.0-20210706234513-2bc298e8be7f/go.mod h1:At5TxYYdxkbQL0TSefRjhLE3Q0lgvqKKMSFUglJ7i1U= 22 | github.com/chromedp/chromedp v0.7.3 h1:FvgJICfjvXtDX+miuMUY0NHuY8zQvjS/TcEQEG6Ldzs= 23 | github.com/chromedp/chromedp v0.7.3/go.mod h1:9gC521Yzgrk078Ulv6KIgG7hJ2x9aWrxMBBobTFk30A= 24 | github.com/chromedp/sysutil v1.0.0 h1:+ZxhTpfpZlmchB58ih/LBHX52ky7w2VhQVKQMucy3Ic= 25 | github.com/chromedp/sysutil v1.0.0/go.mod h1:kgWmDdq8fTzXYcKIBqIYvRRTnYb9aNS9moAV0xufSww= 26 | github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d h1:U+s90UTSYgptZMwQh2aRr3LuazLJIa+Pg3Kc1ylSYVY= 27 | github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= 28 | github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY= 29 | github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= 30 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 31 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 32 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 33 | github.com/emirpasic/gods v1.12.0 h1:QAUIPSaCu4G+POclxeqb3F+WPpdKqFGlw36+yOzGlrg= 34 | github.com/emirpasic/gods v1.12.0/go.mod h1:YfzfFFoVP/catgzJb4IKIqXjX78Ha8FMSDh3ymbK86o= 35 | github.com/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568/go.mod h1:xEzjJPgXI435gkrCt3MPfRiAkVrwSbHsst4LCFVfpJc= 36 | github.com/gliderlabs/ssh v0.2.2 h1:6zsha5zo/TWhRhwqCD3+EarCAgZ2yN28ipRnGPnwkI0= 37 | github.com/gliderlabs/ssh v0.2.2/go.mod h1:U7qILu1NlMHj9FlMhZLlkCdDnU1DBEAqr0aevW3Awn0= 38 | github.com/go-git/gcfg v1.5.0 h1:Q5ViNfGF8zFgyJWPqYwA7qGFoMTEiBmdlkcfRmpIMa4= 39 | github.com/go-git/gcfg v1.5.0/go.mod h1:5m20vg6GwYabIxaOonVkTdrILxQMpEShl1xiMF4ua+E= 40 | github.com/go-git/go-billy/v5 v5.2.0/go.mod h1:pmpqyWchKfYfrkb/UVH4otLvyi/5gJlGI4Hb3ZqZ3W0= 41 | github.com/go-git/go-billy/v5 v5.3.1 h1:CPiOUAzKtMRvolEKw+bG1PLRpT7D3LIs3/3ey4Aiu34= 42 | github.com/go-git/go-billy/v5 v5.3.1/go.mod h1:pmpqyWchKfYfrkb/UVH4otLvyi/5gJlGI4Hb3ZqZ3W0= 43 | github.com/go-git/go-git-fixtures/v4 v4.2.1 h1:n9gGL1Ct/yIw+nfsfr8s4+sbhT+Ncu2SubfXjIWgci8= 44 | github.com/go-git/go-git-fixtures/v4 v4.2.1/go.mod h1:K8zd3kDUAykwTdDCr+I0per6Y6vMiRR/nnVTBtavnB0= 45 | github.com/go-git/go-git/v5 v5.4.2 h1:BXyZu9t0VkbiHtqrsvdq39UDhGJTl1h55VW6CSC4aY4= 46 | github.com/go-git/go-git/v5 v5.4.2/go.mod h1:gQ1kArt6d+n+BGd+/B/I74HwRTLhth2+zti4ihgckDc= 47 | github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU= 48 | github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM= 49 | github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og= 50 | github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw= 51 | github.com/gobwas/ws v1.1.0-rc.5 h1:QOAag7FoBaBYYHRqzqkhhd8fq5RTubvI4v3Ft/gDVVQ= 52 | github.com/gobwas/ws v1.1.0-rc.5/go.mod h1:nzvNcVha5eUziGrbxFCo6qFIojQHjJV5cLYIbezhfL0= 53 | github.com/golobby/cast v1.3.0 h1:8nM9nYU5Pzi1LWXwISx0xhW/7oWXPt9r0hdTC1nnPSI= 54 | github.com/golobby/cast v1.3.0/go.mod h1:WCusT3z1fzp4XVBUGbWy61insoQS8CPJHNTQwlW8qnM= 55 | github.com/golobby/config v1.2.0 h1:3Nf+XFtFE+nxiLI7aY5D9fnsiPE0J87TK6ynpXLTWNs= 56 | github.com/golobby/config v1.2.0/go.mod h1:NkgTd6Kf8XomIc/ULT4oqlfrz2WqlNNqNV6ChYZJiws= 57 | github.com/golobby/config/v3 v3.3.1 h1:3vd/x2UP8VppDPdZE1zPnHHgZxr8RD6l9Bztmq924Gc= 58 | github.com/golobby/config/v3 v3.3.1/go.mod h1:oANc874XBdPDndzDqCt4vpCUHcj9Liz4yq6AsePT8qU= 59 | github.com/golobby/dotenv v1.3.1 h1:BvQyNuOQITmIXNHpQ/FUG2gZcUGmcGMyODMeUfiKkeU= 60 | github.com/golobby/dotenv v1.3.1/go.mod h1:EWUdOzuDlA1g4hdjo++WD37DhNZw33Oce8ryH3liZTQ= 61 | github.com/golobby/env/v2 v2.2.0 h1:OzWNfKmXocjvVQ86lLSgfWGMMbL2HwUZWKW1pKIMJw0= 62 | github.com/golobby/env/v2 v2.2.0/go.mod h1:gIDZcMfoaeTsYTLViD2crQ5XsXQV69t+MvIU/M8KMK0= 63 | github.com/google/go-cmp v0.3.0 h1:crn/baboCvb5fXaQ0IJ1SGTsTVrWpDsCWC8EGETZijY= 64 | github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= 65 | github.com/gosimple/slug v1.1.1/go.mod h1:ER78kgg1Mv0NQGlXiDe57DpCyfbNywXXZ9mIorhxAf0= 66 | github.com/gosimple/slug v1.11.2 h1:MxFR0TmQ/qz0KvIrBbf4phu+G0RBgpwxOn6jPKFKFOw= 67 | github.com/gosimple/slug v1.11.2/go.mod h1:UiRaFH+GEilHstLUmcBgWcI42viBN7mAb818JrYOeFQ= 68 | github.com/gosimple/unidecode v1.0.1 h1:hZzFTMMqSswvf0LBJZCZgThIZrpDHFXux9KeGmn6T/o= 69 | github.com/gosimple/unidecode v1.0.1/go.mod h1:CP0Cr1Y1kogOtx0bJblKzsVWrqYaqfNOnHzpgWw4Awc= 70 | github.com/grafana-tools/sdk v0.0.0-20211118073920-e7b85bb25aa9 h1:F9P4x061BWZFpCffSlVdDvSN15moXEoJnFhHm8syG2Q= 71 | github.com/grafana-tools/sdk v0.0.0-20211118073920-e7b85bb25aa9/go.mod h1:AHHlOEv1+GGQ3ktHMlhuTUwo3zljV3QJbC0+8o2kn+4= 72 | github.com/imdario/mergo v0.3.12 h1:b6R2BslTbIEToALKP7LxUvijTsNI9TAe80pLWN2g/HU= 73 | github.com/imdario/mergo v0.3.12/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA= 74 | github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= 75 | github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= 76 | github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= 77 | github.com/jessevdk/go-flags v1.5.0/go.mod h1:Fw0T6WPc1dYxT4mKEZRfG5kJhaTDP9pj1c2EWnYs/m4= 78 | github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= 79 | github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= 80 | github.com/kevinburke/ssh_config v0.0.0-20190725054713-01f96b0aa0cd/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= 81 | github.com/kevinburke/ssh_config v0.0.0-20201106050909-4977a11b4351 h1:DowS9hvgyYSX4TO5NpyC606/Z4SxnNYbT+WX27or6Ck= 82 | github.com/kevinburke/ssh_config v0.0.0-20201106050909-4977a11b4351/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= 83 | github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= 84 | github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= 85 | github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI= 86 | github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= 87 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 88 | github.com/kr/pty v1.1.8/go.mod h1:O1sed60cT9XZ5uDucP5qwvh+TE3NnUj51EiZO/lmSfw= 89 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 90 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 91 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 92 | github.com/magefile/mage v1.11.0 h1:C/55Ywp9BpgVVclD3lRnSYCwXTYxmSppIgLeDYlNuls= 93 | github.com/magefile/mage v1.11.0/go.mod h1:z5UZb/iS3GoOSn0JgWuiw7dxlurVYTu+/jHXqQg881A= 94 | github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= 95 | github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= 96 | github.com/matryer/is v1.2.0 h1:92UTHpy8CDwaJ08GqLDzhhuixiBUUD1p3AU6PHddz4A= 97 | github.com/matryer/is v1.2.0/go.mod h1:2fLPjFQM9rhQ15aVEtbuwhJinnOqrmgXPNdZsdwlWXA= 98 | github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= 99 | github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= 100 | github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= 101 | github.com/pelletier/go-buffruneio v0.2.0/go.mod h1:JkE26KsDizTr40EUHkXVtNPvgGtbSNq5BcowyYOWdKo= 102 | github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 103 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 104 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 105 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 106 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 107 | github.com/rainycape/unidecode v0.0.0-20150907023854-cb7f23ec59be/go.mod h1:MIDFMn7db1kT65GmV94GzpX9Qdi7N/pQlwb+AN8wh+Q= 108 | github.com/russross/blackfriday/v2 v2.0.1 h1:lPqVAte+HuHNfhJ/0LC98ESWRz8afy9tM/0RK8m9o+Q= 109 | github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 110 | github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo= 111 | github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= 112 | github.com/sergi/go-diff v1.2.0 h1:XU+rvMAioB0UC3q1MFrIQy4Vo5/4VsRDQQXHsEya6xQ= 113 | github.com/sergi/go-diff v1.2.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= 114 | github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo= 115 | github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= 116 | github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q= 117 | github.com/sirupsen/logrus v1.8.1 h1:dJKuHgqk1NNQlqoA6BTlM1Wf9DOH3NBjQyu0h9+AZZE= 118 | github.com/sirupsen/logrus v1.8.1/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= 119 | github.com/src-d/gcfg v1.4.0 h1:xXbNR5AlLSA315x2UO+fTSSAXCDf+Ar38/6oyGbDKQ4= 120 | github.com/src-d/gcfg v1.4.0/go.mod h1:p/UMsR43ujA89BJY9duynAwIpvqEujIH/jFlfL7jWoI= 121 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 122 | github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 123 | github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE= 124 | github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= 125 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 126 | github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= 127 | github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= 128 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 129 | github.com/urfave/cli/v2 v2.3.0 h1:qph92Y649prgesehzOrQjdWyxFOp/QVM+6imKHad91M= 130 | github.com/urfave/cli/v2 v2.3.0/go.mod h1:LJmUH05zAU44vOAcrfzZQKsZbVcdbOG8rtL3/XcUArI= 131 | github.com/xanzy/ssh-agent v0.2.1/go.mod h1:mLlQY/MoOhWBj+gOGMQkOeiEvkx+8pJSI+0Bx9h2kr4= 132 | github.com/xanzy/ssh-agent v0.3.0 h1:wUMzuKtKilRgBAD1sUb8gOwwRr2FGoBVumcjoOACClI= 133 | github.com/xanzy/ssh-agent v0.3.0/go.mod h1:3s9xbODqPuuhK9JV1R321M/FlMZSBvE5aY6eAcqrDh0= 134 | golang.org/x/crypto v0.0.0-20190219172222-a4c6cb3142f2/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= 135 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 136 | golang.org/x/crypto v0.0.0-20190701094942-4def268fd1a4/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 137 | golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= 138 | golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b h1:7mWr3k41Qtv8XlltBkDkl8LoP3mpSgBW8BUoxtEdbXg= 139 | golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= 140 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 141 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 142 | golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 143 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 144 | golang.org/x/net v0.0.0-20210326060303-6b1517762897 h1:KrsHThm5nFk34YtATK1LsThyGhGbGe1olrte/HInHvs= 145 | golang.org/x/net v0.0.0-20210326060303-6b1517762897/go.mod h1:uSPa2vr4CLtc/ILN5odXGNXS6mhrKVzTaCXzk9m6W3k= 146 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 147 | golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 148 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 149 | golang.org/x/sys v0.0.0-20190221075227-b4e8571b14e0/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 150 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 151 | golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 152 | golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 153 | golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 154 | golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 155 | golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 156 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 157 | golang.org/x/sys v0.0.0-20201207223542-d4d67f95c62d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 158 | golang.org/x/sys v0.0.0-20210320140829-1e4c9ba3b0c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 159 | golang.org/x/sys v0.0.0-20210324051608-47abb6519492/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 160 | golang.org/x/sys v0.0.0-20210502180810-71e4cd670f79/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 161 | golang.org/x/sys v0.0.0-20210525143221-35b2ab0089ea/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 162 | golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c h1:F1jZWGFhYfh0Ci55sIpILtKKK8p3i2/krTr0H1rg74I= 163 | golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 164 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1 h1:v+OssWQX+hTHEmOBgwxdZxK4zHq3yOs8F9J7mk0PY8E= 165 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 166 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 167 | golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= 168 | golang.org/x/text v0.3.3 h1:cokOdA+Jmi5PJGXLlLllQSgYigAEfHXJAERHVMaCc2k= 169 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 170 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 171 | golang.org/x/tools v0.0.0-20190729092621-ff9f1409240a/go.mod h1:jcCCGcm9btYwXyDqrUWc6MKQKKGJCWEQ3AfLSRIbEuI= 172 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 173 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 174 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 175 | gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 176 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 177 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 178 | gopkg.in/src-d/go-billy.v4 v4.3.2 h1:0SQA1pRztfTFx2miS8sA97XvooFeNOmvUenF4o0EcVg= 179 | gopkg.in/src-d/go-billy.v4 v4.3.2/go.mod h1:nDjArDMp+XMs1aFAESLRjfGSgfvoYN0hDfzEk0GjC98= 180 | gopkg.in/src-d/go-git-fixtures.v3 v3.5.0 h1:ivZFOIltbce2Mo8IjzUHAFoq/IylO9WHhNOAJK+LsJg= 181 | gopkg.in/src-d/go-git-fixtures.v3 v3.5.0/go.mod h1:dLBcvytrw/TYZsNTWCnkNF2DSIlzWYqTe3rJR56Ac7g= 182 | gopkg.in/src-d/go-git.v4 v4.13.1 h1:SRtFyV8Kxc0UP7aCHcijOMQGPxHSmMOPrzulQWolkYE= 183 | gopkg.in/src-d/go-git.v4 v4.13.1/go.mod h1:nx5NYcxdKxq5fpltdHnPa2Exj4Sx0EclMWZQbYDu2z8= 184 | gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME= 185 | gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= 186 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 187 | gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 188 | gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 189 | gopkg.in/yaml.v2 v2.3.0 h1:clyUAQHOM3G0M3f5vQj7LuJrETvjVot3Z5el9nffUtU= 190 | gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 191 | gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= 192 | gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 193 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 194 | gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo= 195 | gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 196 | -------------------------------------------------------------------------------- /images/dashboard-synchronizer.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NovatecConsulting/grafana-dashboard-synchronizer/1dd794267aceb511e1bfd7d4c635586fd4db74c9/images/dashboard-synchronizer.png -------------------------------------------------------------------------------- /pkg/internal/git_api.go: -------------------------------------------------------------------------------- 1 | package internal 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "io/ioutil" 7 | "time" 8 | 9 | "gopkg.in/src-d/go-git.v4/plumbing" 10 | 11 | "github.com/go-git/go-git/v5/plumbing/object" 12 | log "github.com/sirupsen/logrus" 13 | ssh2 "golang.org/x/crypto/ssh" 14 | "gopkg.in/src-d/go-billy.v4" 15 | "gopkg.in/src-d/go-billy.v4/memfs" 16 | "gopkg.in/src-d/go-git.v4" 17 | object2 "gopkg.in/src-d/go-git.v4/plumbing/object" 18 | "gopkg.in/src-d/go-git.v4/plumbing/transport/ssh" 19 | "gopkg.in/src-d/go-git.v4/storage/memory" 20 | ) 21 | 22 | // GitApi access to git api 23 | type GitApi struct { 24 | gitUrl string 25 | authenticator *ssh.PublicKeys 26 | inMemoryStore memory.Storage 27 | inMemoryFileSystem billy.Filesystem 28 | repository *git.Repository 29 | } 30 | 31 | // NewGitApi creates a new NewGitApi instance 32 | func NewGitApi(gitUrl string, privateKeyFile string) *GitApi { 33 | authenticator, err := createPublicKeys(privateKeyFile) 34 | if err != nil { 35 | log.WithFields(log.Fields{ 36 | "error": err, 37 | "private-key-file": privateKeyFile, 38 | }).Fatal("Failed to load publiy key from the private key.") 39 | } 40 | inMemoryStore, inMemoryFileSystem := createInMemory() 41 | gitApi := GitApi{gitUrl, authenticator, *inMemoryStore, inMemoryFileSystem, nil} 42 | 43 | return &gitApi 44 | } 45 | 46 | // helper function to create the git authenticator 47 | func createPublicKeys(privateKeyFile string) (*ssh.PublicKeys, error) { 48 | if privateKeyFile == "" { 49 | return nil, errors.New("Private key must not be empty.") 50 | } 51 | // git authentication with ssh 52 | authenticator, err := ssh.NewPublicKeysFromFile("git", privateKeyFile, "") 53 | if err != nil { 54 | return nil, err 55 | } 56 | 57 | // TODO delete and set known hosts? 58 | authenticator.HostKeyCallback = ssh2.InsecureIgnoreHostKey() 59 | 60 | return authenticator, err 61 | } 62 | 63 | // helper function to create the in memory storage and filesystem 64 | func createInMemory() (*memory.Storage, billy.Filesystem) { 65 | // prepare in memory 66 | store := memory.NewStorage() 67 | var fs billy.Filesystem 68 | fs = memfs.New() 69 | 70 | return store, fs 71 | } 72 | 73 | // CloneRepo clones the gitApi.gitUrls repository 74 | func (gitApi *GitApi) CloneRepo(branchName string) (*git.Repository, error) { 75 | //todo: refactor this function 76 | 77 | if gitApi.repository != nil { 78 | // only checkout branch if repository has already be cloned 79 | log.WithFields(log.Fields{ 80 | "repository-url": gitApi.gitUrl, 81 | "branch": branchName, 82 | }).Debug("Checkout branch because repository already exists..") 83 | 84 | repo := gitApi.repository 85 | 86 | worktree, _ := repo.Worktree() 87 | 88 | err := worktree.Checkout(&git.CheckoutOptions{ 89 | Branch: plumbing.ReferenceName(fmt.Sprintf("refs/remotes/origin/%s", branchName)), 90 | Force: true, 91 | }) 92 | if err != nil { 93 | return nil, err 94 | } 95 | 96 | return gitApi.repository, nil 97 | } else { 98 | // clone repository into memory 99 | log.WithFields(log.Fields{ 100 | "repository-url": gitApi.gitUrl, 101 | "branch": branchName, 102 | }).Info("Cloning repository..") 103 | 104 | r, err := git.Clone(&gitApi.inMemoryStore, gitApi.inMemoryFileSystem, &git.CloneOptions{ 105 | URL: gitApi.gitUrl, 106 | Auth: gitApi.authenticator, 107 | ReferenceName: plumbing.NewBranchReferenceName(branchName), 108 | SingleBranch: false, 109 | }) 110 | 111 | if err != nil { 112 | return nil, err 113 | } 114 | 115 | gitApi.repository = r 116 | return r, nil 117 | } 118 | } 119 | 120 | // AddFileWithContent add the given filename and content to the in memory filesystem 121 | func (gitApi GitApi) AddFileWithContent(fileName string, fileContent string) { 122 | // add file with content to in memory filesystem 123 | tempFile, err := gitApi.inMemoryFileSystem.Create(fileName) 124 | if err != nil { 125 | log.Fatal("create file error", "error", err) 126 | return 127 | } else { 128 | tempFile.Write([]byte(fileContent)) 129 | tempFile.Close() 130 | } 131 | } 132 | 133 | // CommitWorktree commits all changes in the filesystem 134 | func (gitApi GitApi) CommitWorktree(repository git.Repository, tag string) { 135 | // get worktree and commit 136 | w, err := repository.Worktree() 137 | if err != nil { 138 | log.Fatal("worktree error", "error", err) 139 | return 140 | } else { 141 | w.Add("./") 142 | wStatus, _ := w.Status() 143 | log.Debug("worktree status", "status", wStatus) 144 | 145 | _, err := w.Commit("Synchronized Dashboards with tag <"+tag+">", &git.CommitOptions{ 146 | Author: (*object2.Signature)(&object.Signature{ 147 | Name: "grafana-dashboard-sync-plugin", 148 | When: time.Now(), 149 | }), 150 | }) 151 | if err != nil { 152 | log.Fatal("worktree commit error", "error", err.Error()) 153 | return 154 | } 155 | } 156 | } 157 | 158 | // PushRepo pushes the given repository 159 | func (gitApi GitApi) PushRepo(repository git.Repository) { 160 | // push repo 161 | err := repository.Push(&git.PushOptions{ 162 | RemoteName: "origin", 163 | Auth: gitApi.authenticator, 164 | }) 165 | if err != nil { 166 | log.Fatal("push error", "error", err.Error()) 167 | } 168 | } 169 | 170 | func (gitApi GitApi) GetLatestCommitId(repository git.Repository) (string, error, string) { 171 | // retrieves the branch pointed by HEAD 172 | ref, err := repository.Head() 173 | if err != nil { 174 | return "", err, "Cannot resolve head of repository" 175 | } 176 | 177 | // get the commit object, pointed by ref 178 | commit, err := repository.CommitObject(ref.Hash()) 179 | if err != nil { 180 | return "", err, "Cannot access commit by hash" 181 | } 182 | 183 | return commit.ID().String(), nil, "" 184 | } 185 | 186 | // GetFileContent get the given content of a file from the in memory filesystem 187 | func (gitApi GitApi) GetFileContent() map[string]map[string][]byte { 188 | // read current in memory filesystem to get dirs 189 | filesOrDirs, err := gitApi.inMemoryFileSystem.ReadDir("./") 190 | if err != nil { 191 | log.Fatal("inMemoryFileSystem error", "error", err) 192 | return nil 193 | } 194 | 195 | var dirMap []string 196 | 197 | for _, fileOrDir := range filesOrDirs { 198 | if fileOrDir.IsDir() { 199 | dirName := fileOrDir.Name() 200 | dirMap = append(dirMap, dirName) 201 | } 202 | } 203 | 204 | fileMap := make(map[string]map[string][]byte) 205 | 206 | for _, dir := range dirMap { 207 | // prepare fileMap for dir 208 | fileMap[dir] = make(map[string][]byte) 209 | 210 | // read current in memory filesystem to get files 211 | files, err := gitApi.inMemoryFileSystem.ReadDir("./" + dir + "/") 212 | if err != nil { 213 | log.Fatal("inMemoryFileSystem ReadDir error", "error", err) 214 | return nil 215 | } 216 | 217 | for _, file := range files { 218 | 219 | log.Debug("file", "name", file.Name()) 220 | 221 | if file.IsDir() { 222 | continue 223 | } 224 | 225 | src, err := gitApi.inMemoryFileSystem.Open("./" + dir + "/" + file.Name()) 226 | 227 | if err != nil { 228 | log.Fatal("inMemoryFileSystem Open error", "error", err) 229 | return nil 230 | } 231 | byteFile, err := ioutil.ReadAll(src) 232 | if err != nil { 233 | log.Fatal("read error", "error", err) 234 | } else { 235 | fileMap[dir][file.Name()] = byteFile 236 | src.Close() 237 | } 238 | } 239 | } 240 | return fileMap 241 | } 242 | -------------------------------------------------------------------------------- /pkg/internal/grafana_api.go: -------------------------------------------------------------------------------- 1 | package internal 2 | 3 | import ( 4 | "context" 5 | "strings" 6 | 7 | sdk "github.com/NovatecConsulting/grafana-api-go-sdk" 8 | log "github.com/sirupsen/logrus" 9 | ) 10 | 11 | // GrafanaApi access to grafana api 12 | type GrafanaApi struct { 13 | grafanaClient *sdk.Client 14 | } 15 | 16 | type DashboardWithCustomFields struct { 17 | sdk.Board 18 | SyncOrigin string `json:"syncOrigin"` 19 | } 20 | 21 | // NewGrafanaApi creates a new GrafanaApi instance 22 | func NewGrafanaApi(grafanaURL string, apiToken string) *GrafanaApi { 23 | client, _ := sdk.NewClient(grafanaURL, apiToken, sdk.DefaultHTTPClient) 24 | grafanaApi := GrafanaApi{client} 25 | return &grafanaApi 26 | } 27 | 28 | // SearchDashboardsWithTag returns all dashboards with the given tag 29 | func (grafanaApi GrafanaApi) SearchDashboardsWithTag(tag string) ([]sdk.FoundBoard, error) { 30 | searchParam := sdk.SearchTag(tag) 31 | foundDashboards, err := grafanaApi.grafanaClient.Search(context.Background(), searchParam) 32 | return foundDashboards, err 33 | } 34 | 35 | // GetDashboardObjectByUID return Dashboard by the given UID as object 36 | func (grafanaApi GrafanaApi) GetDashboardObjectByUID(uid string) (sdk.Board, sdk.BoardProperties) { 37 | dashboardObject, dashboardProperties, err := grafanaApi.grafanaClient.GetDashboardByUID(context.Background(), uid) 38 | if err != nil { 39 | dashboardNotFound := strings.Contains(err.Error(), "Dashboard not found") 40 | if !dashboardNotFound { 41 | log.Fatal("get dashboard object error", "error", err.Error()) 42 | } 43 | return sdk.Board{}, sdk.BoardProperties{} 44 | } 45 | return dashboardObject, dashboardProperties 46 | } 47 | 48 | // CreateOrUpdateDashboardObjectByID create or update the Dashboard with the given dashboard object 49 | func (grafanaApi GrafanaApi) CreateOrUpdateDashboardObjectByID(rawDashboard []byte, folderId int, message string) sdk.StatusMessage { 50 | statusMessage, err := grafanaApi.grafanaClient.SetRawDashboardWithParam(context.Background(), sdk.RawBoardRequest{ 51 | Dashboard: rawDashboard, 52 | Parameters: sdk.SetDashboardParams{ 53 | Overwrite: true, 54 | FolderID: folderId, 55 | Message: message, 56 | }, 57 | }) 58 | if err != nil { 59 | log.Fatal("set dashboard error", "error", err.Error()) 60 | } 61 | return statusMessage 62 | } 63 | 64 | // CreateFolder create a folder in Grafana 65 | func (grafanaApi GrafanaApi) CreateFolder(folderName string) (*sdk.Folder, error) { 66 | folder := sdk.Folder{Title: folderName} 67 | folder, err := grafanaApi.grafanaClient.CreateFolder(context.Background(), folder) 68 | if err != nil { 69 | return &folder, err 70 | } 71 | return &folder, nil 72 | } 73 | 74 | // Returns the ID of a given folder. 75 | func (grafanaApi GrafanaApi) GetFolder(folderName string) (*sdk.Folder, error) { 76 | if folderName == "General" { 77 | // see the folling site for more details on this: https://grafana.com/docs/grafana/latest/http_api/folder/#a-note-about-the-general-folder 78 | return &sdk.Folder{ 79 | ID: 0, 80 | }, nil 81 | } 82 | 83 | folders, err := grafanaApi.grafanaClient.GetAllFolders(context.Background()) 84 | if err != nil { 85 | return nil, err 86 | } 87 | for _, folder := range folders { 88 | if folder.Title == folderName { 89 | return &folder, nil 90 | } 91 | } 92 | return nil, nil 93 | } 94 | 95 | // DeleteTagFromDashboardObjectByID delete the given tag from the Dashboard object 96 | func (grafanaApi GrafanaApi) DeleteTagFromDashboardObjectByID(dashboard sdk.Board, tag string) sdk.Board { 97 | for i, iTag := range dashboard.Tags { 98 | if iTag == tag { 99 | dashboard.Tags = append(dashboard.Tags[:i], dashboard.Tags[i+1:]...) 100 | break 101 | } 102 | } 103 | return dashboard 104 | } 105 | -------------------------------------------------------------------------------- /pkg/internal/synchronizer.go: -------------------------------------------------------------------------------- 1 | package internal 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "reflect" 7 | "regexp" 8 | "strconv" 9 | 10 | sdk "github.com/NovatecConsulting/grafana-api-go-sdk" 11 | log "github.com/sirupsen/logrus" 12 | ) 13 | 14 | type SynchronizeOptions struct { 15 | JobName string `yaml:"job-name"` 16 | GrafanaToken string `yaml:"grafana-token"` 17 | GrafanaUrl string `yaml:"grafana-url"` 18 | 19 | GitRepositoryUrl string `yaml:"git-repository-url"` 20 | PrivateKeyFile string `yaml:"private-key-file"` 21 | 22 | PushConfiguration PushConfiguration `yaml:"push-configuration"` 23 | PullConfiguration PullConfiguration `yaml:"pull-configuration"` 24 | } 25 | 26 | type PullConfiguration struct { 27 | Enable bool `yaml:"enable"` 28 | GitBranch string `yaml:"git-branch"` 29 | Filter string `yaml:"filter"` 30 | } 31 | 32 | type PushConfiguration struct { 33 | PullConfiguration `yaml:",inline"` 34 | TagPattern string `yaml:"tag-pattern"` 35 | PushTags bool `yaml:"push-tags"` 36 | } 37 | 38 | // Creates a new Synchronizer instance. 39 | func NewSynchronizer(options SynchronizeOptions) *Synchronization { 40 | synchronization := Synchronization{ 41 | options: options, 42 | } 43 | 44 | log.WithFields(log.Fields{ 45 | "job": options.JobName, 46 | "repository-url": options.GitRepositoryUrl, 47 | "private-key-file": options.PrivateKeyFile, 48 | "grafana-url": options.GrafanaUrl, 49 | }).Info("Initialize synchronizer job.") 50 | 51 | synchronization.grafanaApi = NewGrafanaApi(options.GrafanaUrl, options.GrafanaToken) 52 | synchronization.gitApi = NewGitApi(options.GitRepositoryUrl, options.PrivateKeyFile) 53 | 54 | return &synchronization 55 | } 56 | 57 | type Synchronization struct { 58 | options SynchronizeOptions 59 | grafanaApi *GrafanaApi 60 | gitApi *GitApi 61 | } 62 | 63 | // Executes the synchronization using the configuration stored in this struct. 64 | func (s *Synchronization) Synchronize(dryRun bool) error { 65 | log.WithFields(log.Fields{ 66 | "job": s.options.JobName, 67 | "dry-run": strconv.FormatBool(dryRun), 68 | }).Info("Starting synchronization.") 69 | 70 | // push dashboard into Git 71 | if s.options.PushConfiguration.Enable { 72 | err := s.pushDashboards(dryRun) 73 | if err != nil { 74 | return err 75 | } 76 | } 77 | 78 | // Pull Dashboards from Git 79 | if s.options.PullConfiguration.Enable { 80 | err := s.pullDashboards(dryRun) 81 | if err != nil { 82 | return err 83 | } 84 | } 85 | 86 | log.WithFields(log.Fields{ 87 | "job": s.options.JobName, 88 | }).Info("Job was successfully completed.") 89 | 90 | return nil 91 | } 92 | 93 | // Pushs dashboards from the configured Grafana into Git. 94 | func (s *Synchronization) pushDashboards(dryRun bool) error { 95 | configuration := s.options.PushConfiguration 96 | 97 | log.WithFields(log.Fields{ 98 | "job": s.options.JobName, 99 | "target-branch": configuration.GitBranch, 100 | "filter": configuration.Filter, 101 | "tag-pattern": configuration.TagPattern, 102 | "push-tags": configuration.PushTags, 103 | }).Info("Starting dashboard synchroization (export) into the Git repository.") 104 | 105 | // initializing the dashboard filter 106 | var regexFilter *regexp.Regexp 107 | var err error 108 | if configuration.Filter != "" { 109 | regexFilter, err = regexp.Compile(configuration.Filter) 110 | if err != nil { 111 | log.WithFields(log.Fields{ 112 | "error": err, 113 | "job": s.options.JobName, 114 | "filter": configuration.Filter, 115 | }).Fatal("Invalid filter pattern for the push configuration. Skipping exportation of dashboard.") 116 | return err 117 | } 118 | } 119 | 120 | dashboardTag := configuration.TagPattern 121 | 122 | resultBoards, err := s.grafanaApi.SearchDashboardsWithTag(dashboardTag) 123 | 124 | if err != nil { 125 | log.WithField("error", err).Fatal("Failed fetching dashboards from Grafana.") 126 | } 127 | 128 | if len(resultBoards) > 0 { 129 | log.WithField("amount", len(resultBoards)).Info("Successfully fetched dashboards.") 130 | 131 | // clone repo from specific branch 132 | repository, err := s.gitApi.CloneRepo(configuration.GitBranch) 133 | if err != nil { 134 | log.WithField("error", err).Fatal("Error while cloning repository.") 135 | return err 136 | } 137 | 138 | for _, board := range resultBoards { 139 | // get dashboard Object and Properties 140 | dashboard, boardProperties := s.grafanaApi.GetDashboardObjectByUID(board.UID) 141 | 142 | // synchronize only dashboards matching the filter 143 | if regexFilter != nil { 144 | folderAndTitle := boardProperties.FolderTitle + "/" + dashboard.Title 145 | if regexFilter.FindStringIndex(folderAndTitle) == nil { 146 | log.WithFields(log.Fields{ 147 | "dashboard-path": folderAndTitle, 148 | "filter": configuration.Filter, 149 | }).Info("Skipping export because dashboard does not match the specified filter pattern.") 150 | continue 151 | } 152 | } 153 | 154 | // delete Tag from dashboard Object 155 | var dashboardWithDeletedTag sdk.Board 156 | if configuration.PushTags { 157 | dashboardWithDeletedTag = dashboard 158 | } else { 159 | dashboardWithDeletedTag = s.grafanaApi.DeleteTagFromDashboardObjectByID(dashboard, dashboardTag) 160 | } 161 | 162 | // get folder name and id, required for update processes and git folder structure 163 | folderId := boardProperties.FolderID 164 | 165 | // get raw Json Dashboard, required for import and export 166 | dashboardJson, err := json.Marshal(DashboardWithCustomFields{dashboardWithDeletedTag, s.options.JobName}) 167 | if err != nil { 168 | log.WithField("error", err).Fatal("Error while parsing dashboard JSON.") 169 | } 170 | 171 | // update dashboard with deleted Tag in Grafana 172 | log.WithField("dashboard", dashboard.Title).Info("Removing sync tag from dashboard.") 173 | if !dryRun { 174 | s.grafanaApi.CreateOrUpdateDashboardObjectByID(dashboardJson, folderId, fmt.Sprintf("Deleted '%s' tag", dashboardTag)) 175 | } 176 | log.Debug("Dashboard preparation successfully") 177 | 178 | // Add Dashboard to in memory file system 179 | log.WithField("dashboard", dashboard.Title).Info("Adding dashboard for synchronization.") 180 | s.gitApi.AddFileWithContent(boardProperties.FolderTitle+"/"+dashboard.Title+".json", string(dashboardJson)) 181 | } 182 | 183 | log.Info("Pushing dashboards to the remote Git repository.") 184 | if !dryRun { 185 | s.gitApi.CommitWorktree(*repository, dashboardTag) 186 | s.gitApi.PushRepo(*repository) 187 | } 188 | 189 | log.Info("Successfully pushed dashboards to the remote Git repository.") 190 | } else { 191 | log.WithField("tag-pattern", configuration.TagPattern).Info("No dashboards found using the configured tag pattern.") 192 | } 193 | 194 | return nil 195 | } 196 | 197 | // Pulling dashboards from the configured Git and importing them into Grafana. 198 | func (s *Synchronization) pullDashboards(dryRun bool) error { 199 | configuration := s.options.PullConfiguration 200 | 201 | log.WithFields(log.Fields{ 202 | "job": s.options.JobName, 203 | "target-branch": configuration.GitBranch, 204 | "filter": configuration.Filter, 205 | }).Info("Starting dashboard synchroization (import) from the Git repository.") 206 | 207 | // initializing the dashboard filter 208 | var regexFilter *regexp.Regexp 209 | var err error 210 | if configuration.Filter != "" { 211 | regexFilter, err = regexp.Compile(configuration.Filter) 212 | if err != nil { 213 | log.WithFields(log.Fields{ 214 | "error": err, 215 | "job": s.options.JobName, 216 | "filter": configuration.Filter, 217 | }).Fatal("Invalid filter pattern for the pull configuration. Skipping importation of dashboard.") 218 | return err 219 | } 220 | } 221 | 222 | // clone and fetch the configured repository 223 | repository, err := s.gitApi.CloneRepo(configuration.GitBranch) 224 | if err != nil { 225 | log.WithField("error", err).Fatal("Error while cloning repository.") 226 | return err 227 | } 228 | 229 | commitId, err, _ := s.gitApi.GetLatestCommitId(*repository) 230 | if err != nil { 231 | return err 232 | } 233 | 234 | // stats counter 235 | countImport := 0 236 | countUpToDate := 0 237 | 238 | // get files from Git repository 239 | fileMap := s.gitApi.GetFileContent() 240 | 241 | // for each folder 242 | for folderName, dashboardFiles := range fileMap { 243 | // get Grafana folder or create it if it doesn't exist 244 | folder, err := s.grafanaApi.GetFolder(folderName) 245 | if err != nil { 246 | log.WithFields(log.Fields{ 247 | "folder": folderName, 248 | "error": err, 249 | }).Fatal("Could not fetch Grafana folder.") 250 | continue 251 | } else if folder == nil { 252 | log.WithField("folder", folderName).Info("Creating Grafana folder.") 253 | if !dryRun { 254 | folder, err = s.grafanaApi.CreateFolder(folderName) 255 | if err != nil { 256 | log.WithFields(log.Fields{ 257 | "folder": folderName, 258 | "error": err, 259 | }).Fatal("Could not create Grafana folder.") 260 | continue 261 | } 262 | } 263 | } 264 | 265 | // for each dashboard within folder 266 | for _, dashboardJson := range dashboardFiles { 267 | // get dashboards from Git and Grafana for comparison 268 | dashboard := DashboardWithCustomFields{} 269 | err := json.Unmarshal(dashboardJson, &dashboard) 270 | if err != nil { 271 | log.WithFields(log.Fields{ 272 | "dashboard": dashboard.Title, 273 | "error": err, 274 | }).Fatal("Failed to unmarshal dashboard.") 275 | } 276 | 277 | // synchronize only dashboards matching the filter 278 | if regexFilter != nil { 279 | folderAndTitle := folderName + "/" + dashboard.Title 280 | if regexFilter.FindStringIndex(folderAndTitle) == nil { 281 | log.WithFields(log.Fields{ 282 | "dashboard-path": folderAndTitle, 283 | "filter": configuration.Filter, 284 | }).Info("Skipping import because dashboard does not match the specified filter pattern.") 285 | continue 286 | } 287 | } 288 | 289 | //gitDashboardExtended := getDashboardObjectFromRawDashboard(gitRawDashboard) 290 | grafanaDashboard, _ := s.grafanaApi.GetDashboardObjectByUID(dashboard.UID) 291 | 292 | // extract the custom tags from the dashboard model 293 | syncOrigin := dashboard.SyncOrigin 294 | 295 | // we need to explicitly set certain attributes for comparision 296 | // --- 297 | // 'Version' and 'Dashboard ID' need to be set equal, as they are different because of import mechanisms 298 | grafanaDashboard.Version = dashboard.Version 299 | grafanaDashboard.ID = dashboard.ID 300 | // 'SyncOrigin' need to be set, because custom fields are lost through the import 301 | grafanaDashboardExtended := DashboardWithCustomFields{grafanaDashboard, dashboard.SyncOrigin} 302 | 303 | // import dashboard if it differs from the current one 304 | if !reflect.DeepEqual(grafanaDashboardExtended, dashboard) { 305 | versionMessage := fmt.Sprintf("[SYNC] Synchronized dashboard. Version '%s' from origin '%s' (commit %s).", strconv.Itoa(int(grafanaDashboardExtended.Version)), syncOrigin, commitId) 306 | 307 | log.WithFields(log.Fields{ 308 | "dashboard": dashboard.Title, 309 | "folder": folderName, 310 | }).Info("Importing dashboard into Grafana.") 311 | if !dryRun { 312 | s.grafanaApi.CreateOrUpdateDashboardObjectByID(dashboardJson, folder.ID, versionMessage) 313 | } 314 | 315 | countImport++ 316 | } else { 317 | log.WithField("dashboard", dashboard.Title).Info("Dashboard ignored because it is already up-to-date.") 318 | countUpToDate++ 319 | } 320 | } 321 | } 322 | 323 | log.WithFields(log.Fields{ 324 | "imported": countImport, 325 | "up-to-date": countUpToDate, 326 | }).Info("Successfully synchronized dashboards from Git repositroy") 327 | 328 | return nil 329 | } 330 | -------------------------------------------------------------------------------- /pkg/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "io/ioutil" 6 | "os" 7 | 8 | "github.com/NovatecConsulting/grafana-dashboard-sync/pkg/internal" 9 | log "github.com/sirupsen/logrus" 10 | "github.com/urfave/cli/v2" 11 | "gopkg.in/yaml.v3" 12 | ) 13 | 14 | func main() { 15 | app := &cli.App{ 16 | Flags: []cli.Flag{ 17 | &cli.StringFlag{ 18 | Name: "config", 19 | Aliases: []string{"c"}, 20 | Value: "configuration.yml", 21 | Usage: "the configuration file to use", 22 | }, 23 | &cli.BoolFlag{ 24 | Name: "dry-run", 25 | Usage: "performs a dry run without actually importing or exporting dashboards", 26 | }, 27 | &cli.BoolFlag{ 28 | Name: "log-as-json", 29 | Usage: "printing logs as structured json objects", 30 | }, 31 | }, 32 | Action: func(c *cli.Context) error { 33 | return synchronizeDashboards(c) 34 | }, 35 | } 36 | 37 | err := app.Run(os.Args) 38 | if err != nil { 39 | log.Fatal(err) 40 | } 41 | } 42 | 43 | // Starts the synchronization of the Grafana dashboards. 44 | func synchronizeDashboards(c *cli.Context) error { 45 | // setup logger 46 | if c.Bool("log-as-json") { 47 | log.SetFormatter(&log.JSONFormatter{}) 48 | } else { 49 | log.SetFormatter(&log.TextFormatter{ForceColors: true}) 50 | } 51 | 52 | log.Info("Synchronizing Grafana dashboards...") 53 | 54 | if c.Bool("dry-run") { 55 | log.Info("DRY-RUN : The application will NOT perform any changes to Git or Grafana due to the fry-run flag!") 56 | } 57 | 58 | // read configuration 59 | input, err := readConf(c.String("config")) 60 | if err != nil { 61 | log.WithField("error", err).Fatal("Error while reading configuration file.") 62 | return err 63 | } 64 | 65 | // do the synchronization 66 | for _, element := range *input { 67 | synchronizer := internal.NewSynchronizer(element) 68 | synchronizer.Synchronize(c.Bool("dry-run")) 69 | } 70 | 71 | log.Info("Synchronization completed.") 72 | return nil 73 | } 74 | 75 | // Reads the given file and parses it into a struct representing the configuration to use. 76 | func readConf(filename string) (*[]internal.SynchronizeOptions, error) { 77 | log.WithField("file", filename).Info("Reading configuration file...") 78 | 79 | buf, err := ioutil.ReadFile(filename) 80 | if err != nil { 81 | return nil, err 82 | } 83 | 84 | c := &[]internal.SynchronizeOptions{} 85 | err = yaml.Unmarshal(buf, c) 86 | if err != nil { 87 | return nil, fmt.Errorf("in file %q: %v", filename, err) 88 | } 89 | 90 | return c, nil 91 | } 92 | --------------------------------------------------------------------------------