├── .github ├── FUNDING.yml ├── dependabot.yml └── workflows │ ├── build.yml │ ├── goreleaser.yml │ ├── lint.yml │ └── publish_docker.yml ├── .gitignore ├── .goreleaser.yml ├── Dockerfile ├── LICENSE ├── README.MD ├── cmd └── root.go ├── go.mod ├── go.sum ├── grafana ├── common.go ├── dashboard.go ├── datasource.go ├── folder.go ├── http.go └── notification.go └── main.go /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | patreon: mpostument -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "gomod" 4 | directory: "/" 5 | schedule: 6 | interval: "weekly" 7 | assignees: 8 | - "mpostument" 9 | 10 | - package-ecosystem: "github-actions" 11 | directory: "/" 12 | schedule: 13 | interval: "weekly" 14 | assignees: 15 | - "mpostument" -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Go Build 2 | 3 | on: 4 | push: 5 | branches: [ master, main ] 6 | pull_request: 7 | branches: [ master, main ] 8 | 9 | jobs: 10 | 11 | build: 12 | name: Build 13 | runs-on: ubuntu-latest 14 | steps: 15 | 16 | - name: Setup Go environment 17 | uses: actions/setup-go@v4 18 | with: 19 | go-version: 1.20.0 20 | 21 | - name: Check out code into the Go module directory 22 | uses: actions/checkout@v4 23 | 24 | - name: Get dependencies 25 | run: | 26 | go get -v -t -d ./... 27 | - name: Build 28 | run: go build -v . 29 | 30 | - name: Test 31 | run: go test -v ./... -------------------------------------------------------------------------------- /.github/workflows/goreleaser.yml: -------------------------------------------------------------------------------- 1 | name: goreleaser 2 | 3 | on: 4 | push: 5 | tags: 6 | - '*' 7 | 8 | jobs: 9 | goreleaser: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - 13 | name: Checkout 14 | uses: actions/checkout@v4 15 | with: 16 | fetch-depth: 0 17 | - 18 | name: Set up Go 19 | uses: actions/setup-go@v4 20 | with: 21 | go-version: 1.20.0 22 | - 23 | name: Run GoReleaser 24 | uses: goreleaser/goreleaser-action@v5.0.0 25 | with: 26 | version: latest 27 | args: release --rm-dist 28 | env: 29 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: golangci-lint 2 | on: 3 | push: 4 | tags: 5 | - '*' 6 | branches: 7 | - master 8 | - main 9 | pull_request: 10 | jobs: 11 | golangci: 12 | name: lint 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v4 16 | - name: golangci-lint 17 | uses: golangci/golangci-lint-action@v3.7.0 18 | with: 19 | # Required: the version of golangci-lint is required and must be specified without patch version: we always use the latest patch version. 20 | version: latest -------------------------------------------------------------------------------- /.github/workflows/publish_docker.yml: -------------------------------------------------------------------------------- 1 | name: Docker Publish 2 | 3 | on: 4 | push: 5 | tags: 6 | - "*" 7 | 8 | env: 9 | REGISTRY: ghcr.io 10 | IMAGE_NAME: ${{ github.repository }} 11 | 12 | jobs: 13 | build-and-push-image: 14 | runs-on: ubuntu-latest 15 | permissions: 16 | contents: read 17 | packages: write 18 | 19 | steps: 20 | - name: Checkout repository 21 | uses: actions/checkout@v4 22 | 23 | - name: Log in to the Container registry 24 | uses: docker/login-action@465a07811f14bebb1938fbed4728c6a1ff8901fc 25 | with: 26 | registry: ${{ env.REGISTRY }} 27 | username: ${{ github.actor }} 28 | password: ${{ secrets.GITHUB_TOKEN }} 29 | 30 | - name: Extract metadata (tags, labels) for Docker 31 | id: meta 32 | uses: docker/metadata-action@v5.0.0 33 | with: 34 | images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} 35 | 36 | - name: Build and push Docker image 37 | uses: docker/build-push-action@v5.0.0 38 | with: 39 | context: . 40 | push: true 41 | tags: ${{ steps.meta.outputs.tags }} 42 | labels: ${{ steps.meta.outputs.labels }} 43 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.json 2 | vendor 3 | .idea/ 4 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | before: 2 | hooks: 3 | - go mod download 4 | builds: 5 | - env: 6 | - CGO_ENABLED=0 7 | goos: 8 | - linux 9 | - darwin 10 | - windows 11 | goarch: 12 | - 386 13 | - arm 14 | - amd64 15 | - arm64 16 | goarm: 17 | - 5 18 | - 6 19 | - 7 20 | archives: 21 | - replacements: 22 | amd64: 64bit 23 | 386: 32bit 24 | arm: ARM 25 | arm64: ARM64 26 | darwin: macOS 27 | linux: Linux 28 | files: 29 | - README.MD 30 | - LICENSE 31 | checksum: 32 | name_template: "checksums.txt" 33 | snapshot: 34 | name_template: "{{ .Tag }}-next" 35 | changelog: 36 | sort: asc 37 | filters: 38 | exclude: 39 | - "^docs:" 40 | - "^test:" 41 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Build App 2 | FROM golang:1.20.0-alpine3.17 AS builder 3 | 4 | WORKDIR ${GOPATH}/src/github.com/mpostument/grafana-sync 5 | COPY . ${GOPATH}/src/github.com/mpostument/grafana-sync 6 | 7 | RUN go build -o /go/bin/grafana-sync . 8 | 9 | 10 | # Create small image with binary 11 | FROM alpine:3.17 12 | 13 | RUN apk --no-cache add ca-certificates 14 | 15 | COPY --from=builder /go/bin/grafana-sync /usr/bin/grafana-sync 16 | 17 | ENTRYPOINT ["/usr/bin/grafana-sync"] 18 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright 2020 Maksym Postument 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | -------------------------------------------------------------------------------- /README.MD: -------------------------------------------------------------------------------- 1 | # grafana-sync 2 | 3 | Keep your grafana dashboards in sync. 4 | 5 | ## Table of Contents 6 | 7 | - [grafana-sync](#grafana-sync) 8 | - [Table of Contents](#table-of-contents) 9 | - [Installing](#installing) 10 | - [Getting Started](#getting-started) 11 | - [Pull dashboards](#pull-dashboards) 12 | - [Pull folder](#pull-folder) 13 | - [Pull notifications](#pull-notifications) 14 | - [Pull datasources](#pull-datasources) 15 | - [Push dashboards](#push-dashboards) 16 | - [Push folders](#push-folders) 17 | - [Push notifications](#push-notifications) 18 | - [Push datasources](#push-datasources) 19 | - [Global parameters](#global-parameters) 20 | - [Contributing](#contributing) 21 | - [License](#license) 22 | 23 | ## Installing 24 | 25 | Download the latest binary from [releases](https://github.com/mpostument/grafana-sync/releases) 26 | Or use docker image from [github registry](https://github.com/mpostument/grafana-sync/pkgs/container/grafana-sync) 27 | 28 | ## Getting Started 29 | 30 | ### Pull dashboards 31 | 32 | ```shell 33 | # Save all dashboards to directory 34 | grafana-sync pull-dashboards --apikey="eyJrIjoiOWJYTktGNFlCbFVMOG1LY3d6ekN4Mmw4MFgyYU44a1UiLCJuIjoiY29icmEiLCJpZCI6MX0=" --directory="dashboards" --url http://127.0.0.1:3000 35 | 36 | # Save all dashboards from specific folder to local directory using folder name 37 | grafana-sync pull-dashboards --apikey="eyJrIjoiOWJYTktGNFlCbFVMOG1LY3d6ekN4Mmw4MFgyYU44a1UiLCJuIjoiY29icmEiLCJpZCI6MX0=" --directory="dashboards" --url http://127.0.0.1:3000 --folderName="TestFolder" 38 | 39 | # Save all dashboards from specific folder to local directory using folder id 40 | grafana-sync pull-dashboards --apikey="eyJrIjoiOWJYTktGNFlCbFVMOG1LY3d6ekN4Mmw4MFgyYU44a1UiLCJuIjoiY29icmEiLCJpZCI6MX0=" --directory="dashboards" --url http://127.0.0.1:3000 --folderId=1 41 | 42 | # Save dashboards with specific tags to directory 43 | grafana-sync pull-dashboards --apikey="eyJrIjoiOWJYTktGNFlCbFVMOG1LY3d6ekN4Mmw4MFgyYU44a1UiLCJuIjoiY29icmEiLCJpZCI6MX0=" --directory="dashboards" --url http://127.0.0.1:3000 --tag=export 44 | ``` 45 | 46 | ### Pull folder 47 | 48 | ```shell 49 | grafana-sync pull-folders --apikey="eyJrIjoiOWJYTktGNFlCbFVMOG1LY3d6ekN4Mmw4MFgyYU44a1UiLCJuIjoiY29icmEiLCJpZCI6MX0=" --directory="folders" --url http://127.0.0.1:3000 50 | ``` 51 | 52 | ### Pull notifications 53 | 54 | ```shell 55 | grafana-sync pull-notifications --apikey="eyJrIjoiOWJYTktGNFlCbFVMOG1LY3d6ekN4Mmw4MFgyYU44a1UiLCJuIjoiY29icmEiLCJpZCI6MX0=" --directory="notifications" --url http://127.0.0.1:3000 56 | ``` 57 | 58 | ### Pull datasources 59 | 60 | ```shell 61 | grafana-sync pull-datasources --apikey="eyJrIjoiOWJYTktGNFlCbFVMOG1LY3d6ekN4Mmw4MFgyYU44a1UiLCJuIjoiY29icmEiLCJpZCI6MX0=" --directory="datasources" --url http://127.0.0.1:3000 62 | ``` 63 | 64 | ### Push dashboards 65 | 66 | ```shell 67 | # Push all dashboards to general directory 68 | grafana-sync push-dashboards --apikey="eyJrIjoiOWJYTktGNFlCbFVMOG1LY3d6ekN4Mmw4MFgyYU44a1UiLCJuIjoiY29icmEiLCJpZCI6MX0=" --directory="dashboards" --url http://127.0.0.1:3000 69 | 70 | # Push dashboards to grafana in custom folder by folder name 71 | grafana-sync push-dashboards --apikey="eyJrIjoiOWJYTktGNFlCbFVMOG1LY3d6ekN4Mmw4MFgyYU44a1UiLCJuIjoiY29icmEiLCJpZCI6MX0=" --directory="dashboards" --url http://127.0.0.1:3000 --folderName="TestFolder" 72 | 73 | # Push folders to grafana in custom folder by folder id 74 | grafana-sync push-folders --apikey="eyJrIjoiOWJYTktGNFlCbFVMOG1LY3d6ekN4Mmw4MFgyYU44a1UiLCJuIjoiY29icmEiLCJpZCI6MX0=" --directory="dashboards" --url http://127.0.0.1:3000 --folderId=1 75 | ``` 76 | 77 | ### Push folders 78 | 79 | ```shell 80 | grafana-sync push-folders --apikey="eyJrIjoiOWJYTktGNFlCbFVMOG1LY3d6ekN4Mmw4MFgyYU44a1UiLCJuIjoiY29icmEiLCJpZCI6MX0=" --directory="folders" --url http://127.0.0.1:3000 81 | ``` 82 | 83 | ### Push notifications 84 | 85 | ```shell 86 | grafana-sync push-notifications --apikey="eyJrIjoiOWJYTktGNFlCbFVMOG1LY3d6ekN4Mmw4MFgyYU44a1UiLCJuIjoiY29icmEiLCJpZCI6MX0=" --directory="notifications" --url http://127.0.0.1:3000 87 | ``` 88 | 89 | ### Push datasources 90 | 91 | ```shell 92 | grafana-sync push-datasources --apikey="eyJrIjoiOWJYTktGNFlCbFVMOG1LY3d6ekN4Mmw4MFgyYU44a1UiLCJuIjoiY29icmEiLCJpZCI6MX0=" --directory="datasources" --url http://127.0.0.1:3000 93 | ``` 94 | 95 | ## Global parameters 96 | 97 | `directory` - Directory where to save dashboards. Default `.` 98 | `tag` - Dashboard tag to read. Supported only with `pull` option. Default `""` 99 | `apikey` - Grafana api key, need to be editor or admin. Default `""`. 100 | Api key can be stored in `$HOME/.grafana-sync.yaml` as `apikey: ` 101 | `url` - Grafana Url with port. Default `http://localhost:3000` 102 | `customHeaders` - Key-value pairs of custom http headers (header1=value1,header2=value2) 103 | 104 | ## Contributing 105 | 106 | 1. Fork it 107 | 2. Download your fork to your PC ( `git clone https://github.com/your_username/grafana-sync && cd grafana-sync` ) 108 | 3. Create your feature branch ( `git checkout -b my-new-feature` ) 109 | 4. Make changes and add them ( `git add .` ) 110 | 5. Commit your changes ( `git commit -m 'Add some feature'` ) 111 | 6. Push to the branch ( `git push origin my-new-feature` ) 112 | 7. Create new pull request 113 | 114 | ## License 115 | 116 | grafana-sync is released under the Apache 2.0 license. See [LICENSE.txt](https://github.com/mpostument/grafana-sync/blob/master/LICENSE) 117 | -------------------------------------------------------------------------------- /cmd/root.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright © 2020 Maksym Postument 777rip777@gmail.com 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | package cmd 17 | 18 | import ( 19 | "fmt" 20 | "log" 21 | "os" 22 | 23 | "github.com/spf13/cobra" 24 | 25 | "github.com/mpostument/grafana-sync/grafana" 26 | 27 | homedir "github.com/mitchellh/go-homedir" 28 | "github.com/spf13/viper" 29 | ) 30 | 31 | var cfgFile string 32 | var customHeaders map[string]string 33 | 34 | var rootCmd = &cobra.Command{ 35 | Use: "grafana-sync", 36 | Short: "Root command for grafana interaction", 37 | Long: `Root command for grafana interaction.`, 38 | Version: "1.5.0", 39 | } 40 | 41 | var pullDashboardsCmd = &cobra.Command{ 42 | Use: "pull-dashboards", 43 | Short: "Pull grafana dashboards in to the directory", 44 | Long: `Save to the directory grafana dashboards. 45 | Directory name specified by flag --directory. If flag --tag is used, 46 | only dashboards with given tag are pulled`, 47 | Run: func(cmd *cobra.Command, args []string) { 48 | var ( 49 | folderId int 50 | err error 51 | ) 52 | url, _ := cmd.Flags().GetString("url") 53 | apiKey := viper.GetString("apikey") 54 | directory, _ := cmd.Flags().GetString("directory") 55 | tag, _ := cmd.Flags().GetString("tag") 56 | folderName, _ := cmd.Flags().GetString("folderName") 57 | 58 | if folderName != "" { 59 | folderId, err = grafana.FindFolderId(url, apiKey, folderName) 60 | if err != nil { 61 | log.Fatalln(err) 62 | } 63 | } else { 64 | folderId, _ = cmd.Flags().GetInt("folderId") 65 | } 66 | 67 | if err := grafana.PullDashboard(url, apiKey, directory, tag, folderId); err != nil { 68 | log.Fatalln("Pull dashboards command failed", err) 69 | } 70 | if grafana.ExecutionErrorHappened { 71 | os.Exit(1) 72 | } 73 | }, 74 | } 75 | 76 | var pushDashboardsCmd = &cobra.Command{ 77 | Use: "push-dashboards", 78 | Short: "Push grafana dashboards from directory", 79 | Long: `Read json with dashboards description and publish to grafana.`, 80 | Run: func(cmd *cobra.Command, args []string) { 81 | var ( 82 | folderId int 83 | err error 84 | ) 85 | url, _ := cmd.Flags().GetString("url") 86 | apiKey, _ := cmd.Flags().GetString("apikey") 87 | directory, _ := cmd.Flags().GetString("directory") 88 | folderName, _ := cmd.Flags().GetString("folderName") 89 | 90 | if folderName != "" { 91 | folderId, err = grafana.FindFolderId(url, apiKey, folderName) 92 | if err != nil { 93 | log.Fatalln(err) 94 | } 95 | } else { 96 | folderId, _ = cmd.Flags().GetInt("folderId") 97 | } 98 | 99 | if err := grafana.PushDashboard(url, apiKey, directory, folderId); err != nil { 100 | log.Fatalln("Push dashboards command failed", err) 101 | } 102 | if grafana.ExecutionErrorHappened { 103 | os.Exit(1) 104 | } 105 | }, 106 | } 107 | 108 | var pullFoldersCmd = &cobra.Command{ 109 | Use: "pull-folders", 110 | Short: "Pull grafana folders json in to the directory", 111 | Long: `Save to the directory grafana folders json. 112 | Directory name specified by flag --directory.`, 113 | Run: func(cmd *cobra.Command, args []string) { 114 | url, _ := cmd.Flags().GetString("url") 115 | apiKey := viper.GetString("apikey") 116 | directory, _ := cmd.Flags().GetString("directory") 117 | if err := grafana.PullFolders(url, apiKey, directory); err != nil { 118 | log.Fatalln("Pull folders command failed", err) 119 | } 120 | if grafana.ExecutionErrorHappened { 121 | os.Exit(1) 122 | } 123 | }, 124 | } 125 | 126 | var pushFoldersCmd = &cobra.Command{ 127 | Use: "push-folders", 128 | Short: "Read json and create grafana folders", 129 | Long: `Read json with folders description and publish to grafana.`, 130 | Run: func(cmd *cobra.Command, args []string) { 131 | url, _ := cmd.Flags().GetString("url") 132 | apiKey := viper.GetString("apikey") 133 | directory, _ := cmd.Flags().GetString("directory") 134 | if err := grafana.PushFolder(url, apiKey, directory); err != nil { 135 | log.Fatalln("Push folders command failed", err) 136 | } 137 | if grafana.ExecutionErrorHappened { 138 | os.Exit(1) 139 | } 140 | }, 141 | } 142 | 143 | var pullNotificationsCmd = &cobra.Command{ 144 | Use: "pull-notifications", 145 | Short: "Pull grafana notifications json in to the directory", 146 | Long: `Save to the directory grafana folders json. 147 | Directory name specified by flag --directory.`, 148 | Run: func(cmd *cobra.Command, args []string) { 149 | url, _ := cmd.Flags().GetString("url") 150 | apiKey := viper.GetString("apikey") 151 | directory, _ := cmd.Flags().GetString("directory") 152 | if err := grafana.PullNotifications(url, apiKey, directory); err != nil { 153 | log.Fatalln("Pull notifications command failed", err) 154 | } 155 | if grafana.ExecutionErrorHappened { 156 | os.Exit(1) 157 | } 158 | }, 159 | } 160 | 161 | var pushNotificationsCmd = &cobra.Command{ 162 | Use: "push-notifications", 163 | Short: "Read json and create grafana notifications", 164 | Long: `Read json with notifications description and publish to grafana.`, 165 | Run: func(cmd *cobra.Command, args []string) { 166 | url, _ := cmd.Flags().GetString("url") 167 | apiKey := viper.GetString("apikey") 168 | directory, _ := cmd.Flags().GetString("directory") 169 | if err := grafana.PushNotification(url, apiKey, directory); err != nil { 170 | log.Fatalln("Push notifications command failed", err) 171 | } 172 | if grafana.ExecutionErrorHappened { 173 | os.Exit(1) 174 | } 175 | }, 176 | } 177 | 178 | var pullDataSourcesCmd = &cobra.Command{ 179 | Use: "pull-datasources", 180 | Short: "Pull grafana datasources json in to the directory", 181 | Long: `Save to the directory grafana datasources json. 182 | Directory name specified by flag --directory.`, 183 | Run: func(cmd *cobra.Command, args []string) { 184 | url, _ := cmd.Flags().GetString("url") 185 | apiKey := viper.GetString("apikey") 186 | directory, _ := cmd.Flags().GetString("directory") 187 | if err := grafana.PullDatasources(url, apiKey, directory); err != nil { 188 | log.Fatalln("Pull datasources command failed", err) 189 | } 190 | if grafana.ExecutionErrorHappened { 191 | os.Exit(1) 192 | } 193 | }, 194 | } 195 | 196 | var pushDataSourcesCmd = &cobra.Command{ 197 | Use: "push-datasources", 198 | Short: "Read json and create grafana datasources", 199 | Long: `Read json with datasources description and publish to grafana.`, 200 | Run: func(cmd *cobra.Command, args []string) { 201 | url, _ := cmd.Flags().GetString("url") 202 | apiKey := viper.GetString("apikey") 203 | directory, _ := cmd.Flags().GetString("directory") 204 | if err := grafana.PushDatasources(url, apiKey, directory); err != nil { 205 | log.Fatalln("Push datasources command failed", err) 206 | } 207 | if grafana.ExecutionErrorHappened { 208 | os.Exit(1) 209 | } 210 | }, 211 | } 212 | 213 | func Execute() { 214 | if err := rootCmd.Execute(); err != nil { 215 | fmt.Println(err) 216 | os.Exit(1) 217 | } 218 | } 219 | 220 | func init() { 221 | cobra.OnInitialize(initConfig) 222 | 223 | rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is $HOME/.grafana-sync.yaml)") 224 | rootCmd.PersistentFlags().StringP("url", "u", "http://localhost:3000", "Grafana Url with port") 225 | rootCmd.PersistentFlags().StringP("directory", "d", ".", "Directory where to save dashboards") 226 | rootCmd.PersistentFlags().StringP("apikey", "a", "", "Grafana api key") 227 | rootCmd.PersistentFlags().StringToStringVar(&customHeaders, "customHeaders", map[string]string{}, "Key-value pairs of custom http headers (key1=value1,key2=value2)") 228 | pullDataSourcesCmd.PersistentFlags().StringP("tag", "t", "", "Dashboard tag to read") 229 | pushDashboardsCmd.PersistentFlags().IntP("folderId", "f", 0, "Directory Id to which push dashboards") 230 | pushDashboardsCmd.PersistentFlags().StringP("folderName", "n", "", "Directory name to which push dashboards") 231 | pullDashboardsCmd.PersistentFlags().IntP("folderId", "f", -1, "Directory Id from which pull dashboards") 232 | pullDashboardsCmd.PersistentFlags().StringP("folderName", "n", "", "Directory name from which pull dashboards") 233 | pullDashboardsCmd.PersistentFlags().StringP("tag", "t", "", "Dashboard tag to p") 234 | 235 | if err := viper.BindPFlag("apikey", rootCmd.PersistentFlags().Lookup("apikey")); err != nil { 236 | log.Println(err) 237 | } 238 | 239 | if err := viper.BindPFlag("customHeaders", rootCmd.PersistentFlags().Lookup("customHeaders")); err != nil { 240 | log.Println(err) 241 | } 242 | 243 | rootCmd.AddCommand(pullDashboardsCmd) 244 | rootCmd.AddCommand(pushDashboardsCmd) 245 | rootCmd.AddCommand(pullFoldersCmd) 246 | rootCmd.AddCommand(pushFoldersCmd) 247 | rootCmd.AddCommand(pullNotificationsCmd) 248 | rootCmd.AddCommand(pushNotificationsCmd) 249 | rootCmd.AddCommand(pullDataSourcesCmd) 250 | rootCmd.AddCommand(pushDataSourcesCmd) 251 | } 252 | 253 | // initConfig reads in config file and ENV variables if set. 254 | func initConfig() { 255 | if cfgFile != "" { 256 | // Use config file from the flag. 257 | viper.SetConfigFile(cfgFile) 258 | } else { 259 | // Find home directory. 260 | home, err := homedir.Dir() 261 | if err != nil { 262 | fmt.Println(err) 263 | os.Exit(1) 264 | } 265 | 266 | // Search config in home directory with name ".grafana-sync" (without extension). 267 | viper.AddConfigPath(home) 268 | viper.SetConfigName(".grafana-sync") 269 | } 270 | 271 | viper.AutomaticEnv() // read in environment variables that match 272 | 273 | // If a config file is found, read it in. 274 | if err := viper.ReadInConfig(); err == nil { 275 | fmt.Println("Using config file:", viper.ConfigFileUsed()) 276 | } 277 | 278 | grafana.InitHttpClient(viper.GetStringMapString("customHeaders")) 279 | } 280 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/mpostument/grafana-sync 2 | 3 | go 1.16 4 | 5 | require ( 6 | github.com/grafana-tools/sdk v0.0.0-20220919052116-6562121319fc 7 | github.com/mitchellh/go-homedir v1.1.0 8 | github.com/spf13/cobra v1.7.0 9 | github.com/spf13/viper v1.17.0 10 | ) 11 | -------------------------------------------------------------------------------- /grafana/common.go: -------------------------------------------------------------------------------- 1 | package grafana 2 | 3 | import ( 4 | "io/ioutil" 5 | "os" 6 | "path/filepath" 7 | ) 8 | 9 | var ExecutionErrorHappened = false 10 | 11 | func writeToFile(directory string, content []byte, name string, tag string) error { 12 | var ( 13 | err error 14 | path string 15 | dashboardFile *os.File 16 | fileName string 17 | ) 18 | 19 | path = directory 20 | if tag != "" { 21 | path = filepath.Join(path, tag) 22 | } 23 | 24 | if _, err = os.Stat(path); os.IsNotExist(err) { 25 | if err = os.MkdirAll(path, 0755); err != nil { 26 | return err 27 | } 28 | } 29 | fileName = filepath.Join(path, name+".json") 30 | dashboardFile, err = os.Create(fileName) 31 | if err != nil { 32 | return err 33 | } 34 | 35 | defer dashboardFile.Close() 36 | 37 | err = ioutil.WriteFile(dashboardFile.Name(), content, os.FileMode(0755)) 38 | if err != nil { 39 | return err 40 | } 41 | return nil 42 | } 43 | -------------------------------------------------------------------------------- /grafana/dashboard.go: -------------------------------------------------------------------------------- 1 | package grafana 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "io/ioutil" 7 | "log" 8 | "os" 9 | "path/filepath" 10 | 11 | "github.com/grafana-tools/sdk" 12 | ) 13 | 14 | func PullDashboard(grafanaURL string, apiKey string, directory string, tag string, folderID int) error { 15 | var ( 16 | boardLinks []sdk.FoundBoard 17 | rawBoard sdk.Board 18 | meta sdk.BoardProperties 19 | err error 20 | ) 21 | 22 | ctx := context.Background() 23 | c, err := sdk.NewClient(grafanaURL, apiKey, httpClient) 24 | 25 | if err != nil { 26 | return err 27 | } 28 | 29 | searchParams := []sdk.SearchParam{sdk.SearchType(sdk.SearchTypeDashboard)} 30 | if folderID != -1 { 31 | searchParams = append(searchParams, sdk.SearchFolderID(folderID)) 32 | } 33 | 34 | if tag != "" { 35 | searchParams = append(searchParams, sdk.SearchTag(tag)) 36 | } 37 | 38 | if boardLinks, err = c.Search(ctx, searchParams...); err != nil { 39 | return err 40 | } 41 | 42 | for _, link := range boardLinks { 43 | if rawBoard, meta, err = c.GetDashboardByUID(ctx, link.UID); err != nil { 44 | log.Printf("%s for %s\n", err, link.URI) 45 | ExecutionErrorHappened = true 46 | continue 47 | } 48 | rawBoard.ID = 0 49 | b, err := json.MarshalIndent(rawBoard, "", " ") 50 | if err != nil { 51 | return err 52 | } 53 | if err = writeToFile(directory, b, meta.Slug, tag); err != nil { 54 | return err 55 | } 56 | } 57 | return nil 58 | } 59 | 60 | func PushDashboard(grafanaURL string, apiKey string, directory string, folderId int) error { 61 | var ( 62 | filesInDir []os.FileInfo 63 | rawBoard []byte 64 | err error 65 | ) 66 | 67 | ctx := context.Background() 68 | c, err := sdk.NewClient(grafanaURL, apiKey, httpClient) 69 | if err != nil { 70 | return err 71 | } 72 | 73 | if filesInDir, err = ioutil.ReadDir(directory); err != nil { 74 | return err 75 | } 76 | for _, file := range filesInDir { 77 | if filepath.Ext(file.Name()) == ".json" { 78 | if rawBoard, err = ioutil.ReadFile(filepath.Join(directory, file.Name())); err != nil { 79 | log.Println(err) 80 | ExecutionErrorHappened = true 81 | continue 82 | } 83 | var board sdk.Board 84 | if err = json.Unmarshal(rawBoard, &board); err != nil { 85 | log.Println(err) 86 | ExecutionErrorHappened = true 87 | continue 88 | } 89 | params := sdk.SetDashboardParams{ 90 | FolderID: folderId, 91 | Overwrite: true, 92 | } 93 | if _, err := c.SetDashboard(ctx, board, params); err != nil { 94 | log.Printf("error on importing dashboard %s", board.Title) 95 | ExecutionErrorHappened = true 96 | continue 97 | } 98 | } 99 | } 100 | return nil 101 | } 102 | -------------------------------------------------------------------------------- /grafana/datasource.go: -------------------------------------------------------------------------------- 1 | package grafana 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "io/ioutil" 7 | "log" 8 | "os" 9 | "path/filepath" 10 | 11 | "github.com/grafana-tools/sdk" 12 | ) 13 | 14 | func PullDatasources(grafanaURL string, apiKey string, directory string) error { 15 | var ( 16 | datasources []sdk.Datasource 17 | err error 18 | ) 19 | ctx := context.Background() 20 | 21 | c, err := sdk.NewClient(grafanaURL, apiKey, httpClient) 22 | if err != nil { 23 | return err 24 | } 25 | 26 | if datasources, err = c.GetAllDatasources(ctx); err != nil { 27 | return err 28 | } 29 | 30 | for _, datasource := range datasources { 31 | b, err := json.MarshalIndent(datasource, "", " ") 32 | if err != nil { 33 | return err 34 | } 35 | if err = writeToFile(directory, b, datasource.Name, ""); err != nil { 36 | return err 37 | } 38 | } 39 | return nil 40 | } 41 | 42 | func PushDatasources(grafanaURL string, apiKey string, directory string) error { 43 | var ( 44 | filesInDir []os.FileInfo 45 | rawFolder []byte 46 | err error 47 | ) 48 | 49 | ctx := context.Background() 50 | c, err := sdk.NewClient(grafanaURL, apiKey, httpClient) 51 | if err != nil { 52 | return err 53 | } 54 | 55 | if filesInDir, err = ioutil.ReadDir(directory); err != nil { 56 | return err 57 | } 58 | for _, file := range filesInDir { 59 | if filepath.Ext(file.Name()) == ".json" { 60 | if rawFolder, err = ioutil.ReadFile(filepath.Join(directory, file.Name())); err != nil { 61 | log.Println(err) 62 | continue 63 | } 64 | var datasource sdk.Datasource 65 | if err = json.Unmarshal(rawFolder, &datasource); err != nil { 66 | log.Println(err) 67 | ExecutionErrorHappened = true 68 | continue 69 | } 70 | if _, err := c.CreateDatasource(ctx, datasource); err != nil { 71 | log.Printf("error on importing folder %s", datasource.Name) 72 | ExecutionErrorHappened = true 73 | continue 74 | } 75 | } 76 | } 77 | return nil 78 | } 79 | -------------------------------------------------------------------------------- /grafana/folder.go: -------------------------------------------------------------------------------- 1 | package grafana 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "io/ioutil" 7 | "log" 8 | "os" 9 | "path/filepath" 10 | 11 | "github.com/grafana-tools/sdk" 12 | ) 13 | 14 | func PullFolders(grafanaURL string, apiKey string, directory string) error { 15 | var ( 16 | folders []sdk.Folder 17 | err error 18 | ) 19 | ctx := context.Background() 20 | 21 | c, err := sdk.NewClient(grafanaURL, apiKey, httpClient) 22 | if err != nil { 23 | return err 24 | } 25 | 26 | if folders, err = c.GetAllFolders(ctx); err != nil { 27 | return err 28 | } 29 | 30 | for _, folder := range folders { 31 | b, err := json.MarshalIndent(folder, "", " ") 32 | if err != nil { 33 | return err 34 | } 35 | if err = writeToFile(directory, b, folder.Title, ""); err != nil { 36 | return err 37 | } 38 | } 39 | return nil 40 | } 41 | 42 | func PushFolder(grafanaURL string, apiKey string, directory string) error { 43 | var ( 44 | filesInDir []os.FileInfo 45 | rawFolder []byte 46 | err error 47 | ) 48 | 49 | ctx := context.Background() 50 | c, err := sdk.NewClient(grafanaURL, apiKey, httpClient) 51 | if err != nil { 52 | return err 53 | } 54 | if filesInDir, err = ioutil.ReadDir(directory); err != nil { 55 | return err 56 | } 57 | for _, file := range filesInDir { 58 | if filepath.Ext(file.Name()) == ".json" { 59 | if rawFolder, err = ioutil.ReadFile(filepath.Join(directory, file.Name())); err != nil { 60 | log.Println(err) 61 | ExecutionErrorHappened = true 62 | continue 63 | } 64 | var folder sdk.Folder 65 | if err = json.Unmarshal(rawFolder, &folder); err != nil { 66 | log.Println(err) 67 | ExecutionErrorHappened = true 68 | continue 69 | } 70 | if _, err := c.CreateFolder(ctx, folder); err != nil { 71 | log.Printf("error on importing folder %s", folder.Title) 72 | ExecutionErrorHappened = true 73 | continue 74 | } 75 | } 76 | } 77 | return nil 78 | } 79 | 80 | func FindFolderId(grafanaURL string, apiKey string, folderName string) (int, error) { 81 | ctx := context.Background() 82 | c, err := sdk.NewClient(grafanaURL, apiKey, httpClient) 83 | if err != nil { 84 | return 0, err 85 | } 86 | 87 | allFolders, err := c.GetAllFolders(ctx) 88 | 89 | if err != nil { 90 | return 0, err 91 | } 92 | for _, folder := range allFolders { 93 | if folder.Title == folderName { 94 | return folder.ID, nil 95 | } 96 | } 97 | return 0, nil 98 | } 99 | -------------------------------------------------------------------------------- /grafana/http.go: -------------------------------------------------------------------------------- 1 | package grafana 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/grafana-tools/sdk" 7 | ) 8 | 9 | var httpClient = sdk.DefaultHTTPClient 10 | 11 | type customHttpTransport struct { 12 | http.Transport 13 | customHeaders map[string]string 14 | } 15 | 16 | func (ct *customHttpTransport) RoundTrip(req *http.Request) (*http.Response, error) { 17 | for key, value := range ct.customHeaders { 18 | req.Header.Add(key, value) 19 | } 20 | 21 | return ct.Transport.RoundTrip(req) 22 | } 23 | 24 | func InitHttpClient(customHeaders map[string]string) { 25 | httpClient.Transport = &customHttpTransport{ 26 | customHeaders: customHeaders, 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /grafana/notification.go: -------------------------------------------------------------------------------- 1 | package grafana 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "io/ioutil" 7 | "log" 8 | "os" 9 | "path/filepath" 10 | 11 | "github.com/grafana-tools/sdk" 12 | ) 13 | 14 | func PullNotifications(grafanaURL string, apiKey string, directory string) error { 15 | var ( 16 | notifications []sdk.AlertNotification 17 | err error 18 | ) 19 | ctx := context.Background() 20 | 21 | c, err := sdk.NewClient(grafanaURL, apiKey, httpClient) 22 | if err != nil { 23 | return err 24 | } 25 | 26 | if notifications, err = c.GetAllAlertNotifications(ctx); err != nil { 27 | return err 28 | } 29 | 30 | for _, notification := range notifications { 31 | b, err := json.MarshalIndent(notification, "", " ") 32 | if err != nil { 33 | return err 34 | } 35 | if err = writeToFile(directory, b, notification.Name, ""); err != nil { 36 | return err 37 | } 38 | } 39 | return nil 40 | } 41 | 42 | func PushNotification(grafanaURL string, apiKey string, directory string) error { 43 | var ( 44 | filesInDir []os.FileInfo 45 | rawFolder []byte 46 | err error 47 | ) 48 | 49 | ctx := context.Background() 50 | c, err := sdk.NewClient(grafanaURL, apiKey, httpClient) 51 | if err != nil { 52 | return err 53 | } 54 | if filesInDir, err = ioutil.ReadDir(directory); err != nil { 55 | return err 56 | } 57 | for _, file := range filesInDir { 58 | if filepath.Ext(file.Name()) == ".json" { 59 | if rawFolder, err = ioutil.ReadFile(filepath.Join(directory, file.Name())); err != nil { 60 | log.Println(err) 61 | ExecutionErrorHappened = true 62 | continue 63 | } 64 | var notification sdk.AlertNotification 65 | if err = json.Unmarshal(rawFolder, ¬ification); err != nil { 66 | log.Println(err) 67 | ExecutionErrorHappened = true 68 | continue 69 | } 70 | if _, err := c.CreateAlertNotification(ctx, notification); err != nil { 71 | log.Printf("error on importing notification %s", notification.Name) 72 | ExecutionErrorHappened = true 73 | continue 74 | } 75 | } 76 | } 77 | return nil 78 | } 79 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright © 2020 Maksym Postument 777rip777@gmail.com 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | package main 17 | 18 | import ( 19 | "github.com/mpostument/grafana-sync/cmd" 20 | ) 21 | 22 | func main() { 23 | cmd.Execute() 24 | } 25 | --------------------------------------------------------------------------------