├── .github ├── dependabot.yml └── workflows │ ├── build.yml │ ├── golangci-lint.yml │ └── test.yml ├── .gitignore ├── LICENSE ├── Makefile ├── README.md ├── docs ├── index.md └── rules │ ├── panel-datasource-rule.md │ ├── panel-title-description-rule.md │ ├── panel-units-rule.md │ ├── target-instance-rule.md │ ├── target-job-rule.md │ ├── target-logql-auto-rule.md │ ├── target-logql-rule.md │ ├── target-promql-rule.md │ ├── target-rate-interval-rule.md │ ├── template-datasource-rule.md │ ├── template-instance-rule.md │ ├── template-job-rule.md │ ├── template-label-promql-rule.md │ ├── template-on-time-change-reload-rule.md │ └── template-uneditable-rule.md ├── go.mod ├── go.sum ├── lint ├── configuration.go ├── constants.go ├── lint.go ├── lint_test.go ├── model_test.go ├── results.go ├── rule_panel_datasource.go ├── rule_panel_datasource_test.go ├── rule_panel_no_targets.go ├── rule_panel_no_targets_test.go ├── rule_panel_title_description.go ├── rule_panel_title_description_test.go ├── rule_panel_units.go ├── rule_panel_units_test.go ├── rule_target_counter_agg.go ├── rule_target_counter_agg_test.go ├── rule_target_job_instance.go ├── rule_target_job_instance_test.go ├── rule_target_logql.go ├── rule_target_logql_auto.go ├── rule_target_logql_auto_test.go ├── rule_target_logql_test.go ├── rule_target_promql.go ├── rule_target_promql_test.go ├── rule_target_rate_interval.go ├── rule_target_rate_interval_test.go ├── rule_template_datasource.go ├── rule_template_datasource_test.go ├── rule_template_instance.go ├── rule_template_instance_test.go ├── rule_template_job.go ├── rule_template_job_test.go ├── rule_template_label_promql.go ├── rule_template_label_promql_test.go ├── rule_template_on_time_change_reload.go ├── rule_template_on_time_change_reload_test.go ├── rule_uneditable.go ├── rule_uneditable_test.go ├── rules.go ├── rules_test.go ├── target_utils.go ├── testdata │ └── dashboard.json ├── variables.go └── variables_test.go ├── main.go └── scripts └── replace-rulenames-with-doclinks.sh /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "gomod" 4 | directory: "/" # Location of package manifests 5 | schedule: 6 | interval: "daily" 7 | open-pull-requests-limit: 10 8 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | on: [push, pull_request] 2 | name: Build 3 | permissions: 4 | contents: read 5 | 6 | jobs: 7 | build: 8 | strategy: 9 | matrix: 10 | go-version: [1.23.x] 11 | os: [ubuntu-24.04] 12 | runs-on: ${{ matrix.os }} 13 | steps: 14 | - name: Install Go 15 | uses: actions/setup-go@bfdd3570ce990073878bf10f6b2d79082de49492 # v2.2.0 16 | with: 17 | go-version: ${{ matrix.go-version }} 18 | - name: Checkout code 19 | uses: actions/checkout@ee0669bd1cc54295c223e0bb666b733df41de1c5 # v2.7.0 20 | with: 21 | persist-credentials: false 22 | - name: Build 23 | run: go build ./ 24 | -------------------------------------------------------------------------------- /.github/workflows/golangci-lint.yml: -------------------------------------------------------------------------------- 1 | name: golangci-lint 2 | on: [push, pull_request] 3 | permissions: 4 | contents: read 5 | # Optional: allow read access to pull request. Use with `only-new-issues` option. 6 | # pull-requests: read 7 | jobs: 8 | golangci: 9 | name: lint 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@ee0669bd1cc54295c223e0bb666b733df41de1c5 # v2.7.0 13 | with: 14 | persist-credentials: false 15 | - name: golangci-lint 16 | uses: golangci/golangci-lint-action@5c56cd6c9dc07901af25baab6f2b0d9f3b7c3018 # v2.5.2 17 | with: 18 | # Optional: version of golangci-lint to use in form of v1.2 or v1.2.3 or `latest` to use the latest version 19 | version: v1.61.0 20 | 21 | # Optional: working directory, useful for monorepos 22 | # working-directory: somedir 23 | 24 | # Optional: golangci-lint command line arguments. 25 | args: --timeout=80s 26 | 27 | # Optional: show only new issues if it's a pull request. The default value is `false`. 28 | # only-new-issues: true 29 | 30 | # Optional: if set to true then the action will use pre-installed Go. 31 | # skip-go-installation: true 32 | 33 | # Optional: if set to true then the action don't cache or restore ~/go/pkg. 34 | # skip-pkg-cache: true 35 | 36 | # Optional: if set to true then the action don't cache or restore ~/.cache/go-build. 37 | # skip-build-cache: true 38 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | on: [push, pull_request] 2 | name: Test 3 | permissions: 4 | contents: read 5 | 6 | jobs: 7 | test: 8 | strategy: 9 | matrix: 10 | go-version: [1.21.x] 11 | os: [ubuntu-latest] 12 | runs-on: ${{ matrix.os }} 13 | steps: 14 | - name: Install Go 15 | uses: actions/setup-go@bfdd3570ce990073878bf10f6b2d79082de49492 # v2.2.0 16 | with: 17 | go-version: ${{ matrix.go-version }} 18 | - name: Checkout code 19 | uses: actions/checkout@ee0669bd1cc54295c223e0bb666b733df41de1c5 # v2.7.0 20 | with: 21 | persist-credentials: false 22 | - name: Test 23 | run: go test ./... 24 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | docs/_intermediate 2 | .idea 3 | .DS_store 4 | dashboard-linter 5 | -------------------------------------------------------------------------------- /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 [yyyy] [name of copyright owner] 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. -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | FILES_TO_FMT=$(shell find . -name '*.go' -print) 2 | 3 | clean-docs: 4 | @rm -rf ./docs/_intermediate 5 | 6 | clean: clean-docs 7 | 8 | update-docs: intermediate-docs embedmd 9 | @./scripts/replace-rulenames-with-doclinks.sh 10 | @embedmd -w ./docs/index.md 11 | 12 | intermediate-docs: 13 | @mkdir -p ./docs/_intermediate 14 | @go run ./main.go -h > ./docs/_intermediate/help.txt 15 | @go run ./main.go completion -h > ./docs/_intermediate/completion.txt 16 | @go run ./main.go lint -h > ./docs/_intermediate/lint.txt 17 | @go run ./main.go rules > ./docs/_intermediate/rules.txt 18 | @echo "Can't automate everything, please replace the #Rules section of index.md with the contents of ./docs/_intermediate/rules.txt" 19 | 20 | embedmd: 21 | @go install github.com/campoy/embedmd@v1.0.0 22 | 23 | .PHONY: fmt check-fmt 24 | fmt: 25 | @gofmt -s -w $(FILES_TO_FMT) 26 | @goimports -w $(FILES_TO_FMT) 27 | 28 | check-fmt: fmt 29 | @git diff --exit-code -- $(FILES_TO_FMT) 30 | 31 | .PHONY: test 32 | test: 33 | @go test ./... 34 | 35 | .PHONY: lint 36 | lint: 37 | @echo "Running golangci-lint" 38 | golangci-lint run 39 | 40 | .PHONY: check 41 | check: test lint 42 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Grafana Dashboard Linter 2 | 3 | This tool is a command-line application to lint Grafana dashboards for common mistakes, and suggest best practices. To use the linter, run the following install commands: 4 | 5 | ``` 6 | $ go install github.com/grafana/dashboard-linter@latest 7 | $ dashboard-linter lint dashboard.json 8 | ``` 9 | 10 | This tool is a work in progress and it's still very early days. The current capabilities are focused exclusively on dashboards that use a Prometheus data source. 11 | 12 | See [the docs](docs/index.md) for more detail. 13 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | # Usage 2 | All Commands: 3 | 4 | [embedmd]:# (_intermediate/help.txt) 5 | 6 | ```txt 7 | A command-line application to lint Grafana dashboards. 8 | 9 | Usage: 10 | dashboard-linter [flags] 11 | dashboard-linter [command] 12 | 13 | Available Commands: 14 | completion Generate the autocompletion script for the specified shell 15 | help Help about any command 16 | lint Lint a dashboard 17 | rules Print documentation about each lint rule. 18 | 19 | Flags: 20 | -h, --help help for dashboard-linter 21 | 22 | Use "dashboard-linter [command] --help" for more information about a command. 23 | ``` 24 | 25 | ## Completion 26 | 27 | [embedmd]:# (_intermediate/completion.txt) 28 | 29 | ```txt 30 | Generate the autocompletion script for dashboard-linter for the specified shell. 31 | See each sub-command's help for details on how to use the generated script. 32 | 33 | Usage: 34 | dashboard-linter completion [command] 35 | 36 | Available Commands: 37 | bash Generate the autocompletion script for bash 38 | fish Generate the autocompletion script for fish 39 | powershell Generate the autocompletion script for powershell 40 | zsh Generate the autocompletion script for zsh 41 | 42 | Flags: 43 | -h, --help help for completion 44 | 45 | Use "dashboard-linter completion [command] --help" for more information about a command. 46 | ``` 47 | 48 | ## Lint 49 | 50 | [embedmd]:# (_intermediate/lint.txt) 51 | 52 | ```txt 53 | Returns warnings or errors for dashboard which do not adhere to accepted standards 54 | 55 | Usage: 56 | dashboard-linter lint [dashboard.json] [flags] 57 | 58 | Flags: 59 | -c, --config string path to a configuration file 60 | --fix automatically fix problems if possible 61 | -h, --help help for lint 62 | --stdin read from stdin 63 | --strict fail upon linting error or warning 64 | --verbose show more information about linting 65 | ``` 66 | 67 | # Rules 68 | 69 | The linter implements the following rules: 70 | 71 | * [template-datasource-rule](./rules/template-datasource-rule.md) - Checks that the dashboard has a templated datasource. 72 | * [template-job-rule](./rules/template-job-rule.md) - Checks that the dashboard has a templated job. 73 | * [template-instance-rule](./rules/template-instance-rule.md) - Checks that the dashboard has a templated instance. 74 | * [template-label-promql-rule](./rules/template-label-promql-rule.md) - Checks that the dashboard templated labels have proper PromQL expressions. 75 | * [template-on-time-change-reload-rule](./rules/template-on-time-change-reload-rule.md) - Checks that the dashboard template variables are configured to reload on time change. 76 | * [panel-datasource-rule](./rules/panel-datasource-rule.md) - Checks that each panel uses the templated datasource. 77 | * [panel-title-description-rule](./rules/panel-title-description-rule.md) - Checks that each panel has a title and description. 78 | * [panel-units-rule](./rules/panel-units-rule.md) - Checks that each panel uses has valid units defined. 79 | * `panel-no-targets-rule` - Checks that each panel has at least one target. 80 | * [target-logql-rule](./rules/target-logql-rule.md) - Checks that each target uses a valid LogQL query. 81 | * [target-logql-auto-rule](./rules/target-logql-auto-rule.md) - Checks that each Loki target uses $__auto for range vectors when appropriate. 82 | * [target-promql-rule](./rules/target-promql-rule.md) - Checks that each target uses a valid PromQL query. 83 | * [target-rate-interval-rule](./rules/target-rate-interval-rule.md) - Checks that each target uses $__rate_interval. 84 | * [target-job-rule](./rules/target-job-rule.md) - Checks that every PromQL query has a job matcher. 85 | * [target-instance-rule](./rules/target-instance-rule.md) - Checks that every PromQL query has a instance matcher. 86 | * `target-counter-agg-rule` - Checks that any counter metric (ending in _total) is aggregated with rate, irate, or increase. 87 | * `uneditable-dashboard` - Checks that the dashboard is not editable. 88 | 89 | ## Related Rules 90 | 91 | There are groups of rules that are intended to drive certain outcomes, but may be implemented separately to allow more granular [exceptions](#exclusions-and-warnings), and to keep the rules terse. 92 | 93 | ### Job and Instance Template Variables 94 | 95 | The following rules work together to ensure that every dashboard has template variables for `Job` and `Instance`, that they are properly configured, and used in every promql query. 96 | 97 | * [template-job-rule](./rules/template-job-rule.md) 98 | * [template-instance-rule](./rules/template-instance-rule.md) 99 | * [target-job-rule](./rules/target-job-rule.md) 100 | * [target-instance-rule](./rules/target-instance-rule.md) 101 | 102 | These rules enforce a best practice for dashboards with a single Prometheus or Loki data source. Metrics and logs scraped by Prometheus and Loki have automatically generated [job and instance labels](https://prometheus.io/docs/concepts/jobs_instances/) on them. For this reason, having the ability to filter by these assured always-present labels is logical and a useful additional feature. 103 | 104 | #### Multi Data Source Exceptions 105 | 106 | These rules may become cumbersome when dealing with a dashboard with more than one data source. Significant relabeling in the scrape config is required because the `job` and `instance` labels must match between each data source, and the default names for those labels will be different or absent in disparate data sources. 107 | 108 | For example: 109 | The [Grafana Cloud Docker Integration](https://grafana.com/docs/grafana-cloud/data-configuration/integrations/integration-reference/integration-docker/#post-install-configuration-for-the-docker-integration) combines metrics from cAdvisor, and logs from the docker daemon using `docker_sd_configs`. 110 | 111 | In this case, without label rewriting, the logs would not have any labels at all. The metrics relabeling applies opinionated job names rather than the defaults provided by the agent. (`integrations/cadvisor`). 112 | 113 | For dashboards like this, create a linting [exception](#exclusions-and-warnings) for these rules, and use a separate label that exists on data from all data sources to filter. 114 | 115 | # Exclusions and Warnings 116 | 117 | Where the rules above don't make sense, you can add a `.lint` file in the same directory as the dashboard telling the linter to ignore certain rules or downgrade them to a warning. 118 | 119 | Example: 120 | 121 | ```yaml 122 | exclusions: 123 | template-job-rule: 124 | warnings: 125 | template-instance-rule: 126 | ``` 127 | 128 | ## Reasons 129 | 130 | Whenever you exclude or warn for a rule, it's recommended that you provide a reason. This allows for other maintainers of your dashboard to understand why a particular rule may not be followed. Eventually, the dashboard-linter will provide reporting that echoes that reason back to the user. 131 | 132 | Example: 133 | 134 | ```yaml 135 | exclusions: 136 | template-job-rule: 137 | reason: A job matcher is hardcoded into the recording rule used for all queries on these dashboards. 138 | ``` 139 | 140 | ## Multiple Entries and Specific Exclusions 141 | 142 | It is possible to not exclude for every violation of a rule. Whenever possible, it is advised that you exclude *only* the rule violations that are necessary, and that you specifically identify them along with a reason. This will allow the linter to catch the same rule violation, which may happen on another dashboard, panel, or target when modifications are made. 143 | 144 | Example: 145 | 146 | ```yaml 147 | exclusions: 148 | target-rate-interval-rule: 149 | reason: Top 10's are intended to be displayed for the currently selected range. 150 | entries: 151 | - dashboard: Apollo Server 152 | panel: Top 10 Duration Rate 153 | - dashboard: Apollo Server 154 | panel: Top 10 Slowest Fields Resolution 155 | target-instance-rule: 156 | reason: Totals are intended to be across all instances 157 | entries: 158 | - panel: Requests Per Second 159 | targetIdx: 2 160 | - panel: Response Latency 161 | targetIdx: 2 162 | ``` 163 | -------------------------------------------------------------------------------- /docs/rules/panel-datasource-rule.md: -------------------------------------------------------------------------------- 1 | # panel-datasource-rule 2 | This rule checks each panel to be sure that it is using a templated datasource. 3 | 4 | It currently only checks panels of type ["singlestat", "graph", "table", "timeseries"]. -------------------------------------------------------------------------------- /docs/rules/panel-title-description-rule.md: -------------------------------------------------------------------------------- 1 | # panel-title-description-rule 2 | Checks that every panel has a title and description. 3 | 4 | It currently only checks panels of type ["stat", "singlestat", "graph", "table", "timeseries", "gauge"]. 5 | 6 | # Best Practice 7 | All panels should always have a title which clearly describes the panels purpose. 8 | 9 | All panels should also have a more detailed description which appears in the tooltip for the panel. 10 | 11 | # Possible exceptions 12 | If a panel is sufficiently descriptive in it's title and visualization, you may wish to exclude a description and create a lint exclusion for this rule. -------------------------------------------------------------------------------- /docs/rules/panel-units-rule.md: -------------------------------------------------------------------------------- 1 | # panel-units-rule 2 | Checks that every panel has a unit specified, and that the unit is valid per the [current list](https://github.com/grafana/grafana/blob/main/packages/grafana-data/src/valueFormats/categories.ts) defined in Grafana. 3 | 4 | It currently only checks panels of type ["stat", "singlestat", "graph", "table", "timeseries", "gauge"]. 5 | 6 | # Best Practice 7 | All panels should have an apprioriate unit set. 8 | 9 | # Possible exceptions 10 | This rule is automatically excluded when: 11 | - Value mappings are set in a panel. 12 | - A Stat panel is configured to show non-numeric values (like label's value), for that 'Fields options' are configured to any value other than 'Numeric fields' (which is default). 13 | 14 | Also, a panel may be visualizing something which does not have a predefined unit, or which is self explanatory from the vizualization title. In this case you may wish to create a lint exclusion for this rule. -------------------------------------------------------------------------------- /docs/rules/target-instance-rule.md: -------------------------------------------------------------------------------- 1 | # target-instance-rule 2 | Checks that each PromQL query has an instance matcher. See [Job and Instance Template Variables](../index.md#job-and-instance-template-variables) for more information about rules relating to this one. -------------------------------------------------------------------------------- /docs/rules/target-job-rule.md: -------------------------------------------------------------------------------- 1 | # target-job-rule 2 | Checks that each PromQL query has a job matcher. See [Job and Instance Template Variables](../index.md#job-and-instance-template-variables) for more information about rules relating to this one. 3 | 4 | -------------------------------------------------------------------------------- /docs/rules/target-logql-auto-rule.md: -------------------------------------------------------------------------------- 1 | # target-logql-auto-rule 2 | 3 | This rule ensures that all Loki queries in a dashboard use the `$__auto` variable for range vector selectors. Using `$__auto` allows for dynamic adjustment of the range based on the dashboard's time range and resolution, providing more accurate and performant queries. 4 | 5 | ## Best Practice 6 | 7 | Using `$__auto` instead of hard-coded time ranges like `[5m]` provides several benefits: 8 | 9 | 1. **Consistency**: It ensures a consistent approach across all Loki queries in the dashboard. 10 | 2. **Query type optimization**: It correctly uses `$__interval` for "Range" queries and `$__range` for "Instant" queries, optimizing the query for the specific type being used. 11 | 3. **Versatility**: The `$__auto` variable is automatically substituted with the step value for range queries, and with the selected time range's value (computed from the starting and ending times) for instant queries, making it suitable for various query types. 12 | 13 | A detailed explanation can be found in the [Grafana Cloud documentation](https://grafana.com/docs/grafana-cloud/connect-externally-hosted/data-sources/loki/template-variables/#use-__auto-variable-for-loki-metric-queries). 14 | 15 | ### Examples 16 | 17 | #### Invalid 18 | 19 | ```logql 20 | sum(count_over_time({job="mysql"} |= "duration" [5m])) 21 | ``` 22 | 23 | #### Valid 24 | 25 | ```logql 26 | sum(count_over_time({job="mysql"} |= "duration" [$__auto])) 27 | ``` 28 | 29 | ## Possible exceptions 30 | 31 | There may be cases where a specific, fixed time range is required for a particular query. In such cases, you may wish to create a [lint exclusion](../index.md#exclusions-and-warnings) for this rule. 32 | -------------------------------------------------------------------------------- /docs/rules/target-logql-rule.md: -------------------------------------------------------------------------------- 1 | # target-logql-rule 2 | 3 | This rule ensures that all LogQL queries in a dashboard are valid. It checks that each target uses a valid LogQL query, ensuring that the queries are correctly formatted and can be parsed without errors. 4 | -------------------------------------------------------------------------------- /docs/rules/target-promql-rule.md: -------------------------------------------------------------------------------- 1 | # target-promql-rule 2 | Checks that each Prometheus target on each panel uses a valid PromQL query. 3 | 4 | Does not execute against non Prometheus queries. -------------------------------------------------------------------------------- /docs/rules/target-rate-interval-rule.md: -------------------------------------------------------------------------------- 1 | # rate-interval-rule 2 | Checks that every target with a `rate`, `irate` or `increase` function uses `$__rate_interval` for the range of the data to process. 3 | 4 | # Best Practice 5 | In short, this ensures that there is always a sufficient number of data points to calculate a useful result. A detailed description can be found in [this Grafana blog post](https://grafana.com/blog/2020/09/28/new-in-grafana-7.2-__rate_interval-for-prometheus-rate-queries-that-just-work/) 6 | 7 | # Possible exeptions 8 | There may be cases where one deliberately wants to show the rate or increase over a fixed period of time, such as the last 24hr etc. In those cases you may wish to create a lint exclusion for this rule. -------------------------------------------------------------------------------- /docs/rules/template-datasource-rule.md: -------------------------------------------------------------------------------- 1 | # template-datasource-rule 2 | This rule checks that there is precisely one template variable for data source on your dashboard. 3 | 4 | ## Best Practice 5 | The data source variable should be named `datasource` and the label should be "Data Source" 6 | 7 | The variable may be for either a Prometheus or Loki datasource. 8 | 9 | ## Possible exceptions 10 | Some dashboards may contain other data source types besides Prometheus or Loki. 11 | 12 | Some dashboards may contain more than one data source. This rule will be updated in the future to accomodate multiple data sources. -------------------------------------------------------------------------------- /docs/rules/template-instance-rule.md: -------------------------------------------------------------------------------- 1 | # template-instance-rule 2 | Checks that each dashboard has a templated instance. See [Job and Instance Template Variables](../index.md#job-and-instance-template-variables) for more information about rules relating to this one. 3 | 4 | # Best Practice 5 | The rule ensures all of the following conditions. 6 | 7 | * The dashboard template exists. 8 | * The dashboard template is named `instance`. 9 | * The dashboard template is labeled `instance`. 10 | * The dashboard template uses a templated datasource, specifically named `$datasource`. 11 | * The dashboard template uses a Prometheus query to find available matching instances. 12 | * The dashboard template is multi select 13 | * The dashboard template has an allValue of `.+` 14 | 15 | -------------------------------------------------------------------------------- /docs/rules/template-job-rule.md: -------------------------------------------------------------------------------- 1 | # template-job-rule 2 | Checks that each dashboard has a templated job. See [Job and Instance Template Variables](../index.md#job-and-instance-template-variables) for more information about rules relating to this one. 3 | 4 | # Best Practice 5 | The rule ensures all of the following conditions. 6 | 7 | * The dashboard template exists. 8 | * The dashboard template is named `job`. 9 | * The dashboard template is labeled `job`. 10 | * The dashboard template uses a templated datasource, specifically named `$datasource`. 11 | * The dashboard template uses a Prometheus query to find available matching jobs. 12 | * The dashboard template is multi select 13 | * The dashboard template has an allValue of `.+` 14 | 15 | -------------------------------------------------------------------------------- /docs/rules/template-label-promql-rule.md: -------------------------------------------------------------------------------- 1 | # template-label-promql-rule 2 | Checks that dashboard template variables for Prometheus data sources, uses valid PromQL in the query. 3 | 4 | Does *not* execute against template variables which use a non Prometheus data source. -------------------------------------------------------------------------------- /docs/rules/template-on-time-change-reload-rule.md: -------------------------------------------------------------------------------- 1 | # template-on-time-change-reload-rule 2 | Checks that the dashboard template variables are configured to reload on time change. This ensures that a the template variables are up-to-date, which avoids errors which can occur when initially setting up telemetry, then navigating to the dashboard. 3 | 4 | This rule can fix errors using the `--fix` option. 5 | -------------------------------------------------------------------------------- /docs/rules/template-uneditable-rule.md: -------------------------------------------------------------------------------- 1 | # template-uneditable-rule 2 | Checks that dashboard is not able to be edited in the ui. This is due to the fact dashboards are declared as code 3 | and therefore that code should be the accurate source of truth for the dashboard 4 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/grafana/dashboard-linter 2 | 3 | go 1.23 4 | 5 | toolchain go1.23.1 6 | 7 | require ( 8 | github.com/grafana/loki/v3 v3.3.2 9 | github.com/prometheus/prometheus v0.55.1 10 | github.com/spf13/cobra v1.8.1 11 | github.com/spf13/viper v1.19.0 12 | github.com/stretchr/testify v1.10.0 13 | github.com/zeitlinger/conflate v0.0.0-20240927101413-c06be92f798f 14 | golang.org/x/text v0.21.0 15 | gopkg.in/yaml.v3 v3.0.1 16 | ) 17 | 18 | require ( 19 | cel.dev/expr v0.19.1 // indirect 20 | cloud.google.com/go v0.117.0 // indirect 21 | cloud.google.com/go/auth v0.13.0 // indirect 22 | cloud.google.com/go/auth/oauth2adapt v0.2.6 // indirect 23 | cloud.google.com/go/compute/metadata v0.6.0 // indirect 24 | cloud.google.com/go/iam v1.3.0 // indirect 25 | cloud.google.com/go/monitoring v1.22.0 // indirect 26 | cloud.google.com/go/storage v1.48.0 // indirect 27 | dario.cat/mergo v1.0.1 // indirect 28 | github.com/BurntSushi/toml v1.4.0 // indirect 29 | github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.25.0 // indirect 30 | github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.49.0 // indirect 31 | github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.49.0 // indirect 32 | github.com/HdrHistogram/hdrhistogram-go v1.1.2 // indirect 33 | github.com/Masterminds/goutils v1.1.1 // indirect 34 | github.com/Masterminds/semver/v3 v3.3.1 // indirect 35 | github.com/Masterminds/sprig/v3 v3.3.0 // indirect 36 | github.com/alecthomas/units v0.0.0-20240927000941-0f3dac36c52b // indirect 37 | github.com/armon/go-metrics v0.4.1 // indirect 38 | github.com/beorn7/perks v1.0.1 // indirect 39 | github.com/c2h5oh/datasize v0.0.0-20231215233829-aa82cc1e6500 // indirect 40 | github.com/census-instrumentation/opencensus-proto v0.4.1 // indirect 41 | github.com/cespare/xxhash v1.1.0 // indirect 42 | github.com/cespare/xxhash/v2 v2.3.0 // indirect 43 | github.com/cncf/xds/go v0.0.0-20241213214725-57cfbe6fad57 // indirect 44 | github.com/coreos/go-semver v0.3.1 // indirect 45 | github.com/coreos/go-systemd/v22 v22.5.0 // indirect 46 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect 47 | github.com/dennwc/varint v1.0.0 // indirect 48 | github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect 49 | github.com/dustin/go-humanize v1.0.1 // indirect 50 | github.com/edsrzf/mmap-go v1.2.0 // indirect 51 | github.com/envoyproxy/go-control-plane v0.13.1 // indirect 52 | github.com/envoyproxy/protoc-gen-validate v1.1.0 // indirect 53 | github.com/facette/natsort v0.0.0-20181210072756-2cd4dd1e2dcb // indirect 54 | github.com/fatih/color v1.18.0 // indirect 55 | github.com/felixge/httpsnoop v1.0.4 // indirect 56 | github.com/fsnotify/fsnotify v1.8.0 // indirect 57 | github.com/ghodss/yaml v1.0.0 // indirect 58 | github.com/go-kit/log v0.2.1 // indirect 59 | github.com/go-logfmt/logfmt v0.6.0 // indirect 60 | github.com/go-logr/logr v1.4.2 // indirect 61 | github.com/go-logr/stdr v1.2.2 // indirect 62 | github.com/go-redis/redis/v8 v8.11.5 // indirect 63 | github.com/gogo/googleapis v1.4.1 // indirect 64 | github.com/gogo/protobuf v1.3.2 // indirect 65 | github.com/gogo/status v1.1.1 // indirect 66 | github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect 67 | github.com/golang/protobuf v1.5.4 // indirect 68 | github.com/golang/snappy v0.0.4 // indirect 69 | github.com/google/btree v1.1.3 // indirect 70 | github.com/google/s2a-go v0.1.8 // indirect 71 | github.com/google/uuid v1.6.0 // indirect 72 | github.com/googleapis/enterprise-certificate-proxy v0.3.4 // indirect 73 | github.com/googleapis/gax-go/v2 v2.14.1 // indirect 74 | github.com/gorilla/mux v1.8.1 // indirect 75 | github.com/grafana/dskit v0.0.0-20241216174023-0450f2ba7c3d // indirect 76 | github.com/grafana/gomemcache v0.0.0-20241016125027-0a5bcc5aef40 // indirect 77 | github.com/grafana/jsonparser v0.0.0-20241004153430-023329977675 // indirect 78 | github.com/grafana/loki/pkg/push v0.0.0-20241220083700-6c49cc07305e // indirect 79 | github.com/grafana/pyroscope-go/godeltaprof v0.1.8 // indirect 80 | github.com/grafana/regexp v0.0.0-20240518133315-a468a5bfb3bc // indirect 81 | github.com/hashicorp/consul/api v1.30.0 // indirect 82 | github.com/hashicorp/errwrap v1.1.0 // indirect 83 | github.com/hashicorp/go-cleanhttp v0.5.2 // indirect 84 | github.com/hashicorp/go-hclog v1.6.3 // indirect 85 | github.com/hashicorp/go-immutable-radix v1.3.1 // indirect 86 | github.com/hashicorp/go-msgpack/v2 v2.1.2 // indirect 87 | github.com/hashicorp/go-multierror v1.1.1 // indirect 88 | github.com/hashicorp/go-rootcerts v1.0.2 // indirect 89 | github.com/hashicorp/go-sockaddr v1.0.7 // indirect 90 | github.com/hashicorp/golang-lru v1.0.2 // indirect 91 | github.com/hashicorp/hcl v1.0.0 // indirect 92 | github.com/hashicorp/memberlist v0.5.1 // indirect 93 | github.com/hashicorp/serf v0.10.1 // indirect 94 | github.com/huandu/xstrings v1.5.0 // indirect 95 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 96 | github.com/jpillora/backoff v1.0.0 // indirect 97 | github.com/json-iterator/go v1.1.12 // indirect 98 | github.com/klauspost/compress v1.17.11 // indirect 99 | github.com/magiconair/properties v1.8.9 // indirect 100 | github.com/mattn/go-colorable v0.1.13 // indirect 101 | github.com/mattn/go-isatty v0.0.20 // indirect 102 | github.com/mdlayher/socket v0.5.1 // indirect 103 | github.com/mdlayher/vsock v1.2.1 // indirect 104 | github.com/miekg/dns v1.1.62 // indirect 105 | github.com/mitchellh/copystructure v1.2.0 // indirect 106 | github.com/mitchellh/go-homedir v1.1.0 // indirect 107 | github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect 108 | github.com/mitchellh/mapstructure v1.5.0 // indirect 109 | github.com/mitchellh/reflectwalk v1.0.2 // indirect 110 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 111 | github.com/modern-go/reflect2 v1.0.2 // indirect 112 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect 113 | github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f // indirect 114 | github.com/opentracing-contrib/go-grpc v0.1.0 // indirect 115 | github.com/opentracing-contrib/go-stdlib v1.1.0 // indirect 116 | github.com/opentracing/opentracing-go v1.2.1-0.20220228012449-10b1cf09e00b // indirect 117 | github.com/pelletier/go-toml/v2 v2.2.3 // indirect 118 | github.com/pires/go-proxyproto v0.8.0 // indirect 119 | github.com/pkg/errors v0.9.1 // indirect 120 | github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect 121 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect 122 | github.com/prometheus/client_golang v1.20.5 // indirect 123 | github.com/prometheus/client_model v0.6.1 // indirect 124 | github.com/prometheus/common v0.61.0 // indirect 125 | github.com/prometheus/exporter-toolkit v0.13.2 // indirect 126 | github.com/prometheus/procfs v0.15.1 // indirect 127 | github.com/sagikazarmark/locafero v0.6.0 // indirect 128 | github.com/sagikazarmark/slog-shim v0.1.0 // indirect 129 | github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529 // indirect 130 | github.com/sercand/kuberesolver/v5 v5.1.1 // indirect 131 | github.com/shopspring/decimal v1.4.0 // indirect 132 | github.com/sony/gobreaker v1.0.0 // indirect 133 | github.com/sourcegraph/conc v0.3.0 // indirect 134 | github.com/spf13/afero v1.11.0 // indirect 135 | github.com/spf13/cast v1.7.1 // indirect 136 | github.com/spf13/pflag v1.0.5 // indirect 137 | github.com/stretchr/objx v0.5.2 // indirect 138 | github.com/subosito/gotenv v1.6.0 // indirect 139 | github.com/uber/jaeger-client-go v2.30.0+incompatible // indirect 140 | github.com/uber/jaeger-lib v2.4.1+incompatible // indirect 141 | github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect 142 | github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect 143 | github.com/xeipuuv/gojsonschema v1.2.0 // indirect 144 | go.etcd.io/etcd/api/v3 v3.5.17 // indirect 145 | go.etcd.io/etcd/client/pkg/v3 v3.5.17 // indirect 146 | go.etcd.io/etcd/client/v3 v3.5.17 // indirect 147 | go.opentelemetry.io/auto/sdk v1.1.0 // indirect 148 | go.opentelemetry.io/collector/pdata v1.22.0 // indirect 149 | go.opentelemetry.io/contrib/detectors/gcp v1.33.0 // indirect 150 | go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.58.0 // indirect 151 | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.58.0 // indirect 152 | go.opentelemetry.io/otel v1.33.0 // indirect 153 | go.opentelemetry.io/otel/metric v1.33.0 // indirect 154 | go.opentelemetry.io/otel/sdk v1.33.0 // indirect 155 | go.opentelemetry.io/otel/sdk/metric v1.33.0 // indirect 156 | go.opentelemetry.io/otel/trace v1.33.0 // indirect 157 | go.uber.org/atomic v1.11.0 // indirect 158 | go.uber.org/multierr v1.11.0 // indirect 159 | go.uber.org/zap v1.27.0 // indirect 160 | go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect 161 | golang.org/x/crypto v0.31.0 // indirect 162 | golang.org/x/exp v0.0.0-20241217172543-b2144cdd0a67 // indirect 163 | golang.org/x/mod v0.22.0 // indirect 164 | golang.org/x/net v0.33.0 // indirect 165 | golang.org/x/oauth2 v0.24.0 // indirect 166 | golang.org/x/sync v0.10.0 // indirect 167 | golang.org/x/sys v0.28.0 // indirect 168 | golang.org/x/time v0.8.0 // indirect 169 | golang.org/x/tools v0.28.0 // indirect 170 | google.golang.org/api v0.214.0 // indirect 171 | google.golang.org/genproto v0.0.0-20241219192143-6b3ec007d9bb // indirect 172 | google.golang.org/genproto/googleapis/api v0.0.0-20241219192143-6b3ec007d9bb // indirect 173 | google.golang.org/genproto/googleapis/rpc v0.0.0-20241219192143-6b3ec007d9bb // indirect 174 | google.golang.org/grpc v1.69.2 // indirect 175 | google.golang.org/protobuf v1.36.0 // indirect 176 | gopkg.in/ini.v1 v1.67.0 // indirect 177 | gopkg.in/yaml.v2 v2.4.0 // indirect 178 | ) 179 | -------------------------------------------------------------------------------- /lint/configuration.go: -------------------------------------------------------------------------------- 1 | package lint 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "strconv" 7 | 8 | yaml "gopkg.in/yaml.v3" 9 | ) 10 | 11 | // ConfigurationFile contains a map for rule exclusions, and warnings, where the key is the 12 | // rule name to be excluded or downgraded to a warning 13 | type ConfigurationFile struct { 14 | Exclusions map[string]*ConfigurationRuleEntries `yaml:"exclusions"` 15 | Warnings map[string]*ConfigurationRuleEntries `yaml:"warnings"` 16 | Verbose bool `yaml:"-"` 17 | Autofix bool `yaml:"-"` 18 | } 19 | 20 | type ConfigurationRuleEntries struct { 21 | Reason string `json:"reason,omitempty"` 22 | Entries []ConfigurationEntry `json:"entries,omitempty"` 23 | } 24 | 25 | // ConfigurationEntry will exist precisely once for every instance of a rule violation you wish 26 | // exclude or downgrade to a warning. Each ConfigurationEntry will have to be an *exact* match 27 | // to the combination of attributes set. Reason will not be evaluated, and is an opportunity for 28 | // the author to explain why the exception, or downgrade to warning exists. 29 | type ConfigurationEntry struct { 30 | Reason string `json:"reason,omitempty"` 31 | Dashboard string `json:"dashboard,omitempty"` 32 | Panel string `json:"panel,omitempty"` 33 | // Alerts are currently included, so we can read in configuration for Mixtool. 34 | Alert string `json:"alert,omitempty"` 35 | // This gets (un)marshalled as a string, because a 0 index is valid, but also the zero value of an int 36 | TargetIdx string `json:"targetIdx"` 37 | } 38 | 39 | func (cre *ConfigurationRuleEntries) AddEntry(e ConfigurationEntry) { 40 | cre.Entries = append(cre.Entries, e) 41 | } 42 | 43 | func (ce *ConfigurationEntry) IsMatch(r ResultContext) bool { 44 | ret := true 45 | if ce.Dashboard != "" && r.Dashboard != nil && ce.Dashboard != r.Dashboard.Title { 46 | ret = false 47 | } 48 | 49 | if ce.Panel != "" && r.Panel != nil && ce.Panel != r.Panel.Title { 50 | ret = false 51 | } 52 | 53 | if r.Target != nil && ce.TargetIdx != "" { 54 | idx, err := strconv.Atoi(ce.TargetIdx) 55 | if err == nil && idx != r.Target.Idx { 56 | ret = false 57 | } 58 | } 59 | 60 | return ret 61 | } 62 | 63 | func (cf *ConfigurationFile) Apply(res ResultContext) ResultContext { 64 | { 65 | exclusions, ok := cf.Exclusions[res.Rule.Name()] 66 | matched := false 67 | if exclusions != nil { 68 | for _, ce := range exclusions.Entries { 69 | if ce.IsMatch(res) { 70 | matched = true 71 | } 72 | } 73 | if len(exclusions.Entries) == 0 { 74 | matched = true 75 | } 76 | } else if ok { 77 | matched = true 78 | } 79 | if matched { 80 | for i, r := range res.Result.Results { 81 | r.Severity = Exclude 82 | r.Message += " (Excluded)" 83 | res.Result.Results[i] = r 84 | } 85 | } 86 | } 87 | 88 | { 89 | warnings, ok := cf.Warnings[res.Rule.Name()] 90 | matched := false 91 | if warnings != nil { 92 | for _, ce := range warnings.Entries { 93 | if ce.IsMatch(res) { 94 | matched = true 95 | } 96 | } 97 | if len(warnings.Entries) == 0 { 98 | matched = true 99 | } 100 | } else if ok { 101 | matched = true 102 | } 103 | if matched { 104 | for i, r := range res.Result.Results { 105 | r.Severity = Warning 106 | res.Result.Results[i] = r 107 | } 108 | } 109 | } 110 | 111 | { 112 | for i, r := range res.Result.Results { 113 | if !cf.Verbose && r.Severity == Success { 114 | r.Severity = Quiet 115 | res.Result.Results[i] = r 116 | } 117 | } 118 | } 119 | 120 | return res 121 | } 122 | 123 | func NewConfigurationFile() *ConfigurationFile { 124 | return &ConfigurationFile{ 125 | Exclusions: map[string]*ConfigurationRuleEntries{}, 126 | Warnings: map[string]*ConfigurationRuleEntries{}, 127 | } 128 | } 129 | 130 | func (cf *ConfigurationFile) Load(path string) error { 131 | f, err := os.Open(path) 132 | if err != nil && os.IsNotExist(err) { 133 | return nil 134 | } else if err != nil { 135 | return err 136 | } 137 | defer f.Close() 138 | 139 | dec := yaml.NewDecoder(f) 140 | if err = dec.Decode(cf); err != nil { 141 | return fmt.Errorf("could not unmarshal lint configuration %s: %w", path, err) 142 | } 143 | return nil 144 | } 145 | -------------------------------------------------------------------------------- /lint/constants.go: -------------------------------------------------------------------------------- 1 | package lint 2 | 3 | const targetTypeQuery = "query" 4 | 5 | const ( 6 | panelTypeStat = "stat" 7 | panelTypeSingleStat = "singlestat" 8 | panelTypeGauge = "gauge" 9 | panelTypeGraph = "graph" 10 | panelTypeTimeSeries = "timeseries" 11 | panelTypeTimeTable = "table" 12 | ) 13 | -------------------------------------------------------------------------------- /lint/lint.go: -------------------------------------------------------------------------------- 1 | package lint 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "strings" 7 | ) 8 | 9 | type Severity int 10 | 11 | const ( 12 | Success Severity = iota 13 | Exclude 14 | Quiet 15 | Warning 16 | Error 17 | Fixed 18 | 19 | Prometheus = "prometheus" 20 | Loki = "loki" 21 | ) 22 | 23 | // Target is a deliberately incomplete representation of the Dashboard -> Template type in grafana. 24 | // The properties which are extracted from JSON are only those used for linting purposes. 25 | type Template struct { 26 | Name string `json:"name"` 27 | Label string `json:"label"` 28 | Type string `json:"type"` 29 | RawQuery interface{} `json:"query"` 30 | Query string `json:"-"` 31 | Datasource interface{} `json:"datasource,omitempty"` 32 | Multi bool `json:"multi"` 33 | AllValue string `json:"allValue,omitempty"` 34 | Current RawTemplateValue `json:"current"` 35 | Options []RawTemplateValue `json:"options"` 36 | Refresh int `json:"refresh"` 37 | // If you add properties here don't forget to add them to the raw struct, and assign them from raw to actual in UnmarshalJSON below! 38 | } 39 | 40 | type RawTemplateValue map[string]interface{} 41 | 42 | type TemplateValue struct { 43 | Text string `json:"text"` 44 | Value string `json:"value"` 45 | } 46 | 47 | func (t *Template) UnmarshalJSON(buf []byte) error { 48 | var raw struct { 49 | Name string `json:"name"` 50 | Label string `json:"label"` 51 | Type string `json:"type"` 52 | Query interface{} `json:"query"` 53 | Datasource interface{} `json:"datasource,omitempty"` 54 | Multi bool `json:"multi"` 55 | AllValue string `json:"allValue"` 56 | Current RawTemplateValue `json:"current"` 57 | Options []RawTemplateValue `json:"options"` 58 | Refresh int `json:"refresh"` 59 | } 60 | if err := json.Unmarshal(buf, &raw); err != nil { 61 | return err 62 | } 63 | 64 | t.Name = raw.Name 65 | t.Label = raw.Label 66 | t.Type = raw.Type 67 | t.Datasource = raw.Datasource 68 | t.Multi = raw.Multi 69 | t.AllValue = raw.AllValue 70 | t.Current = raw.Current 71 | t.Options = raw.Options 72 | t.Refresh = raw.Refresh 73 | t.RawQuery = raw.Query 74 | 75 | // the 'adhoc' and 'custom' variable type does not have a field `Query`, so we can't perform these checks 76 | if t.Type != "adhoc" && t.Type != "custom" { 77 | switch v := raw.Query.(type) { 78 | case string: 79 | t.Query = v 80 | case map[string]interface{}: 81 | query, ok := v[targetTypeQuery] 82 | if ok { 83 | t.Query = query.(string) 84 | } 85 | default: 86 | return fmt.Errorf("invalid type for field 'query': %v", v) 87 | } 88 | } 89 | 90 | return nil 91 | } 92 | 93 | func (t *Template) GetDataSource() (Datasource, error) { 94 | return GetDataSource(t.Datasource) 95 | } 96 | 97 | func (raw *RawTemplateValue) Get() (TemplateValue, error) { 98 | t := TemplateValue{} 99 | var txt, val interface{} 100 | m := *raw 101 | 102 | txt, ok := m["text"] 103 | if ok { 104 | switch tt := txt.(type) { 105 | case string: 106 | t.Text = txt.(string) 107 | case []interface{}: 108 | t.Text = txt.([]interface{})[0].(string) 109 | default: 110 | return t, fmt.Errorf("invalid type for field 'text': %v", tt) 111 | } 112 | } 113 | 114 | val, ok = m["value"] 115 | if ok { 116 | switch vt := val.(type) { 117 | case string: 118 | t.Value = val.(string) 119 | case []interface{}: 120 | t.Value = val.([]interface{})[0].(string) 121 | default: 122 | return t, fmt.Errorf("invalid type for field 'value': %v", vt) 123 | } 124 | } 125 | 126 | return t, nil 127 | } 128 | 129 | // Input is a deliberately incomplete representation of the Dashboard -> Input type in grafana. 130 | // The properties which are extracted from JSON are only those used for linting purposes. 131 | type Input struct { 132 | Name string `json:"name"` 133 | Label string `json:"label"` 134 | Type string `json:"type"` 135 | PluginID string `json:"pluginId"` 136 | } 137 | 138 | type Datasource struct { 139 | UID string `json:"uid"` 140 | Type string `json:"type"` 141 | } 142 | 143 | func GetDataSource(raw interface{}) (Datasource, error) { 144 | switch v := raw.(type) { 145 | case nil: 146 | return Datasource{}, nil 147 | case string: 148 | return Datasource{v, ""}, nil 149 | case map[string]interface{}: 150 | uid, ok := v["uid"] 151 | if !ok { 152 | return Datasource{}, fmt.Errorf("invalid type for field 'datasource': missing uid field") 153 | } 154 | uidStr, ok := uid.(string) 155 | if !ok { 156 | return Datasource{}, fmt.Errorf("invalid type for field 'datasource': invalid uid field type, should be string") 157 | } 158 | if dsType, ok := v["type"]; ok { 159 | dsTypeStr, ok := dsType.(string) 160 | if !ok { 161 | return Datasource{}, fmt.Errorf("invalid type for field 'datasource': invalid type field type, should be string") 162 | } 163 | return Datasource{uidStr, dsTypeStr}, nil 164 | } 165 | return Datasource{uidStr, ""}, nil 166 | default: 167 | return Datasource{}, fmt.Errorf("invalid type for field 'datasource': %v", v) 168 | } 169 | } 170 | 171 | // Target is a deliberately incomplete representation of the Dashboard -> Panel -> Target type in grafana. 172 | // The properties which are extracted from JSON are only those used for linting purposes. 173 | type Target struct { 174 | Idx int `json:"-"` // This is the only (best?) way to uniquely identify a target, it is set by GetPanels 175 | Datasource interface{} `json:"datasource,omitempty"` 176 | Expr string `json:"expr,omitempty"` 177 | PanelId int `json:"panelId,omitempty"` 178 | RefId string `json:"refId,omitempty"` 179 | Hide bool `json:"hide"` 180 | } 181 | 182 | func (t *Target) GetDataSource() (Datasource, error) { 183 | return GetDataSource(t.Datasource) 184 | } 185 | 186 | type Annotation struct { 187 | Name string `json:"name"` 188 | Datasource interface{} `json:"datasource,omitempty"` 189 | } 190 | 191 | func (a *Annotation) GetDataSource() (Datasource, error) { 192 | return GetDataSource(a.Datasource) 193 | } 194 | 195 | // Panel is a deliberately incomplete representation of the Dashboard -> Panel type in grafana. 196 | // The properties which are extracted from JSON are only those used for linting purposes. 197 | type Panel struct { 198 | Id int `json:"id"` 199 | Title string `json:"title"` 200 | Description string `json:"description,omitempty"` 201 | Targets []Target `json:"targets,omitempty"` 202 | Datasource interface{} `json:"datasource,omitempty"` 203 | Type string `json:"type"` 204 | Panels []Panel `json:"panels,omitempty"` 205 | FieldConfig *FieldConfig `json:"fieldConfig,omitempty"` 206 | Options json.RawMessage `json:"options,omitempty"` 207 | } 208 | 209 | type FieldConfig struct { 210 | Defaults Defaults `json:"defaults,omitempty"` 211 | Overrides []Override `json:"overrides,omitempty"` 212 | } 213 | 214 | type Override struct { 215 | OverrideProperties []OverrideProperty `json:"properties"` 216 | } 217 | 218 | type OverrideProperty struct { 219 | Id string `json:"id"` 220 | Value any `json:"value"` 221 | } 222 | 223 | // oversimplified Reduce options 224 | type ReduceOptions struct { 225 | Fields string `json:"fields,omitempty"` 226 | Calcs []string `json:"[]calcs,omitempty"` 227 | Values bool `json:"values,omitempty"` 228 | Limit int `json:"limit,omitempty"` 229 | } 230 | 231 | // Stat panel options is a deliberately incomplete representation of the stat panel options from grafana. 232 | // The properties which are extracted from JSON are only those used for linting purposes. 233 | type StatOptions struct { 234 | ReduceOptions ReduceOptions `json:"reduceOptions,omitempty"` 235 | } 236 | 237 | type Defaults struct { 238 | Unit string `json:"unit,omitempty"` 239 | Mappings json.RawMessage `json:"mappings,omitempty"` 240 | } 241 | 242 | // GetPanels returns the all panels nested inside the panel (inc the current panel) 243 | func (p *Panel) GetPanels() []Panel { 244 | panels := []Panel{*p} 245 | for _, panel := range p.Panels { 246 | panels = append(panels, panel.GetPanels()...) 247 | } 248 | return panels 249 | } 250 | 251 | func (p *Panel) GetDataSource() (Datasource, error) { 252 | return GetDataSource(p.Datasource) 253 | } 254 | 255 | // Row is a deliberately incomplete representation of the Dashboard -> Row type in grafana. 256 | // The properties which are extracted from JSON are only those used for linting purposes. 257 | type Row struct { 258 | Panels []Panel `json:"panels,omitempty"` 259 | } 260 | 261 | // GetPanels returns the all panels nested inside the row 262 | func (r *Row) GetPanels() []Panel { 263 | var panels []Panel 264 | for _, panel := range r.Panels { 265 | panels = append(panels, panel.GetPanels()...) 266 | } 267 | return panels 268 | } 269 | 270 | // Dashboard is a deliberately incomplete representation of the Dashboard type in grafana. 271 | // The properties which are extracted from JSON are only those used for linting purposes. 272 | type Dashboard struct { 273 | Inputs []Input `json:"__inputs"` 274 | Title string `json:"title,omitempty"` 275 | Templating struct { 276 | List []Template `json:"list"` 277 | } `json:"templating"` 278 | Annotations struct { 279 | List []Annotation `json:"list"` 280 | } `json:"annotations"` 281 | Rows []Row `json:"rows,omitempty"` 282 | Panels []Panel `json:"panels,omitempty"` 283 | Editable bool `json:"editable,omitempty"` 284 | 285 | // Kubernetes shaped dashboards will include an APIVersion and Kind 286 | APIVersion string `json:"apiVersion,omitempty"` 287 | // When reading a kubernetes encoded dashboard, the Dashboard will be 288 | Spec json.RawMessage `json:"spec,omitempty"` 289 | } 290 | 291 | // GetPanels returns the all panels whether they are nested in the (now deprecated) "rows" property or 292 | // in the top level "panels" property. This also monkeypatches Target.Idx into each panel which is used 293 | // to uniquely identify panel targets while linting. 294 | func (d *Dashboard) GetPanels() []Panel { 295 | var p []Panel 296 | for _, row := range d.Rows { 297 | p = append(p, row.GetPanels()...) 298 | } 299 | for _, panel := range d.Panels { 300 | p = append(p, panel.GetPanels()...) 301 | } 302 | for pi, pa := range p { 303 | for ti := range pa.Targets { 304 | p[pi].Targets[ti].Idx = ti 305 | } 306 | } 307 | return p 308 | } 309 | 310 | // GetTemplateByType returns all dashboard templates which match the provided type. Type comparison 311 | // is case insensitive as it uses strings.EqualFold() 312 | func (d *Dashboard) GetTemplateByType(t string) []Template { 313 | var retval []Template 314 | for _, templ := range d.Templating.List { 315 | if strings.EqualFold(templ.Type, t) { 316 | retval = append(retval, templ) 317 | } 318 | } 319 | return retval 320 | } 321 | 322 | func (d *Dashboard) Marshal() ([]byte, error) { 323 | return json.Marshal(d) 324 | } 325 | 326 | func NewDashboard(buf []byte) (Dashboard, error) { 327 | var dash Dashboard 328 | if err := json.Unmarshal(buf, &dash); err != nil { 329 | return dash, err 330 | } 331 | // Support kubernetes flavored dashboards 332 | if dash.Spec != nil { 333 | apiVersion := dash.APIVersion 334 | if apiVersion != "" { 335 | if !(strings.HasPrefix(apiVersion, "v0") || strings.HasPrefix(apiVersion, "v1")) { 336 | return dash, fmt.Errorf("unsupported apiVersion") 337 | } 338 | } 339 | if err := json.Unmarshal(dash.Spec, &dash); err != nil { 340 | return dash, err 341 | } 342 | dash.APIVersion = apiVersion // preserve the original APIVersion 343 | } 344 | return dash, nil 345 | } 346 | -------------------------------------------------------------------------------- /lint/lint_test.go: -------------------------------------------------------------------------------- 1 | package lint 2 | 3 | import ( 4 | "strconv" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/require" 8 | ) 9 | 10 | type TestRule struct { 11 | Rule 12 | name string 13 | } 14 | 15 | func (r *TestRule) Description() string { 16 | return "Test Rule" 17 | } 18 | 19 | func (r *TestRule) Name() string { 20 | return r.name 21 | } 22 | 23 | func appendConfigExclude(t *testing.T, rule string, dashboard string, panel string, targetIdx string, config *ConfigurationFile) { 24 | t.Helper() 25 | 26 | entries := config.Exclusions[rule] 27 | if entries == nil { 28 | entries = &ConfigurationRuleEntries{} 29 | } 30 | 31 | if dashboard != "" || panel != "" || targetIdx != "" { 32 | entries.Entries = append(entries.Entries, ConfigurationEntry{ 33 | Dashboard: dashboard, 34 | Panel: panel, 35 | TargetIdx: targetIdx, 36 | }) 37 | } 38 | config.Exclusions[rule] = entries 39 | } 40 | 41 | func appendConfigWarning(t *testing.T, rule string, dashboard string, panel string, targetIdx string, config *ConfigurationFile) { 42 | t.Helper() 43 | 44 | entries := config.Warnings[rule] 45 | if entries == nil { 46 | entries = &ConfigurationRuleEntries{} 47 | } 48 | 49 | if dashboard != "" || panel != "" || targetIdx != "" { 50 | entries.Entries = append(entries.Entries, ConfigurationEntry{ 51 | Dashboard: dashboard, 52 | Panel: panel, 53 | TargetIdx: targetIdx, 54 | }) 55 | } 56 | config.Warnings[rule] = entries 57 | } 58 | 59 | func newResultContext(rule string, dashboard string, panel string, targetIdx string, result Severity) ResultContext { 60 | ret := ResultContext{ 61 | Result: newRuleResults(Result{Severity: result, Message: "foo"}), 62 | } 63 | if rule != "" { 64 | ret.Rule = &TestRule{name: rule} 65 | } 66 | if dashboard != "" { 67 | ret.Dashboard = &Dashboard{Title: dashboard} 68 | } 69 | if panel != "" { 70 | ret.Panel = &Panel{Title: panel} 71 | } 72 | if targetIdx != "" { 73 | idx, err := strconv.Atoi(targetIdx) 74 | if err == nil { 75 | ret.Target = &Target{Idx: idx} 76 | } 77 | } 78 | return ret 79 | } 80 | 81 | func newRuleResults(r Result) RuleResults { 82 | return RuleResults{Results: []FixableResult{{Result: r}}} 83 | } 84 | 85 | func TestResultSet(t *testing.T) { 86 | t.Run("MaximumSeverity", func(t *testing.T) { 87 | r := ResultSet{ 88 | results: []ResultContext{ 89 | {Result: newRuleResults(Result{Severity: Success})}, 90 | {Result: newRuleResults(Result{Severity: Warning})}, 91 | {Result: newRuleResults(Result{Severity: Error})}, 92 | }, 93 | } 94 | 95 | require.Equal(t, r.MaximumSeverity(), Error) 96 | }) 97 | 98 | t.Run("ByRule", func(t *testing.T) { 99 | r := ResultSet{ 100 | results: []ResultContext{ 101 | newResultContext("rule1", "", "", "", Success), 102 | newResultContext("rule2", "", "", "", Success), 103 | }, 104 | } 105 | 106 | byRule := r.ByRule() 107 | 108 | require.Len(t, byRule, 2) 109 | require.Contains(t, byRule, "rule1") 110 | require.Contains(t, byRule, "rule2") 111 | require.Len(t, byRule["rule1"], 1) 112 | require.Len(t, byRule["rule2"], 1) 113 | }) 114 | 115 | t.Run("Honors Configuration given config present before results added", func(t *testing.T) { 116 | c := NewConfigurationFile() 117 | appendConfigExclude(t, "rule1", "", "", "", c) 118 | 119 | r := ResultSet{} 120 | r.Configure(c) 121 | r.AddResult(newResultContext("rule1", "", "", "", Error)) 122 | 123 | require.Equal(t, Exclude, r.MaximumSeverity()) 124 | require.Equal(t, Exclude, r.ByRule()["rule1"][0].Result.Results[0].Severity) 125 | }) 126 | 127 | t.Run("Honors Configuration given config added after results added", func(t *testing.T) { 128 | c := NewConfigurationFile() 129 | appendConfigExclude(t, "rule1", "", "", "", c) 130 | 131 | r := ResultSet{} 132 | r.AddResult(newResultContext("rule1", "", "", "", Error)) 133 | r.Configure(c) 134 | 135 | require.Equal(t, Exclude, r.MaximumSeverity()) 136 | require.Equal(t, Exclude, r.ByRule()["rule1"][0].Result.Results[0].Severity) 137 | }) 138 | } 139 | 140 | func TestConfiguration(t *testing.T) { 141 | t.Run("Excludes Rule", func(t *testing.T) { 142 | c := NewConfigurationFile() 143 | appendConfigExclude(t, "rule1", "", "", "", c) 144 | 145 | r1 := newResultContext("rule1", "", "", "", Error) 146 | r2 := newResultContext("rule2", "", "", "", Error) 147 | 148 | rc1 := c.Apply(r1) 149 | require.Equal(t, Exclude, rc1.Result.Results[0].Severity) 150 | 151 | rc2 := c.Apply(r2) 152 | require.Equal(t, Error, rc2.Result.Results[0].Severity) 153 | }) 154 | 155 | t.Run("Warns Rule", func(t *testing.T) { 156 | c := NewConfigurationFile() 157 | appendConfigWarning(t, "rule1", "", "", "", c) 158 | 159 | r1 := newResultContext("rule1", "", "", "", Error) 160 | r2 := newResultContext("rule2", "", "", "", Error) 161 | 162 | rc1 := c.Apply(r1) 163 | require.Equal(t, Warning, rc1.Result.Results[0].Severity) 164 | 165 | rc2 := c.Apply(r2) 166 | require.Equal(t, Error, rc2.Result.Results[0].Severity) 167 | }) 168 | 169 | t.Run("Excludes More Specific Config", func(t *testing.T) { 170 | c := NewConfigurationFile() 171 | appendConfigExclude(t, "rule1", "", "", "", c) 172 | appendConfigExclude(t, "rule1", "dash1", "", "", c) 173 | 174 | r1 := newResultContext("rule1", "dash1", "foo", "0", Error) 175 | r2 := newResultContext("rule1", "dash2", "bar", "0", Error) 176 | 177 | rc1 := c.Apply(r1) 178 | require.Equal(t, Exclude, rc1.Result.Results[0].Severity) 179 | 180 | rc2 := c.Apply(r2) 181 | require.Equal(t, Error, rc2.Result.Results[0].Severity) 182 | }) 183 | 184 | t.Run("Excludes multiple entries for the same rule", func(t *testing.T) { 185 | c := NewConfigurationFile() 186 | appendConfigExclude(t, "rule1", "dash1", "", "", c) 187 | appendConfigExclude(t, "rule1", "dash2", "", "", c) 188 | 189 | r1 := newResultContext("rule1", "dash1", "", "", Error) 190 | r2 := newResultContext("rule1", "dash2", "", "", Error) 191 | r3 := newResultContext("rule1", "dash3", "", "", Error) 192 | 193 | rc1 := c.Apply(r1) 194 | require.Equal(t, Exclude, rc1.Result.Results[0].Severity) 195 | 196 | rc2 := c.Apply(r2) 197 | require.Equal(t, Exclude, rc2.Result.Results[0].Severity) 198 | 199 | rc3 := c.Apply(r3) 200 | require.Equal(t, Error, rc3.Result.Results[0].Severity) 201 | }) 202 | 203 | t.Run("Excludes all when rule defined but entries empty", func(t *testing.T) { 204 | c := NewConfigurationFile() 205 | appendConfigExclude(t, "rule1", "", "", "", c) 206 | 207 | r1 := newResultContext("rule1", "dash1", "panel1", "0", Error) 208 | r2 := newResultContext("rule1", "dash1", "panel1", "1", Error) 209 | 210 | rs := []ResultContext{r1, r2} 211 | for _, r := range rs { 212 | rc := c.Apply(r) 213 | require.Equal(t, Exclude, rc.Result.Results[0].Severity) 214 | } 215 | }) 216 | 217 | // Dashboards 218 | t.Run("Excludes Dashboard", func(t *testing.T) { 219 | c := NewConfigurationFile() 220 | appendConfigExclude(t, "rule1", "dash1", "", "", c) 221 | 222 | r1 := newResultContext("rule1", "dash1", "", "", Error) 223 | r2 := newResultContext("rule1", "dash2", "", "", Error) 224 | 225 | rc1 := c.Apply(r1) 226 | require.Equal(t, Exclude, rc1.Result.Results[0].Severity) 227 | 228 | rc2 := c.Apply(r2) 229 | require.Equal(t, Error, rc2.Result.Results[0].Severity) 230 | }) 231 | 232 | t.Run("Warns Dashboard", func(t *testing.T) { 233 | c := NewConfigurationFile() 234 | appendConfigWarning(t, "rule1", "dash1", "", "", c) 235 | 236 | r1 := newResultContext("rule1", "dash1", "", "", Error) 237 | r2 := newResultContext("rule1", "dash2", "", "", Error) 238 | 239 | rc1 := c.Apply(r1) 240 | require.Equal(t, Warning, rc1.Result.Results[0].Severity) 241 | 242 | rc2 := c.Apply(r2) 243 | require.Equal(t, Error, rc2.Result.Results[0].Severity) 244 | }) 245 | 246 | // Panels 247 | t.Run("Excludes Panels", func(t *testing.T) { 248 | c := NewConfigurationFile() 249 | appendConfigExclude(t, "rule1", "dash1", "panel1", "", c) 250 | 251 | r1 := newResultContext("rule1", "dash1", "panel1", "", Error) 252 | r2 := newResultContext("rule1", "dash1", "panel2", "", Error) 253 | 254 | rc1 := c.Apply(r1) 255 | require.Equal(t, Exclude, rc1.Result.Results[0].Severity) 256 | 257 | rc2 := c.Apply(r2) 258 | require.Equal(t, Error, rc2.Result.Results[0].Severity) 259 | }) 260 | 261 | t.Run("Warns Panels", func(t *testing.T) { 262 | c := NewConfigurationFile() 263 | appendConfigWarning(t, "rule1", "dash1", "panel1", "", c) 264 | 265 | r1 := newResultContext("rule1", "dash1", "panel1", "", Error) 266 | r2 := newResultContext("rule1", "dash1", "panel2", "", Error) 267 | 268 | rc1 := c.Apply(r1) 269 | require.Equal(t, Warning, rc1.Result.Results[0].Severity) 270 | 271 | rc2 := c.Apply(r2) 272 | require.Equal(t, Error, rc2.Result.Results[0].Severity) 273 | }) 274 | 275 | // Targets 276 | t.Run("Excludes Targets", func(t *testing.T) { 277 | c := NewConfigurationFile() 278 | appendConfigExclude(t, "rule1", "dash1", "panel1", "0", c) 279 | 280 | r1 := newResultContext("rule1", "dash1", "panel1", "0", Error) 281 | r2 := newResultContext("rule1", "dash1", "panel1", "1", Error) 282 | 283 | rc1 := c.Apply(r1) 284 | require.Equal(t, Exclude, rc1.Result.Results[0].Severity) 285 | 286 | rc2 := c.Apply(r2) 287 | require.Equal(t, Error, rc2.Result.Results[0].Severity) 288 | }) 289 | 290 | t.Run("Warns Targets", func(t *testing.T) { 291 | c := NewConfigurationFile() 292 | appendConfigWarning(t, "rule1", "dash1", "panel1", "0", c) 293 | 294 | r1 := newResultContext("rule1", "dash1", "panel1", "0", Error) 295 | r2 := newResultContext("rule1", "dash1", "panel1", "1", Error) 296 | 297 | rc1 := c.Apply(r1) 298 | require.Equal(t, Warning, rc1.Result.Results[0].Severity) 299 | 300 | rc2 := c.Apply(r2) 301 | require.Equal(t, Error, rc2.Result.Results[0].Severity) 302 | }) 303 | } 304 | -------------------------------------------------------------------------------- /lint/model_test.go: -------------------------------------------------------------------------------- 1 | package lint 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "fmt" 7 | "os" 8 | "testing" 9 | 10 | "github.com/stretchr/testify/assert" 11 | "github.com/stretchr/testify/require" 12 | ) 13 | 14 | func TestParseDatasource(t *testing.T) { 15 | for _, tc := range []struct { 16 | name string 17 | input []byte 18 | expected Datasource 19 | err error 20 | }{ 21 | { 22 | name: "string", 23 | input: []byte(`"${datasource}"`), 24 | expected: Datasource{"${datasource}", ""}, 25 | }, 26 | { 27 | name: "uid", 28 | input: []byte(`{"uid":"${datasource}"}`), 29 | expected: Datasource{"${datasource}", ""}, 30 | }, 31 | { 32 | name: "uid-type", 33 | input: []byte(`{"uid":"${datasource}","type":"${type}"}`), 34 | expected: Datasource{"${datasource}", "${type}"}, 35 | }, 36 | { 37 | name: "byte", 38 | input: []byte(`1`), 39 | err: fmt.Errorf("invalid type for field 'datasource': 1"), 40 | }, 41 | { 42 | name: "empty object", 43 | input: []byte(`{}`), 44 | err: fmt.Errorf("invalid type for field 'datasource': missing uid field"), 45 | }, 46 | { 47 | name: "int uid", 48 | input: []byte(`{"uid":1}`), 49 | err: fmt.Errorf("invalid type for field 'datasource': invalid uid field type, should be string"), 50 | }, 51 | } { 52 | t.Run(tc.name, func(t *testing.T) { 53 | var raw interface{} 54 | err := json.Unmarshal(tc.input, &raw) 55 | require.NoError(t, err) 56 | actual, err := GetDataSource(raw) 57 | require.Equal(t, tc.err, err) 58 | require.Equal(t, tc.expected, actual) 59 | }) 60 | } 61 | } 62 | 63 | func TestParseDashboard(t *testing.T) { 64 | sampleDashboard, err := os.ReadFile("testdata/dashboard.json") 65 | assert.NoError(t, err) 66 | t.Run("Row panels", func(t *testing.T) { 67 | dashboard, err := NewDashboard(sampleDashboard) 68 | assert.NoError(t, err) 69 | assert.Len(t, dashboard.GetPanels(), 4) 70 | }) 71 | t.Run("Annotations", func(t *testing.T) { 72 | dashboard, err := NewDashboard(sampleDashboard) 73 | assert.NoError(t, err) 74 | assert.Len(t, dashboard.Annotations.List, 1) 75 | }) 76 | 77 | t.Run("v0alpha1 dashboard", func(t *testing.T) { 78 | wrap := `{ 79 | "apiVersion": "v0alpha1", 80 | "kind": "Dashboard", 81 | "spec": ` + string(sampleDashboard) + ` 82 | }` 83 | 84 | dashboard, err := NewDashboard([]byte(wrap)) 85 | assert.NoError(t, err) 86 | assert.Len(t, dashboard.Annotations.List, 1) 87 | assert.Equal(t, "v0alpha1", dashboard.APIVersion) 88 | }) 89 | } 90 | 91 | func TestParseTemplateValue(t *testing.T) { 92 | for _, tc := range []struct { 93 | input []byte 94 | expected TemplateValue 95 | err error 96 | }{ 97 | { 98 | input: []byte(`{"text": "text", "value": "value"}`), 99 | expected: TemplateValue{Text: "text", Value: "value"}, 100 | }, 101 | { 102 | input: []byte(`{"text": ["text1", "text2"], "value": ["value1", "value2"]}`), 103 | expected: TemplateValue{Text: "text1", Value: "value1"}, 104 | }, 105 | { 106 | input: []byte(`{"text": 1, "value": 2}`), 107 | err: errors.New("invalid type for field 'text': 1"), 108 | }, 109 | { 110 | input: []byte(`{"text": "text", "value": 2}`), 111 | expected: TemplateValue{Text: "text"}, 112 | err: errors.New("invalid type for field 'value': 2"), 113 | }, 114 | { 115 | input: []byte(`{}`), 116 | expected: TemplateValue{Text: "", Value: ""}, 117 | }, 118 | { 119 | input: []byte(`{"text": "text"}`), 120 | expected: TemplateValue{Text: "text", Value: ""}, 121 | }, 122 | } { 123 | var raw RawTemplateValue 124 | err := json.Unmarshal(tc.input, &raw) 125 | require.NoError(t, err) 126 | actual, err := raw.Get() 127 | require.Equal(t, tc.err, err) 128 | require.Equal(t, tc.expected, actual) 129 | } 130 | } 131 | 132 | func TestParseTemplate(t *testing.T) { 133 | for _, tc := range []struct { 134 | input []byte 135 | expected Template 136 | err error 137 | }{ 138 | { 139 | // NB no "query.query" field, some data source don't use this. 140 | input: []byte(`{ "type": "query", "query": {} }`), 141 | expected: Template{Type: "query", RawQuery: map[string]interface{}{}}, 142 | }, 143 | } { 144 | var actual Template 145 | err := json.Unmarshal(tc.input, &actual) 146 | require.NoError(t, err) 147 | require.Equal(t, tc.expected, actual) 148 | } 149 | } 150 | -------------------------------------------------------------------------------- /lint/results.go: -------------------------------------------------------------------------------- 1 | package lint 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "sort" 7 | ) 8 | 9 | var ResultSuccess = Result{ 10 | Severity: Success, 11 | Message: "OK", 12 | } 13 | 14 | type Result struct { 15 | Severity Severity 16 | Message string 17 | } 18 | 19 | type FixableResult struct { 20 | Result 21 | Fix func(*Dashboard) // if nil, it cannot be fixed 22 | } 23 | 24 | type RuleResults struct { 25 | Results []FixableResult 26 | } 27 | 28 | type TargetResult struct { 29 | Result 30 | Fix func(Dashboard, Panel, *Target) 31 | } 32 | 33 | type TargetRuleResults struct { 34 | Results []TargetResult 35 | } 36 | 37 | func (r *TargetRuleResults) AddError(d Dashboard, p Panel, t Target, message string) { 38 | r.Results = append(r.Results, TargetResult{ 39 | Result: Result{ 40 | Severity: Error, 41 | Message: fmt.Sprintf("Dashboard '%s', panel '%s', target idx '%d' %s", d.Title, p.Title, t.Idx, message), 42 | }, 43 | }) 44 | } 45 | 46 | type PanelResult struct { 47 | Result 48 | Fix func(Dashboard, *Panel) 49 | } 50 | 51 | type PanelRuleResults struct { 52 | Results []PanelResult 53 | } 54 | 55 | func (r *PanelRuleResults) AddError(d Dashboard, p Panel, message string) { 56 | msg := fmt.Sprintf("Dashboard '%s', panel '%s' %s", d.Title, p.Title, message) 57 | if p.Title == "" { 58 | msg = fmt.Sprintf("Dashboard '%s', panel with id '%d' %s", d.Title, p.Id, message) 59 | } 60 | 61 | r.Results = append(r.Results, PanelResult{ 62 | Result: Result{ 63 | Severity: Error, 64 | Message: msg, 65 | }, 66 | }) 67 | } 68 | 69 | type DashboardResult struct { 70 | Result 71 | Fix func(*Dashboard) 72 | } 73 | 74 | type DashboardRuleResults struct { 75 | Results []DashboardResult 76 | } 77 | 78 | func dashboardMessage(d Dashboard, message string) string { 79 | return fmt.Sprintf("Dashboard '%s' %s", d.Title, message) 80 | } 81 | 82 | func (r *DashboardRuleResults) AddError(d Dashboard, message string) { 83 | r.Results = append(r.Results, DashboardResult{ 84 | Result: Result{ 85 | Severity: Error, 86 | Message: dashboardMessage(d, message), 87 | }, 88 | }) 89 | } 90 | 91 | func (r *DashboardRuleResults) AddFixableError(d Dashboard, message string, fix func(*Dashboard)) { 92 | r.Results = append(r.Results, DashboardResult{ 93 | Result: Result{ 94 | Severity: Error, 95 | Message: dashboardMessage(d, message), 96 | }, 97 | Fix: fix, 98 | }) 99 | } 100 | 101 | func (r *DashboardRuleResults) AddWarning(d Dashboard, message string) { 102 | r.Results = append(r.Results, DashboardResult{ 103 | Result: Result{ 104 | Severity: Warning, 105 | Message: dashboardMessage(d, message), 106 | }, 107 | }) 108 | } 109 | 110 | // ResultContext is used by ResultSet to keep all the state data about a lint execution and it's results. 111 | type ResultContext struct { 112 | Result RuleResults 113 | Rule Rule 114 | Dashboard *Dashboard 115 | Panel *Panel 116 | Target *Target 117 | } 118 | 119 | func (r Result) TtyPrint() { 120 | var Reset = "\033[0m" 121 | var Red = "\033[31m" 122 | var Green = "\033[32m" 123 | var Yellow = "\033[33m" 124 | var Orange = "\033[38;5;208m" 125 | var sym string 126 | switch s := r.Severity; s { 127 | case Success: 128 | sym = Green + "✔️" + Reset 129 | case Fixed: 130 | sym = Orange + "🛠️ (fixed)" + Reset 131 | case Exclude: 132 | sym = "➖" 133 | case Warning: 134 | sym = Yellow + "⚠️" + Reset 135 | case Error: 136 | sym = Red + "❌" + Reset 137 | case Quiet: 138 | return 139 | } 140 | 141 | fmt.Fprintf(os.Stdout, "[%s] %s\n", sym, r.Message) 142 | } 143 | 144 | type ResultSet struct { 145 | results []ResultContext 146 | config *ConfigurationFile 147 | } 148 | 149 | // Configure adds, and applies the provided configuration to all results currently in the ResultSet 150 | func (rs *ResultSet) Configure(c *ConfigurationFile) { 151 | rs.config = c 152 | for i := range rs.results { 153 | rs.results[i] = rs.config.Apply(rs.results[i]) 154 | } 155 | } 156 | 157 | // AddResult adds a result to the ResultSet, applying the current configuration if set 158 | func (rs *ResultSet) AddResult(r ResultContext) { 159 | if rs.config != nil { 160 | r = rs.config.Apply(r) 161 | } 162 | rs.results = append(rs.results, r) 163 | } 164 | 165 | func (rs *ResultSet) MaximumSeverity() Severity { 166 | retVal := Success 167 | for _, res := range rs.results { 168 | for _, r := range res.Result.Results { 169 | if r.Severity > retVal { 170 | retVal = r.Severity 171 | } 172 | } 173 | } 174 | return retVal 175 | } 176 | 177 | func (rs *ResultSet) ByRule() map[string][]ResultContext { 178 | ret := make(map[string][]ResultContext) 179 | for _, res := range rs.results { 180 | ret[res.Rule.Name()] = append(ret[res.Rule.Name()], res) 181 | } 182 | for _, rule := range ret { 183 | sort.SliceStable(rule, func(i, j int) bool { 184 | return rule[i].Dashboard.Title < rule[j].Dashboard.Title 185 | }) 186 | } 187 | return ret 188 | } 189 | 190 | func (rs *ResultSet) ReportByRule() { 191 | byRule := rs.ByRule() 192 | rules := make([]string, 0, len(byRule)) 193 | for r := range byRule { 194 | rules = append(rules, r) 195 | } 196 | sort.Strings(rules) 197 | 198 | for _, rule := range rules { 199 | fmt.Fprintln(os.Stdout, byRule[rule][0].Rule.Description()) 200 | for _, rr := range byRule[rule] { 201 | for _, r := range rr.Result.Results { 202 | if r.Severity == Exclude && !rs.config.Verbose { 203 | continue 204 | } 205 | r.TtyPrint() 206 | } 207 | } 208 | } 209 | } 210 | 211 | func (rs *ResultSet) AutoFix(d *Dashboard) int { 212 | changes := 0 213 | for _, r := range rs.results { 214 | for i, fixableResult := range r.Result.Results { 215 | if fixableResult.Fix != nil { 216 | // Fix is only present when something can be fixed 217 | fixableResult.Fix(d) 218 | changes++ 219 | r.Result.Results[i].Result.Severity = Fixed 220 | } 221 | } 222 | } 223 | return changes 224 | } 225 | -------------------------------------------------------------------------------- /lint/rule_panel_datasource.go: -------------------------------------------------------------------------------- 1 | package lint 2 | 3 | import ( 4 | "fmt" 5 | ) 6 | 7 | func NewPanelDatasourceRule() *PanelRuleFunc { 8 | return &PanelRuleFunc{ 9 | name: "panel-datasource-rule", 10 | description: "Checks that each panel uses the templated datasource.", 11 | fn: func(d Dashboard, p Panel) PanelRuleResults { 12 | r := PanelRuleResults{} 13 | 14 | switch p.Type { 15 | case panelTypeSingleStat, panelTypeGraph, panelTypeTimeTable, panelTypeTimeSeries: 16 | // That a templated datasource exists, is the responsibility of another rule. 17 | templatedDs := d.GetTemplateByType("datasource") 18 | availableDsUids := make(map[string]struct{}, len(templatedDs)*2) 19 | for _, tds := range templatedDs { 20 | availableDsUids[fmt.Sprintf("$%s", tds.Name)] = struct{}{} 21 | availableDsUids[fmt.Sprintf("${%s}", tds.Name)] = struct{}{} 22 | } 23 | 24 | src, err := p.GetDataSource() 25 | if err != nil { 26 | r.AddError(d, p, fmt.Sprintf("has invalid datasource: %v'", err)) 27 | } 28 | _, ok := availableDsUids[string(src.UID)] 29 | if !ok { 30 | r.AddError(d, p, fmt.Sprintf("does not use a templated datasource, uses '%s'", src.UID)) 31 | } 32 | } 33 | 34 | return r 35 | }, 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /lint/rule_panel_datasource_test.go: -------------------------------------------------------------------------------- 1 | package lint 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/require" 7 | ) 8 | 9 | func TestPanelDatasource(t *testing.T) { 10 | linter := NewPanelDatasourceRule() 11 | 12 | for _, tc := range []struct { 13 | result Result 14 | panel Panel 15 | templates []Template 16 | }{ 17 | { 18 | result: Result{ 19 | Severity: Error, 20 | Message: "Dashboard 'test', panel 'bar' does not use a templated datasource, uses 'foo'", 21 | }, 22 | panel: Panel{ 23 | Type: "singlestat", 24 | Datasource: "foo", 25 | Title: "bar", 26 | }, 27 | }, 28 | { 29 | result: ResultSuccess, 30 | panel: Panel{ 31 | Type: "singlestat", 32 | Datasource: "$datasource", 33 | }, 34 | templates: []Template{ 35 | { 36 | Type: "datasource", 37 | Name: "datasource", 38 | }, 39 | }, 40 | }, 41 | { 42 | result: ResultSuccess, 43 | panel: Panel{ 44 | Type: "singlestat", 45 | Datasource: "${datasource}", 46 | }, 47 | templates: []Template{ 48 | { 49 | Type: "datasource", 50 | Name: "datasource", 51 | }, 52 | }, 53 | }, 54 | { 55 | result: ResultSuccess, 56 | panel: Panel{ 57 | Type: "singlestat", 58 | Datasource: "$prometheus_datasource", 59 | }, 60 | templates: []Template{ 61 | { 62 | Type: "datasource", 63 | Name: "prometheus_datasource", 64 | }, 65 | }, 66 | }, 67 | { 68 | result: ResultSuccess, 69 | panel: Panel{ 70 | Type: "singlestat", 71 | Datasource: "${prometheus_datasource}", 72 | }, 73 | templates: []Template{ 74 | { 75 | Type: "datasource", 76 | Name: "prometheus_datasource", 77 | }, 78 | }, 79 | }, 80 | } { 81 | testRule(t, linter, Dashboard{ 82 | Title: "test", 83 | Panels: []Panel{tc.panel}, 84 | Templating: struct { 85 | List []Template "json:\"list\"" 86 | }{List: tc.templates}, 87 | }, tc.result) 88 | } 89 | } 90 | 91 | // testRule is a small helper that tests a lint rule and expects it to only return 92 | // a single result. 93 | func testRule(t *testing.T, rule Rule, d Dashboard, result Result) { 94 | testRuleWithAutofix(t, rule, &d, []Result{result}, false) 95 | } 96 | func testMultiResultRule(t *testing.T, rule Rule, d Dashboard, result []Result) { 97 | testRuleWithAutofix(t, rule, &d, result, false) 98 | } 99 | 100 | func testRuleWithAutofix(t *testing.T, rule Rule, d *Dashboard, result []Result, autofix bool) { 101 | rs := ResultSet{} 102 | rule.Lint(*d, &rs) 103 | if autofix { 104 | rs.AutoFix(d) 105 | } 106 | require.Len(t, rs.results, 1) 107 | actual := rs.results[0].Result 108 | if actual.Results[0].Severity == Quiet { 109 | // all test cases expect success 110 | actual.Results[0].Severity = Success 111 | } 112 | rr := make([]Result, len(actual.Results)) 113 | for i, r := range actual.Results { 114 | rr[i] = r.Result 115 | } 116 | 117 | require.Equal(t, result, rr) 118 | } 119 | -------------------------------------------------------------------------------- /lint/rule_panel_no_targets.go: -------------------------------------------------------------------------------- 1 | package lint 2 | 3 | func NewPanelNoTargetsRule() *PanelRuleFunc { 4 | return &PanelRuleFunc{ 5 | name: "panel-no-targets-rule", 6 | description: "Checks that each panel has at least one target.", 7 | fn: func(d Dashboard, p Panel) PanelRuleResults { 8 | r := PanelRuleResults{} 9 | switch p.Type { 10 | case panelTypeStat, panelTypeSingleStat, panelTypeGraph, panelTypeTimeTable, panelTypeTimeSeries, panelTypeGauge: 11 | if p.Targets != nil { 12 | return r 13 | } 14 | 15 | r.AddError(d, p, "has no targets") 16 | } 17 | return r 18 | }, 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /lint/rule_panel_no_targets_test.go: -------------------------------------------------------------------------------- 1 | package lint 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestPanelNoTargets(t *testing.T) { 8 | linter := NewPanelNoTargetsRule() 9 | 10 | for _, tc := range []struct { 11 | result Result 12 | panel Panel 13 | }{ 14 | { 15 | result: Result{ 16 | Severity: Error, 17 | Message: "Dashboard 'test', panel 'bar' has no targets", 18 | }, 19 | panel: Panel{ 20 | Type: "singlestat", 21 | Datasource: "foo", 22 | Title: "bar", 23 | }, 24 | }, 25 | { 26 | result: ResultSuccess, 27 | panel: Panel{ 28 | Type: "singlestat", 29 | Datasource: "foo", 30 | Title: "bar", 31 | Targets: []Target{ 32 | { 33 | Expr: `sum(rate(foo[5m]))`, 34 | }, 35 | }, 36 | }, 37 | }, 38 | } { 39 | testRule(t, linter, Dashboard{Title: "test", Panels: []Panel{tc.panel}}, tc.result) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /lint/rule_panel_title_description.go: -------------------------------------------------------------------------------- 1 | package lint 2 | 3 | import "fmt" 4 | 5 | func NewPanelTitleDescriptionRule() *PanelRuleFunc { 6 | return &PanelRuleFunc{ 7 | name: "panel-title-description-rule", 8 | description: "Checks that each panel has a title and description.", 9 | fn: func(d Dashboard, p Panel) PanelRuleResults { 10 | r := PanelRuleResults{} 11 | switch p.Type { 12 | case panelTypeStat, panelTypeSingleStat, panelTypeGraph, panelTypeTimeTable, panelTypeTimeSeries, panelTypeGauge: 13 | if len(p.Title) == 0 || len(p.Description) == 0 { 14 | r.AddError(d, p, fmt.Sprintf("has missing title or description, currently has title '%s' and description: '%s'", p.Title, p.Description)) 15 | } 16 | } 17 | return r 18 | }, 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /lint/rule_panel_title_description_test.go: -------------------------------------------------------------------------------- 1 | package lint 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestPanelTitleDescription(t *testing.T) { 8 | linter := NewPanelTitleDescriptionRule() 9 | 10 | for _, tc := range []struct { 11 | result Result 12 | panel Panel 13 | }{ 14 | { 15 | result: Result{ 16 | Severity: Error, 17 | Message: "Dashboard 'test', panel with id '1' has missing title or description, currently has title '' and description: ''", 18 | }, 19 | panel: Panel{ 20 | Type: "singlestat", 21 | Id: 1, 22 | Title: "", 23 | Description: "", 24 | }, 25 | }, 26 | { 27 | result: Result{ 28 | Severity: Error, 29 | Message: "Dashboard 'test', panel 'title' has missing title or description, currently has title 'title' and description: ''", 30 | }, 31 | panel: Panel{ 32 | Type: "singlestat", 33 | Id: 2, 34 | Title: "title", 35 | Description: "", 36 | }, 37 | }, 38 | { 39 | result: Result{ 40 | Severity: Error, 41 | Message: "Dashboard 'test', panel with id '3' has missing title or description, currently has title '' and description: 'description'", 42 | }, 43 | panel: Panel{ 44 | Type: "singlestat", 45 | Id: 3, 46 | Title: "", 47 | Description: "description", 48 | }, 49 | }, 50 | { 51 | result: ResultSuccess, 52 | panel: Panel{ 53 | Type: "singlestat", 54 | Id: 1, 55 | Datasource: "foo", 56 | Title: "testpanel", 57 | Description: "testdescription", 58 | }, 59 | }, 60 | } { 61 | testRule(t, linter, Dashboard{Title: "test", Panels: []Panel{tc.panel}}, tc.result) 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /lint/rule_panel_units.go: -------------------------------------------------------------------------------- 1 | package lint 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | ) 7 | 8 | func NewPanelUnitsRule() *PanelRuleFunc { 9 | validUnits := []string{ 10 | // Enumerated from: https://github.com/grafana/grafana/blob/main/packages/grafana-data/src/valueFormats/categories.ts 11 | // Scalar, e.g. number of loaded classes 12 | "none", 13 | // Misc 14 | "string", 15 | // short 16 | "short", "percent", "percentunit", "humidity", "dB", "hex0x", "hex", "sci", "locale", "pixel", 17 | // Acceleration 18 | "accMS2", "accFS2", "accG", 19 | // Angle 20 | "degree", "radian", "grad", "arcmin", "arcsec", 21 | // Area 22 | "areaM2", "areaF2", "areaMI2", 23 | // Computation 24 | "flops", "mflops", "gflops", "tflops", "pflops", "eflops", "zflops", "yflops", 25 | // Concentration 26 | "ppm", "conppb", "conngm3", "conngNm3", "conμgm3", "conμgNm3", "conmgm3", "conmgNm3", "congm3", "congNm3", "conmgdL", "conmmolL", 27 | // Currency 28 | "currencyUSD", "currencyGBP", "currencyEUR", "currencyJPY", "currencyRUB", "currencyUAH", "currencyBRL", "currencyDKK", "currencyISK", "currencyNOK", "currencySEK", "currencyCZK", "currencyCHF", "currencyPLN", "currencyBTC", "currencymBTC", "currencyμBTC", "currencyZAR", "currencyINR", "currencyKRW", "currencyIDR", "currencyPHP", "currencyVND", 29 | // Data 30 | "bytes", "decbytes", "bits", "decbits", "kbytes", "deckbytes", "mbytes", "decmbytes", "gbytes", "decgbytes", "tbytes", "dectbytes", "pbytes", "decpbytes", 31 | // Data rate 32 | "pps", "binBps", "Bps", "binbps", "bps", "KiBs", "Kibits", "KBs", "Kbits", "MiBs", "Mibits", "MBs", "Mbits", "GiBs", "Gibits", "GBs", "Gbits", "TiBs", "Tibits", "TBs", "Tbits", "PiBs", "Pibits", "PBs", "Pbits", 33 | // Date & time 34 | "dateTimeAsIso", "dateTimeAsIsoNoDateIfToday", "dateTimeAsUS", "dateTimeAsUSNoDateIfToday", "dateTimeAsLocal", 35 | // Datetime local (No date if today) 36 | "dateTimeAsLocalNoDateIfToday", "dateTimeAsSystem", "dateTimeFromNow", 37 | // Energy 38 | "watt", "kwatt", "megwatt", "gwatt", "mwatt", "Wm2", "voltamp", "kvoltamp", "voltampreact", "kvoltampreact", "watth", "watthperkg", "kwatth", "kwattm", "amph", "kamph", "mamph", "joule", "ev", "amp", "kamp", "mamp", "volt", "kvolt", "mvolt", "dBm", "ohm", "kohm", "Mohm", "farad", "µfarad", "nfarad", "pfarad", "ffarad", "henry", "mhenry", "µhenry", "lumens", 39 | // Flow 40 | "flowgpm", "flowcms", "flowcfs", "flowcfm", "litreh", "flowlpm", "flowmlpm", "lux", 41 | // Force 42 | "forceNm", "forcekNm", "forceN", "forcekN", 43 | // Hash rate 44 | "Hs", "KHs", "MHs", "GHs", "THs", "PHs", "EHs", 45 | // Mass 46 | "massmg", "massg", "masslb", "masskg", "masst", 47 | // Length 48 | "lengthmm", "lengthin", "lengthft", "lengthm", "lengthkm", "lengthmi", 49 | // Pressure 50 | "pressurembar", "pressurebar", "pressurekbar", "pressurepa", "pressurehpa", "pressurekpa", "pressurehg", "pressurepsi", 51 | // Radiation 52 | "radbq", "radci", "radgy", "radrad", "radsv", "radmsv", "radusv", "radrem", "radexpckg", "radr", "radsvh", "radmsvh", "radusvh", 53 | // Rotational Speed 54 | "rotrpm", "rothz", "rotrads", "rotdegs", 55 | // Temperature 56 | "celsius", "fahrenheit", "kelvin", 57 | // Time 58 | "hertz", "ns", "µs", "ms", "s", "m", "h", "d", "dtdurationms", "dtdurations", "dthms", "dtdhms", "timeticks", "clockms", "clocks", 59 | // Throughput 60 | "cps", "ops", "reqps", "rps", "wps", "iops", "cpm", "opm", "rpm", "wpm", "mps", "mpm", 61 | // Velocity 62 | "velocityms", "velocitykmh", "velocitymph", "velocityknot", 63 | // Volume 64 | "mlitre", "litre", "m3", "Nm3", "dm3", "gallons", 65 | // Boolean 66 | "bool", "bool_yes_no", "bool_on_off", 67 | } 68 | 69 | return &PanelRuleFunc{ 70 | name: "panel-units-rule", 71 | description: "Checks that each panel uses has valid units defined.", 72 | fn: func(d Dashboard, p Panel) PanelRuleResults { 73 | r := PanelRuleResults{} 74 | switch p.Type { 75 | case panelTypeStat, panelTypeSingleStat, panelTypeGraph, panelTypeTimeTable, panelTypeTimeSeries, panelTypeGauge: 76 | 77 | // ignore if has reduceOptions fields (for stat panels only): 78 | if p.Type == "stat" { 79 | var opts StatOptions 80 | err := json.Unmarshal(p.Options, &opts) 81 | if err == nil && hasReduceOptionsNonNumericFields(&opts.ReduceOptions) { 82 | return r 83 | } 84 | } 85 | 86 | //ignore this rule if has value mappings: 87 | valueMappings, err := getValueMappings(p) 88 | if err != nil { 89 | r.AddError(d, p, err.Error()) 90 | } 91 | if valueMappings != nil { 92 | return r 93 | } 94 | 95 | configuredUnit := getConfiguredUnit(p) 96 | if configuredUnit != "" { 97 | for _, u := range validUnits { 98 | if u == configuredUnit { 99 | return r 100 | } 101 | } 102 | } 103 | r.AddError(d, p, fmt.Sprintf("has no or invalid units defined: '%s'", configuredUnit)) 104 | } 105 | return r 106 | }, 107 | } 108 | } 109 | 110 | func getConfiguredUnit(p Panel) string { 111 | configuredUnit := "" 112 | // First check if an override with unit exists - if no override then check if standard unit is present and valid 113 | if p.FieldConfig != nil && len(p.FieldConfig.Overrides) > 0 { 114 | for _, override := range p.FieldConfig.Overrides { 115 | if len(override.OverrideProperties) > 0 { 116 | for _, o := range override.OverrideProperties { 117 | if o.Id == "unit" { 118 | configuredUnit = o.Value.(string) 119 | } 120 | } 121 | } 122 | } 123 | } 124 | if configuredUnit == "" && p.FieldConfig != nil && p.FieldConfig.Defaults.Unit != "" { 125 | configuredUnit = p.FieldConfig.Defaults.Unit 126 | } 127 | return configuredUnit 128 | } 129 | 130 | func getValueMappings(p Panel) (any, error) { 131 | var valueMappings any 132 | // First check if an override with unit exists - if no override then check if standard unit is present and valid 133 | if p.FieldConfig != nil && len(p.FieldConfig.Overrides) > 0 { 134 | for _, override := range p.FieldConfig.Overrides { 135 | if len(override.OverrideProperties) > 0 { 136 | for _, o := range override.OverrideProperties { 137 | if o.Id == "mappings" && o.Value != nil { 138 | return o.Value, nil 139 | } 140 | } 141 | } 142 | } 143 | } 144 | if p.FieldConfig != nil && p.FieldConfig.Defaults.Mappings != nil { 145 | err := json.Unmarshal(p.FieldConfig.Defaults.Mappings, &valueMappings) 146 | if err != nil { 147 | return valueMappings, err 148 | } 149 | } 150 | return valueMappings, nil 151 | } 152 | 153 | // Numeric fields are set as empty string "". Any other value means nonnumeric on grafana stat panel. 154 | func hasReduceOptionsNonNumericFields(reduceOpts *ReduceOptions) bool { 155 | return reduceOpts.Fields != "" 156 | } 157 | -------------------------------------------------------------------------------- /lint/rule_panel_units_test.go: -------------------------------------------------------------------------------- 1 | package lint 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestPanelUnits(t *testing.T) { 8 | linter := NewPanelUnitsRule() 9 | var overrides = make([]Override, 0) 10 | overrides = append(overrides, Override{ 11 | OverrideProperties: []OverrideProperty{ 12 | { 13 | Id: "mappings", 14 | Value: []byte(`[ 15 | { 16 | "type": "value", 17 | "options": { 18 | "1": { 19 | "text": "OK", 20 | "color": "green", 21 | "index": 0 22 | }, 23 | "2": { 24 | "text": "Problem", 25 | "color": "red", 26 | "index": 1 27 | } 28 | } 29 | } 30 | ]`), 31 | }, 32 | }, 33 | }) 34 | for _, tc := range []struct { 35 | name string 36 | result Result 37 | panel Panel 38 | }{ 39 | { 40 | name: "invalid unit", 41 | result: Result{ 42 | Severity: Error, 43 | Message: "Dashboard 'test', panel 'bar' has no or invalid units defined: 'MyInvalidUnit'", 44 | }, 45 | panel: Panel{ 46 | Type: "singlestat", 47 | Datasource: "foo", 48 | Title: "bar", 49 | FieldConfig: &FieldConfig{ 50 | Defaults: Defaults{ 51 | Unit: "MyInvalidUnit", 52 | }, 53 | }, 54 | }, 55 | }, 56 | { 57 | name: "missing FieldConfig", 58 | result: Result{ 59 | Severity: Error, 60 | Message: "Dashboard 'test', panel 'bar' has no or invalid units defined: ''", 61 | }, 62 | panel: Panel{ 63 | Type: "singlestat", 64 | Datasource: "foo", 65 | Title: "bar", 66 | }, 67 | }, 68 | { 69 | name: "empty FieldConfig", 70 | result: Result{ 71 | Severity: Error, 72 | Message: "Dashboard 'test', panel 'bar' has no or invalid units defined: ''", 73 | }, 74 | panel: Panel{ 75 | Type: "singlestat", 76 | Datasource: "foo", 77 | Title: "bar", 78 | FieldConfig: &FieldConfig{}, 79 | }, 80 | }, 81 | { 82 | name: "valid", 83 | result: ResultSuccess, 84 | panel: Panel{ 85 | Type: "singlestat", 86 | Datasource: "foo", 87 | Title: "bar", 88 | FieldConfig: &FieldConfig{ 89 | Defaults: Defaults{ 90 | Unit: "short", 91 | }, 92 | }, 93 | }, 94 | }, 95 | { 96 | name: "none - scalar", 97 | result: ResultSuccess, 98 | panel: Panel{ 99 | Type: "singlestat", 100 | Datasource: "foo", 101 | Title: "bar", 102 | FieldConfig: &FieldConfig{ 103 | Defaults: Defaults{ 104 | Unit: "none", 105 | }, 106 | }, 107 | }, 108 | }, 109 | { 110 | name: "has nonnumeric reduceOptions fields", 111 | result: ResultSuccess, 112 | panel: Panel{ 113 | Type: "stat", 114 | Datasource: "foo", 115 | Title: "bar", 116 | Options: []byte(` 117 | { 118 | "reduceOptions": { 119 | "fields": "/^version$/" 120 | } 121 | } 122 | 123 | `), 124 | }, 125 | }, 126 | { 127 | name: "has empty reduceOptions fields(Numeric Fields default value)", 128 | result: Result{ 129 | Severity: Error, 130 | Message: "Dashboard 'test', panel 'bar' has no or invalid units defined: ''", 131 | }, 132 | panel: Panel{ 133 | Type: "stat", 134 | Datasource: "foo", 135 | Title: "bar", 136 | Options: []byte(` 137 | { 138 | "reduceOptions": { 139 | "fields": "" 140 | } 141 | } 142 | 143 | `), 144 | }, 145 | }, 146 | { 147 | name: "no units but have value mappings", 148 | result: ResultSuccess, 149 | panel: Panel{ 150 | Type: "singlestat", 151 | Datasource: "foo", 152 | Title: "bar", 153 | FieldConfig: &FieldConfig{ 154 | Defaults: Defaults{ 155 | Mappings: []byte(` 156 | [ 157 | { 158 | "options": { 159 | "0": { 160 | "color": "red", 161 | "index": 1, 162 | "text": "DOWN" 163 | }, 164 | "1": { 165 | "color": "green", 166 | "index": 0, 167 | "text": "UP" 168 | } 169 | }, 170 | "type": "value" 171 | } 172 | ]`, 173 | ), 174 | }, 175 | }, 176 | }, 177 | }, 178 | { 179 | name: "no units but have value mappings in overrides", 180 | result: ResultSuccess, 181 | panel: Panel{ 182 | Type: "singlestat", 183 | Datasource: "foo", 184 | Title: "bar", 185 | FieldConfig: &FieldConfig{ 186 | Overrides: overrides, 187 | }, 188 | }, 189 | }, 190 | } { 191 | t.Run(tc.name, func(t *testing.T) { 192 | testRule(t, linter, Dashboard{Title: "test", Panels: []Panel{tc.panel}}, tc.result) 193 | }) 194 | } 195 | } 196 | -------------------------------------------------------------------------------- /lint/rule_target_counter_agg.go: -------------------------------------------------------------------------------- 1 | package lint 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/prometheus/prometheus/promql/parser" 8 | ) 9 | 10 | func NewTargetCounterAggRule() *TargetRuleFunc { 11 | return &TargetRuleFunc{ 12 | name: "target-counter-agg-rule", 13 | description: "Checks that any counter metric (ending in _total) is aggregated with rate, irate, or increase.", 14 | fn: func(d Dashboard, p Panel, t Target) TargetRuleResults { 15 | r := TargetRuleResults{} 16 | expr, err := parsePromQL(t.Expr, d.Templating.List) 17 | if err != nil { 18 | // Invalid PromQL is another rule 19 | return r 20 | } 21 | 22 | err = parser.Walk(newInspector(), expr, nil) 23 | if err != nil { 24 | r.AddError(d, p, t, err.Error()) 25 | } 26 | return r 27 | }, 28 | } 29 | } 30 | 31 | func newInspector() inspector { 32 | return func(node parser.Node, parents []parser.Node) error { 33 | // We're looking for either a VectorSelector. This skips any other node type. 34 | selector, ok := node.(*parser.VectorSelector) 35 | if !ok { 36 | return nil 37 | } 38 | 39 | errmsg := fmt.Errorf("counter metric '%s' is not aggregated with rate, irate, or increase", node.String()) 40 | 41 | if strings.HasSuffix(selector.String(), "_total") { 42 | // The vector selector must have (at least) two parents 43 | if len(parents) < 2 { 44 | return errmsg 45 | } 46 | // The vector must be ranged 47 | _, ok := parents[len(parents)-1].(*parser.MatrixSelector) 48 | if !ok { 49 | return errmsg 50 | } 51 | // The range, must be in a function call 52 | call, ok := parents[len(parents)-2].(*parser.Call) 53 | if !ok { 54 | return errmsg 55 | } 56 | // Finally, the immediate ancestor call must be rate, irate, or increase 57 | if call.Func.Name != "rate" && call.Func.Name != "irate" && call.Func.Name != "increase" { 58 | return errmsg 59 | } 60 | } 61 | return nil 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /lint/rule_target_counter_agg_test.go: -------------------------------------------------------------------------------- 1 | package lint 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestTargetCounterAggRule(t *testing.T) { 8 | linter := NewTargetCounterAggRule() 9 | 10 | for _, tc := range []struct { 11 | result Result 12 | panel Panel 13 | }{ 14 | // Non aggregated counter fails 15 | { 16 | result: Result{ 17 | Severity: Error, 18 | Message: "Dashboard 'dashboard', panel 'panel', target idx '0' counter metric 'something_total' is not aggregated with rate, irate, or increase", 19 | }, 20 | panel: Panel{ 21 | Title: "panel", 22 | Datasource: "foo", 23 | Targets: []Target{ 24 | { 25 | Expr: `something_total`, 26 | }, 27 | }, 28 | }, 29 | }, 30 | // Weird matrix selector without an aggregator 31 | { 32 | result: Result{ 33 | Severity: Error, 34 | Message: "Dashboard 'dashboard', panel 'panel', target idx '0' counter metric 'something_total' is not aggregated with rate, irate, or increase", 35 | }, 36 | panel: Panel{ 37 | Title: "panel", 38 | Datasource: "foo", 39 | Targets: []Target{ 40 | { 41 | Expr: `something_total[$__rate_interval]`, 42 | }, 43 | }, 44 | }, 45 | }, 46 | // Single aggregated counter is good 47 | { 48 | result: ResultSuccess, 49 | panel: Panel{ 50 | Title: "panel", 51 | Datasource: "foo", 52 | Targets: []Target{ 53 | { 54 | Expr: `increase(something_total[$__rate_interval])`, 55 | }, 56 | }, 57 | }, 58 | }, 59 | // Sanity check for multiple counters in one query, with the first one failing 60 | { 61 | result: Result{ 62 | Severity: Error, 63 | Message: "Dashboard 'dashboard', panel 'panel', target idx '0' counter metric 'something_total' is not aggregated with rate, irate, or increase", 64 | }, 65 | panel: Panel{ 66 | Title: "panel", 67 | Datasource: "foo", 68 | Targets: []Target{ 69 | { 70 | Expr: `something_total / rate(somethingelse_total[$__rate_interval])`, 71 | }, 72 | }, 73 | }, 74 | }, 75 | // Sanity check for multiple counters in one query, with the second one failing 76 | { 77 | result: Result{ 78 | Severity: Error, 79 | Message: "Dashboard 'dashboard', panel 'panel', target idx '0' counter metric 'somethingelse_total' is not aggregated with rate, irate, or increase", 80 | }, 81 | panel: Panel{ 82 | Title: "panel", 83 | Datasource: "foo", 84 | Targets: []Target{ 85 | { 86 | Expr: `rate(something_total[$__rate_interval]) / somethingelse_total`, 87 | }, 88 | }, 89 | }, 90 | }, 91 | } { 92 | dashboard := Dashboard{ 93 | Title: "dashboard", 94 | Templating: struct { 95 | List []Template "json:\"list\"" 96 | }{List: []Template{}}, 97 | Panels: []Panel{tc.panel}, 98 | } 99 | 100 | testRule(t, linter, dashboard, tc.result) 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /lint/rule_target_job_instance.go: -------------------------------------------------------------------------------- 1 | package lint 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/prometheus/prometheus/model/labels" 7 | "github.com/prometheus/prometheus/promql/parser" 8 | ) 9 | 10 | func newTargetRequiredMatcherRule(matcher string) *TargetRuleFunc { 11 | return &TargetRuleFunc{ 12 | name: fmt.Sprintf("target-%s-rule", matcher), 13 | description: fmt.Sprintf("Checks that every PromQL query has a %s matcher.", matcher), 14 | fn: func(d Dashboard, p Panel, t Target) TargetRuleResults { 15 | r := TargetRuleResults{} 16 | // TODO: The RuleSet should be responsible for routing rule checks based on their query type (prometheus, loki, mysql, etc) 17 | // and for ensuring that the datasource is set. 18 | if t := getTemplateDatasource(d); t == nil || t.Query != Prometheus { 19 | // Missing template datasource is a separate rule. 20 | // Non prometheus datasources don't have rules yet 21 | return r 22 | } 23 | 24 | node, err := parsePromQL(t.Expr, d.Templating.List) 25 | if err != nil { 26 | // Invalid PromQL is another rule 27 | return r 28 | } 29 | 30 | for _, selector := range parser.ExtractSelectors(node) { 31 | if err := checkForMatcher(selector, matcher, labels.MatchRegexp, fmt.Sprintf("$%s", matcher)); err != nil { 32 | r.AddError(d, p, t, fmt.Sprintf("invalid PromQL query '%s': %v", t.Expr, err)) 33 | } 34 | } 35 | 36 | return r 37 | }, 38 | } 39 | } 40 | 41 | func NewTargetJobRule() *TargetRuleFunc { 42 | return newTargetRequiredMatcherRule("job") 43 | } 44 | 45 | func NewTargetInstanceRule() *TargetRuleFunc { 46 | return newTargetRequiredMatcherRule("instance") 47 | } 48 | -------------------------------------------------------------------------------- /lint/rule_target_job_instance_test.go: -------------------------------------------------------------------------------- 1 | package lint 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | ) 7 | 8 | func testTargetRequiredMatcherRule(t *testing.T, matcher string) { 9 | var linter *TargetRuleFunc 10 | 11 | switch matcher { 12 | case "job": 13 | linter = NewTargetJobRule() 14 | case "instance": 15 | linter = NewTargetInstanceRule() 16 | default: 17 | t.Errorf("No concrete target required matcher rule for '%s", matcher) 18 | return 19 | } 20 | 21 | for _, tc := range []struct { 22 | result Result 23 | target Target 24 | }{ 25 | // Happy path 26 | { 27 | result: ResultSuccess, 28 | target: Target{ 29 | Expr: fmt.Sprintf(`sum(rate(foo{%s=~"$%s"}[5m]))`, matcher, matcher), 30 | }, 31 | }, 32 | // Happy path (multiple matchers where at least one matches) 33 | { 34 | result: ResultSuccess, 35 | target: Target{ 36 | Expr: fmt.Sprintf(`sum(rate(foo{%s="integrations/bar", %s=~"$%s"}[5m]))`, matcher, matcher, matcher), 37 | }, 38 | }, 39 | { 40 | result: ResultSuccess, 41 | target: Target{ 42 | Expr: fmt.Sprintf(`sum(rate(foo{%s=~"$%s", %s="integrations/bar"}[5m]))`, matcher, matcher, matcher), 43 | }, 44 | }, 45 | // Also happy when the promql is invalid 46 | { 47 | result: ResultSuccess, 48 | target: Target{ 49 | Expr: `foo(bar.baz))`, 50 | }, 51 | }, 52 | // Missing matcher 53 | { 54 | result: Result{ 55 | Severity: Error, 56 | Message: fmt.Sprintf("Dashboard 'dashboard', panel 'panel', target idx '0' invalid PromQL query 'sum(rate(foo[5m]))': %s selector not found", matcher), 57 | }, 58 | target: Target{ 59 | Expr: `sum(rate(foo[5m]))`, 60 | }, 61 | }, 62 | // Not a regex matcher 63 | { 64 | result: Result{ 65 | Severity: Error, 66 | Message: fmt.Sprintf("Dashboard 'dashboard', panel 'panel', target idx '0' invalid PromQL query 'sum(rate(foo{%s=\"$%s\"}[5m]))': %s selector is =, not =~", matcher, matcher, matcher), 67 | }, 68 | target: Target{ 69 | Expr: fmt.Sprintf(`sum(rate(foo{%s="$%s"}[5m]))`, matcher, matcher), 70 | }, 71 | }, 72 | // Wrong template variable 73 | { 74 | result: Result{ 75 | Severity: Error, 76 | Message: fmt.Sprintf("Dashboard 'dashboard', panel 'panel', target idx '0' invalid PromQL query 'sum(rate(foo{%s=~\"$foo\"}[5m]))': %s selector is $foo, not $%s", matcher, matcher, matcher), 77 | }, 78 | target: Target{ 79 | Expr: fmt.Sprintf(`sum(rate(foo{%s=~"$foo"}[5m]))`, matcher), 80 | }, 81 | }, 82 | } { 83 | dashboard := Dashboard{ 84 | Title: "dashboard", 85 | Templating: struct { 86 | List []Template `json:"list"` 87 | }{ 88 | List: []Template{ 89 | { 90 | Type: "datasource", 91 | Query: "prometheus", 92 | }, 93 | }, 94 | }, 95 | Panels: []Panel{ 96 | { 97 | Title: "panel", 98 | Type: "singlestat", 99 | Targets: []Target{tc.target}, 100 | }, 101 | }, 102 | } 103 | 104 | testRule(t, linter, dashboard, tc.result) 105 | } 106 | } 107 | 108 | func TestTargetJobInstanceRule(t *testing.T) { 109 | testTargetRequiredMatcherRule(t, "job") 110 | testTargetRequiredMatcherRule(t, "instance") 111 | } 112 | -------------------------------------------------------------------------------- /lint/rule_target_logql.go: -------------------------------------------------------------------------------- 1 | package lint 2 | 3 | import ( 4 | "fmt" 5 | ) 6 | 7 | func NewTargetLogQLRule() *TargetRuleFunc { 8 | return &TargetRuleFunc{ 9 | name: "target-logql-rule", 10 | description: "Checks that each target uses a valid LogQL query.", 11 | fn: func(d Dashboard, p Panel, t Target) TargetRuleResults { 12 | r := TargetRuleResults{} 13 | 14 | // Skip hidden targets 15 | if t.Hide { 16 | return r 17 | } 18 | 19 | // Check if the datasource is Loki 20 | isLoki := false 21 | if templateDS := getTemplateDatasource(d); templateDS != nil && templateDS.Query == Loki { 22 | isLoki = true 23 | } else if ds, err := t.GetDataSource(); err == nil && ds.Type == Loki { 24 | isLoki = true 25 | } 26 | 27 | // skip if the datasource is not Loki 28 | if !isLoki { 29 | return r 30 | } 31 | 32 | if !panelHasQueries(p) { 33 | return r 34 | } 35 | 36 | // If panel does not contain an expression then check if it references another panel and it exists 37 | if len(t.Expr) == 0 { 38 | if t.PanelId > 0 { 39 | for _, p1 := range d.Panels { 40 | if p1.Id == t.PanelId { 41 | return r 42 | } 43 | } 44 | r.AddError(d, p, t, "Invalid panel reference in target") 45 | } 46 | return r 47 | } 48 | 49 | // Parse the LogQL query 50 | _, err := parseLogQL(t.Expr, d.Templating.List) 51 | if err != nil { 52 | r.AddError(d, p, t, fmt.Sprintf("invalid LogQL query '%s': %v", t.Expr, err)) 53 | return r 54 | } 55 | 56 | return r 57 | }, 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /lint/rule_target_logql_auto.go: -------------------------------------------------------------------------------- 1 | package lint 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | "time" 7 | 8 | "github.com/grafana/loki/v3/pkg/logql/syntax" 9 | ) 10 | 11 | func parseLogQL(expr string, variables []Template) (syntax.Expr, error) { 12 | expr, err := expandLogQLVariables(expr, variables) 13 | if err != nil { 14 | return nil, fmt.Errorf("could not expand variables: %w", err) 15 | } 16 | return syntax.ParseExpr(expr) 17 | } 18 | 19 | func NewTargetLogQLAutoRule() *TargetRuleFunc { 20 | autoDuration, err := time.ParseDuration(globalVariables["__auto"].(string)) 21 | if err != nil { 22 | panic(err) 23 | } 24 | 25 | return &TargetRuleFunc{ 26 | name: "target-logql-auto-rule", 27 | description: "Checks that each Loki target uses $__auto for range vectors when appropriate.", 28 | fn: func(d Dashboard, p Panel, t Target) TargetRuleResults { 29 | r := TargetRuleResults{} 30 | 31 | // skip hidden targets 32 | if t.Hide { 33 | return r 34 | } 35 | 36 | // check if the datasource is Loki 37 | isLoki := false 38 | if templateDS := getTemplateDatasource(d); templateDS != nil && templateDS.Query == Loki { 39 | isLoki = true 40 | } else if ds, err := t.GetDataSource(); err == nil && ds.Type == Loki { 41 | isLoki = true 42 | } 43 | 44 | // skip if the datasource is not Loki 45 | if !isLoki { 46 | return r 47 | } 48 | 49 | // skip if the panel does not have queries 50 | if !panelHasQueries(p) { 51 | return r 52 | } 53 | 54 | parsedExpr, err := parseLogQL(t.Expr, d.Templating.List) 55 | if err != nil { 56 | r.AddError(d, p, t, fmt.Sprintf("Invalid LogQL query: %v", err)) 57 | return r 58 | } 59 | 60 | originalExpr := t.Expr 61 | 62 | hasFixedDuration := false 63 | 64 | // Inspect the parsed expression to check for fixed durations 65 | Inspect(parsedExpr, func(node syntax.Expr) bool { 66 | if logRange, ok := node.(*syntax.LogRange); ok { 67 | if logRange.Interval != autoDuration && !strings.Contains(originalExpr, "$__auto") { 68 | hasFixedDuration = true 69 | return false 70 | } 71 | } 72 | return true 73 | }) 74 | 75 | if hasFixedDuration { 76 | r.AddError(d, p, t, "LogQL query uses fixed duration: should use $__auto") 77 | } 78 | 79 | return r 80 | }, 81 | } 82 | } 83 | 84 | func Inspect(node syntax.Expr, f func(syntax.Expr) bool) { 85 | if node == nil || !f(node) { 86 | return 87 | } 88 | switch n := node.(type) { 89 | case *syntax.BinOpExpr: 90 | Inspect(n.SampleExpr, f) 91 | Inspect(n.RHS, f) 92 | case *syntax.RangeAggregationExpr: 93 | Inspect(n.Left, f) 94 | case *syntax.VectorAggregationExpr: 95 | Inspect(n.Left, f) 96 | case *syntax.LabelReplaceExpr: 97 | Inspect(n.Left, f) 98 | case *syntax.LogRange: 99 | Inspect(n.Left, f) 100 | case *syntax.PipelineExpr: 101 | Inspect(n.Left, f) 102 | for _, stage := range n.MultiStages { 103 | f(stage) 104 | } 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /lint/rule_target_logql_auto_test.go: -------------------------------------------------------------------------------- 1 | package lint 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | // TestTargetLogQLAutoRule tests the NewTargetLogQLAutoRule function to ensure 8 | // that it correctly identifies LogQL queries that should use $__auto for range vectors. 9 | func TestTargetLogQLAutoRule(t *testing.T) { 10 | linter := NewTargetLogQLAutoRule() 11 | 12 | for _, tc := range []struct { 13 | result Result 14 | panel Panel 15 | }{ 16 | // Test case: Non-Loki panel should pass without errors. 17 | { 18 | result: ResultSuccess, 19 | panel: Panel{ 20 | Title: "panel", 21 | Datasource: "foo", 22 | Targets: []Target{ 23 | { 24 | Expr: `sum(rate({job=~"$job",instance=~"$instance"}[5m]))`, 25 | }, 26 | }, 27 | }, 28 | }, 29 | // Test case: Valid LogQL query using $__auto. 30 | { 31 | result: ResultSuccess, 32 | panel: Panel{ 33 | Title: "panel", 34 | Type: "singlestat", 35 | Targets: []Target{ 36 | { 37 | Expr: `sum(rate({job=~"$job",instance=~"$instance"} [$__auto]))`, 38 | }, 39 | }, 40 | }, 41 | }, 42 | // Test case: Valid LogQL query using $__auto in a complex expression. 43 | { 44 | result: ResultSuccess, 45 | panel: Panel{ 46 | Title: "panel", 47 | Type: "singlestat", 48 | Targets: []Target{ 49 | { 50 | Expr: `sum(rate({job=~"$job",instance=~"$instance"} [$__auto]))/sum(rate({job=~"$job",instance=~"$instance"} [$__auto]))`, 51 | }, 52 | }, 53 | }, 54 | }, 55 | // Test case: Invalid LogQL query without $__auto. 56 | { 57 | result: Result{ 58 | Severity: Error, 59 | Message: `Dashboard 'dashboard', panel 'panel', target idx '0' LogQL query uses fixed duration: should use $__auto`, 60 | }, 61 | panel: Panel{ 62 | Title: "panel", 63 | Type: "singlestat", 64 | Targets: []Target{ 65 | { 66 | Expr: `sum(rate({job=~"$job",instance=~"$instance"}[5m]))`, 67 | }, 68 | }, 69 | }, 70 | }, 71 | // Test case: Invalid LogQL query without $__auto in a timeseries panel. 72 | { 73 | result: Result{ 74 | Severity: Error, 75 | Message: `Dashboard 'dashboard', panel 'panel', target idx '0' LogQL query uses fixed duration: should use $__auto`, 76 | }, 77 | panel: Panel{ 78 | Title: "panel", 79 | Type: "timeseries", 80 | Targets: []Target{ 81 | { 82 | Expr: `sum(rate({job=~"$job",instance=~"$instance"}[5m]))`, 83 | }, 84 | }, 85 | }, 86 | }, 87 | // Test case: Valid LogQL query with count_over_time and $__auto. 88 | { 89 | result: ResultSuccess, 90 | panel: Panel{ 91 | Title: "panel", 92 | Type: "singlestat", 93 | Targets: []Target{ 94 | { 95 | Expr: `count_over_time({job="mysql"} [$__auto])`, 96 | }, 97 | }, 98 | }, 99 | }, 100 | // Test case: Invalid LogQL query with count_over_time without $__auto. 101 | { 102 | result: Result{ 103 | Severity: Error, 104 | Message: `Dashboard 'dashboard', panel 'panel', target idx '0' LogQL query uses fixed duration: should use $__auto`, 105 | }, 106 | panel: Panel{ 107 | Title: "panel", 108 | Type: "singlestat", 109 | Targets: []Target{ 110 | { 111 | Expr: `count_over_time({job="mysql"}[5m])`, 112 | }, 113 | }, 114 | }, 115 | }, 116 | // Test case: Valid LogQL query with bytes_rate and $__auto. 117 | { 118 | result: ResultSuccess, 119 | panel: Panel{ 120 | Title: "panel", 121 | Type: "singlestat", 122 | Targets: []Target{ 123 | { 124 | Expr: `bytes_rate({job="mysql"} [$__auto])`, 125 | }, 126 | }, 127 | }, 128 | }, 129 | // Test case: Invalid LogQL query with bytes_rate without $__auto. 130 | { 131 | result: Result{ 132 | Severity: Error, 133 | Message: `Dashboard 'dashboard', panel 'panel', target idx '0' LogQL query uses fixed duration: should use $__auto`, 134 | }, 135 | panel: Panel{ 136 | Title: "panel", 137 | Type: "singlestat", 138 | Targets: []Target{ 139 | { 140 | Expr: `bytes_rate({job="mysql"}[5m])`, 141 | }, 142 | }, 143 | }, 144 | }, 145 | // Test case: Valid LogQL query with bytes_over_time and $__auto. 146 | { 147 | result: ResultSuccess, 148 | panel: Panel{ 149 | Title: "panel", 150 | Type: "singlestat", 151 | Targets: []Target{ 152 | { 153 | Expr: `bytes_over_time({job="mysql"} [$__auto])`, 154 | }, 155 | }, 156 | }, 157 | }, 158 | // Test case: Invalid LogQL query with bytes_over_time without $__auto. 159 | { 160 | result: Result{ 161 | Severity: Error, 162 | Message: `Dashboard 'dashboard', panel 'panel', target idx '0' LogQL query uses fixed duration: should use $__auto`, 163 | }, 164 | panel: Panel{ 165 | Title: "panel", 166 | Type: "singlestat", 167 | Targets: []Target{ 168 | { 169 | Expr: `bytes_over_time({job="mysql"}[5m])`, 170 | }, 171 | }, 172 | }, 173 | }, 174 | // Test case: Valid LogQL query with sum_over_time and $__auto. 175 | { 176 | result: ResultSuccess, 177 | panel: Panel{ 178 | Title: "panel", 179 | Type: "singlestat", 180 | Targets: []Target{ 181 | { 182 | Expr: `sum_over_time({job="mysql"} |= "duration" | unwrap duration [$__auto])`, 183 | }, 184 | }, 185 | }, 186 | }, 187 | // Test case: Invalid LogQL query with sum_over_time without $__auto. 188 | { 189 | result: Result{ 190 | Severity: Error, 191 | Message: `Dashboard 'dashboard', panel 'panel', target idx '0' LogQL query uses fixed duration: should use $__auto`, 192 | }, 193 | panel: Panel{ 194 | Title: "panel", 195 | Type: "singlestat", 196 | Targets: []Target{ 197 | { 198 | Expr: `sum_over_time({job="mysql"} |= "duration" | unwrap duration[5m])`, 199 | }, 200 | }, 201 | }, 202 | }, 203 | // Test case: Valid LogQL query with avg_over_time and $__auto. 204 | { 205 | result: ResultSuccess, 206 | panel: Panel{ 207 | Title: "panel", 208 | Type: "singlestat", 209 | Targets: []Target{ 210 | { 211 | Expr: `avg_over_time({job="mysql"} |= "duration" | unwrap duration [$__auto])`, 212 | }, 213 | }, 214 | }, 215 | }, 216 | // Test case: Invalid LogQL query with avg_over_time without $__auto. 217 | { 218 | result: Result{ 219 | Severity: Error, 220 | Message: `Dashboard 'dashboard', panel 'panel', target idx '0' LogQL query uses fixed duration: should use $__auto`, 221 | }, 222 | panel: Panel{ 223 | Title: "panel", 224 | Type: "singlestat", 225 | Targets: []Target{ 226 | { 227 | Expr: `avg_over_time({job="mysql"} |= "duration" | unwrap duration[5m])`, 228 | }, 229 | }, 230 | }, 231 | }, 232 | // Add similar tests for other unwrapped range aggregations... 233 | } { 234 | dashboard := Dashboard{ 235 | Title: "dashboard", 236 | Templating: struct { 237 | List []Template `json:"list"` 238 | }{ 239 | List: []Template{ 240 | { 241 | Type: "datasource", 242 | Query: "loki", 243 | }, 244 | }, 245 | }, 246 | Panels: []Panel{tc.panel}, 247 | } 248 | testRule(t, linter, dashboard, tc.result) 249 | } 250 | } 251 | -------------------------------------------------------------------------------- /lint/rule_target_logql_test.go: -------------------------------------------------------------------------------- 1 | package lint 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestTargetLogQLRule(t *testing.T) { 8 | linter := NewTargetLogQLRule() 9 | 10 | for _, tc := range []struct { 11 | result Result 12 | panel Panel 13 | }{ 14 | // Don't fail non-Loki panels. 15 | { 16 | result: ResultSuccess, 17 | panel: Panel{ 18 | Title: "panel", 19 | Datasource: "prometheus", 20 | Targets: []Target{ 21 | { 22 | Expr: `sum(rate(foo[5m]))`, 23 | }, 24 | }, 25 | }, 26 | }, 27 | // Valid LogQL query 28 | { 29 | result: ResultSuccess, 30 | panel: Panel{ 31 | Title: "panel", 32 | Type: "singlestat", 33 | Targets: []Target{ 34 | { 35 | Expr: `sum(rate({job="mysql"}[5m]))`, 36 | }, 37 | }, 38 | }, 39 | }, 40 | // Invalid LogQL query 41 | { 42 | result: Result{ 43 | Severity: Error, 44 | Message: `Dashboard 'dashboard', panel 'panel', target idx '0' invalid LogQL query 'sum(rate({job="mysql"[5m]))': parse error at line 0, col 22: syntax error: unexpected RANGE, expecting } or ,`, 45 | }, 46 | panel: Panel{ 47 | Title: "panel", 48 | Type: "singlestat", 49 | Targets: []Target{ 50 | { 51 | Expr: `sum(rate({job="mysql"[5m]))`, 52 | }, 53 | }, 54 | }, 55 | }, 56 | // Valid LogQL query with $__auto 57 | { 58 | result: ResultSuccess, 59 | panel: Panel{ 60 | Title: "panel", 61 | Type: "singlestat", 62 | Targets: []Target{ 63 | { 64 | Expr: `sum(rate({job="mysql"}[$__auto]))`, 65 | }, 66 | }, 67 | }, 68 | }, 69 | // Valid complex LogQL query 70 | { 71 | result: ResultSuccess, 72 | panel: Panel{ 73 | Title: "panel", 74 | Type: "singlestat", 75 | Targets: []Target{ 76 | { 77 | Expr: `sum by (host) (rate({job="mysql"} |= "error" != "timeout" | json | duration > 10s [5m]))`, 78 | }, 79 | }, 80 | }, 81 | }, 82 | // Invalid complex LogQL query 83 | { 84 | result: Result{ 85 | Severity: Error, 86 | Message: `Dashboard 'dashboard', panel 'panel', target idx '0' invalid LogQL query 'sum by (host) (rate({job="mysql"} |= "error" != "timeout" | json | duration > 10s [5m])))': parse error at line 1, col 89: syntax error: unexpected )`, 87 | }, 88 | panel: Panel{ 89 | Title: "panel", 90 | Type: "singlestat", 91 | Targets: []Target{ 92 | { 93 | Expr: `sum by (host) (rate({job="mysql"} |= "error" != "timeout" | json | duration > 10s [5m])))`, 94 | }, 95 | }, 96 | }, 97 | }, 98 | // LogQL query with line_format 99 | { 100 | result: ResultSuccess, 101 | panel: Panel{ 102 | Title: "panel", 103 | Type: "singlestat", 104 | Targets: []Target{ 105 | { 106 | Expr: `{job="mysql"} | json | line_format "{{.timestamp}} {{.message}}"`, 107 | }, 108 | }, 109 | }, 110 | }, 111 | // LogQL query with unwrap 112 | { 113 | result: ResultSuccess, 114 | panel: Panel{ 115 | Title: "panel", 116 | Type: "singlestat", 117 | Targets: []Target{ 118 | { 119 | Expr: `sum(rate({job="mysql"} | unwrap duration [5m]))`, 120 | }, 121 | }, 122 | }, 123 | }, 124 | } { 125 | dashboard := Dashboard{ 126 | Title: "dashboard", 127 | Templating: struct { 128 | List []Template `json:"list"` 129 | }{ 130 | List: []Template{ 131 | { 132 | Type: "datasource", 133 | Query: "loki", 134 | }, 135 | }, 136 | }, 137 | Panels: []Panel{tc.panel}, 138 | } 139 | testRule(t, linter, dashboard, tc.result) 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /lint/rule_target_promql.go: -------------------------------------------------------------------------------- 1 | package lint 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/prometheus/prometheus/promql/parser" 7 | ) 8 | 9 | // panelHasQueries returns true is the panel has queries we should try and 10 | // validate. We allow-list panels here to prevent false positives with 11 | // new panel types we don't understand. 12 | func panelHasQueries(p Panel) bool { 13 | types := []string{panelTypeSingleStat, panelTypeGauge, panelTypeTimeTable, "stat", "state-timeline", panelTypeTimeSeries} 14 | for _, t := range types { 15 | if p.Type == t { 16 | return true 17 | } 18 | } 19 | return false 20 | } 21 | 22 | // parsePromQL returns the parsed PromQL statement from a panel, 23 | // replacing eg [$__rate_interval] with [5m] so queries parse correctly. 24 | // We also replace various other Grafana global variables. 25 | func parsePromQL(expr string, variables []Template) (parser.Expr, error) { 26 | expr, err := expandVariables(expr, variables) 27 | if err != nil { 28 | return nil, fmt.Errorf("could not expand variables: %w", err) 29 | } 30 | return parser.ParseExpr(expr) 31 | } 32 | 33 | // NewTargetPromQLRule builds a lint rule for panels with Prometheus queries which checks: 34 | // - the query is valid PromQL 35 | // - the query contains two matchers within every selector - `{job=~"$job", instance=~"$instance"}` 36 | // - the query is not empty 37 | // - if the query references another panel then make sure that panel exists 38 | func NewTargetPromQLRule() *TargetRuleFunc { 39 | return &TargetRuleFunc{ 40 | name: "target-promql-rule", 41 | description: "Checks that each target uses a valid PromQL query.", 42 | fn: func(d Dashboard, p Panel, t Target) TargetRuleResults { 43 | r := TargetRuleResults{} 44 | 45 | if t := getTemplateDatasource(d); t == nil || t.Query != Prometheus { 46 | // Missing template datasources is a separate rule. 47 | return r 48 | } 49 | 50 | if !panelHasQueries(p) { 51 | return r 52 | } 53 | 54 | // If panel does not contain an expression then check if it references another panel and it exists 55 | if len(t.Expr) == 0 { 56 | if t.PanelId > 0 { 57 | for _, p1 := range d.Panels { 58 | if p1.Id == t.PanelId { 59 | return r 60 | } 61 | } 62 | r.AddError(d, p, t, "Invalid panel reference in target") 63 | } 64 | } 65 | 66 | if _, err := parsePromQL(t.Expr, d.Templating.List); err != nil { 67 | r.AddError(d, p, t, fmt.Sprintf("invalid PromQL query '%s': %v", t.Expr, err)) 68 | } 69 | 70 | return r 71 | }, 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /lint/rule_target_promql_test.go: -------------------------------------------------------------------------------- 1 | package lint 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestTargetPromQLRule(t *testing.T) { 8 | linter := NewTargetPromQLRule() 9 | 10 | for _, tc := range []struct { 11 | result []Result 12 | panel Panel 13 | }{ 14 | // Don't fail non-prometheus panels. 15 | { 16 | result: []Result{ResultSuccess}, 17 | panel: Panel{ 18 | Title: "panel", 19 | Datasource: "foo", 20 | Targets: []Target{ 21 | { 22 | Expr: `sum(rate(foo[5m]))`, 23 | }, 24 | }, 25 | }, 26 | }, 27 | // This is what a valid panel looks like. 28 | { 29 | result: []Result{ResultSuccess}, 30 | panel: Panel{ 31 | Title: "panel", 32 | Type: "singlestat", 33 | Targets: []Target{ 34 | { 35 | Expr: `sum(rate(foo[5m]))`, 36 | }, 37 | }, 38 | }, 39 | }, 40 | // Invalid query 41 | { 42 | result: []Result{ResultSuccess}, 43 | panel: Panel{ 44 | Title: "panel", 45 | Type: "singlestat", 46 | Targets: []Target{ 47 | { 48 | Expr: `sum(rate(foo[5m]))`, 49 | }, 50 | }, 51 | }, 52 | }, 53 | // Timeseries support 54 | { 55 | result: []Result{{ 56 | Severity: Error, 57 | Message: "Dashboard 'dashboard', panel 'panel', target idx '0' invalid PromQL query 'foo(bar.baz)': 1:8: parse error: unexpected character: '.'", 58 | }}, 59 | panel: Panel{ 60 | Title: "panel", 61 | Type: "timeseries", 62 | Targets: []Target{ 63 | { 64 | Expr: `foo(bar.baz)`, 65 | }, 66 | }, 67 | }, 68 | }, 69 | // Variable substitutions 70 | { 71 | result: []Result{ResultSuccess}, 72 | panel: Panel{ 73 | Title: "panel", 74 | Type: "singlestat", 75 | Targets: []Target{ 76 | { 77 | Expr: `sum(rate(foo[$__rate_interval])) * $__range_s`, 78 | }, 79 | }, 80 | }, 81 | }, 82 | // Variable substitutions with ${...} 83 | { 84 | result: []Result{ResultSuccess}, 85 | panel: Panel{ 86 | Title: "panel", 87 | Type: "singlestat", 88 | Targets: []Target{ 89 | { 90 | Expr: `sum(rate(foo[$__rate_interval])) * ${__range_s}`, 91 | }, 92 | }, 93 | }, 94 | }, 95 | // Variable substitutions inside by clause 96 | { 97 | result: []Result{ResultSuccess}, 98 | panel: Panel{ 99 | Title: "panel", 100 | Type: "singlestat", 101 | Targets: []Target{ 102 | { 103 | Expr: `sum by(${variable:csv}) (rate(foo[$__rate_interval])) * $__range_s`, 104 | }, 105 | }, 106 | }, 107 | }, 108 | // Template variables substitutions 109 | { 110 | result: []Result{ResultSuccess}, 111 | panel: Panel{ 112 | Title: "panel", 113 | Type: "singlestat", 114 | Targets: []Target{ 115 | { 116 | Expr: `sum (rate(foo[$interval:$resolution]))`, 117 | }, 118 | }, 119 | }, 120 | }, 121 | { 122 | result: []Result{ResultSuccess}, 123 | panel: Panel{ 124 | Title: "panel", 125 | Type: "singlestat", 126 | Targets: []Target{ 127 | { 128 | Expr: `increase(foo{}[$sampling])`, 129 | }, 130 | }, 131 | }, 132 | }, 133 | // Empty PromQL expression 134 | { 135 | result: []Result{{ 136 | Severity: Error, 137 | Message: "Dashboard 'dashboard', panel 'panel', target idx '0' invalid PromQL query '': unknown position: parse error: no expression found in input", 138 | }}, 139 | panel: Panel{ 140 | Title: "panel", 141 | Type: "singlestat", 142 | Targets: []Target{ 143 | { 144 | Expr: ``, 145 | }, 146 | }, 147 | }, 148 | }, 149 | // Reference another panel that does not exist 150 | { 151 | result: []Result{ 152 | { 153 | Severity: Error, 154 | Message: "Dashboard 'dashboard', panel 'panel', target idx '0' Invalid panel reference in target", 155 | }, 156 | { 157 | Severity: Error, 158 | Message: "Dashboard 'dashboard', panel 'panel', target idx '0' invalid PromQL query '': unknown position: parse error: no expression found in input", 159 | }, 160 | }, 161 | panel: Panel{ 162 | Id: 1, 163 | Title: "panel", 164 | Type: "singlestat", 165 | Targets: []Target{ 166 | { 167 | PanelId: 2, 168 | }, 169 | }, 170 | }, 171 | }, 172 | } { 173 | dashboard := Dashboard{ 174 | Title: "dashboard", 175 | Templating: struct { 176 | List []Template `json:"list"` 177 | }{ 178 | List: []Template{ 179 | { 180 | Type: "datasource", 181 | Query: "prometheus", 182 | }, 183 | { 184 | Type: "interval", 185 | Name: "interval", 186 | Options: []RawTemplateValue{ 187 | map[string]interface{}{ 188 | "value": "1h", 189 | }, 190 | }, 191 | }, 192 | { 193 | Type: "interval", 194 | Name: "sampling", 195 | Current: map[string]interface{}{"value": "$__auto_interval_sampling"}, 196 | }, 197 | { 198 | Type: "resolution", 199 | Name: "resolution", 200 | Options: []RawTemplateValue{ 201 | map[string]interface{}{ 202 | "value": "1h", 203 | }, 204 | map[string]interface{}{ 205 | "value": "1h", 206 | }, 207 | }, 208 | }, 209 | }, 210 | }, 211 | Panels: []Panel{ 212 | tc.panel, 213 | }, 214 | } 215 | 216 | testMultiResultRule(t, linter, dashboard, tc.result) 217 | } 218 | } 219 | -------------------------------------------------------------------------------- /lint/rule_target_rate_interval.go: -------------------------------------------------------------------------------- 1 | package lint 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | 7 | "github.com/prometheus/prometheus/promql/parser" 8 | ) 9 | 10 | type inspector func(parser.Node, []parser.Node) error 11 | 12 | func (f inspector) Visit(node parser.Node, path []parser.Node) (parser.Visitor, error) { 13 | if err := f(node, path); err != nil { 14 | return nil, err 15 | } 16 | return f, nil 17 | } 18 | 19 | // NewTargetRateIntervalRule builds a lint rule for panels with Prometheus queries which checks 20 | // all range vector selectors use $__rate_interval. 21 | func NewTargetRateIntervalRule() *TargetRuleFunc { 22 | rateIntervalMagicDuration, err := time.ParseDuration(globalVariables["__rate_interval"].(string)) 23 | if err != nil { 24 | // Will not happen 25 | panic(err) 26 | } 27 | return &TargetRuleFunc{ 28 | name: "target-rate-interval-rule", 29 | description: "Checks that each target uses $__rate_interval.", 30 | fn: func(d Dashboard, p Panel, t Target) TargetRuleResults { 31 | r := TargetRuleResults{} 32 | if t := getTemplateDatasource(d); t == nil || t.Query != Prometheus { 33 | // Missing template datasources is a separate rule. 34 | return r 35 | } 36 | 37 | if !panelHasQueries(p) { 38 | // Don't lint certain types of panels. 39 | return r 40 | } 41 | 42 | expr, err := parsePromQL(t.Expr, d.Templating.List) 43 | if err != nil { 44 | // Invalid PromQL is another rule 45 | return r 46 | } 47 | err = parser.Walk(inspector(func(node parser.Node, parents []parser.Node) error { 48 | selector, ok := node.(*parser.MatrixSelector) 49 | if !ok { 50 | // We are not inspecting something like foo{...}[...] 51 | return nil 52 | } 53 | 54 | if selector.Range == rateIntervalMagicDuration { 55 | // Range vector selector is $__rate_interval 56 | return nil 57 | } 58 | 59 | if len(parents) == 0 { 60 | // Bit weird to have a naked foo[$__rate_interval], but allow it. 61 | return nil 62 | } 63 | // Now check if the parent is a rate function 64 | call, ok := parents[len(parents)-1].(*parser.Call) 65 | if !ok { 66 | return fmt.Errorf( 67 | "invalid PromQL query '%s': $__rate_interval used in non-rate function", t.Expr) 68 | } 69 | 70 | if call.Func.Name != "rate" && call.Func.Name != "irate" { 71 | // the parent is not an (i)rate function call, allow it 72 | return nil 73 | } 74 | 75 | return fmt.Errorf("invalid PromQL query '%s': should use $__rate_interval", t.Expr) 76 | }), expr, nil) 77 | if err != nil { 78 | r.AddError(d, p, t, err.Error()) 79 | } 80 | 81 | return r 82 | }, 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /lint/rule_target_rate_interval_test.go: -------------------------------------------------------------------------------- 1 | package lint 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestTargetRateIntervalRule(t *testing.T) { 8 | linter := NewTargetRateIntervalRule() 9 | 10 | for _, tc := range []struct { 11 | result Result 12 | panel Panel 13 | }{ 14 | // Don't fail non-prometheus panels. 15 | { 16 | result: ResultSuccess, 17 | panel: Panel{ 18 | Title: "panel", 19 | Datasource: "foo", 20 | Targets: []Target{ 21 | { 22 | Expr: `sum(rate(foo[5m]))`, 23 | }, 24 | }, 25 | }, 26 | }, 27 | // This is what a valid panel looks like. 28 | { 29 | result: ResultSuccess, 30 | panel: Panel{ 31 | Title: "panel", 32 | Type: "singlestat", 33 | Targets: []Target{ 34 | { 35 | Expr: `sum(rate(foo{job=~"$job",instance=~"$instance"}[$__rate_interval]))`, 36 | }, 37 | }, 38 | }, 39 | }, 40 | // This is what a valid panel looks like. 41 | { 42 | result: ResultSuccess, 43 | panel: Panel{ 44 | Title: "panel", 45 | Type: "singlestat", 46 | Targets: []Target{ 47 | { 48 | Expr: `sum(rate(foo{job=~"$job",instance=~"$instance"}[$__rate_interval]))/sum(rate(bar{job=~"$job",instance=~"$instance"}[$__rate_interval]))`, 49 | }, 50 | }, 51 | }, 52 | }, 53 | // Invalid query 54 | { 55 | result: Result{ 56 | Severity: Error, 57 | Message: `Dashboard 'dashboard', panel 'panel', target idx '0' invalid PromQL query 'sum(rate(foo{job=~"$job",instance=~"$instance"}[5m]))': should use $__rate_interval`, 58 | }, 59 | panel: Panel{ 60 | Title: "panel", 61 | Type: "singlestat", 62 | Targets: []Target{ 63 | { 64 | Expr: `sum(rate(foo{job=~"$job",instance=~"$instance"}[5m]))`, 65 | }, 66 | }, 67 | }, 68 | }, 69 | // Timeseries support 70 | { 71 | result: Result{ 72 | Severity: Error, 73 | Message: `Dashboard 'dashboard', panel 'panel', target idx '0' invalid PromQL query 'sum(rate(foo{job=~"$job",instance=~"$instance"}[5m]))': should use $__rate_interval`, 74 | }, 75 | panel: Panel{ 76 | Title: "panel", 77 | Type: "timeseries", 78 | Targets: []Target{ 79 | { 80 | Expr: `sum(rate(foo{job=~"$job",instance=~"$instance"}[5m]))`, 81 | }, 82 | }, 83 | }, 84 | }, 85 | // Non-rate functions should not make the linter fail 86 | { 87 | result: ResultSuccess, 88 | panel: Panel{ 89 | Title: "panel", 90 | Type: "singlestat", 91 | Targets: []Target{ 92 | { 93 | Expr: `sum(increase(foo{job=~"$job",instance=~"$instance"}[$__range]))`, 94 | }, 95 | }, 96 | }, 97 | }, 98 | // irate should be checked too 99 | { 100 | result: Result{ 101 | Severity: Error, 102 | Message: `Dashboard 'dashboard', panel 'panel', target idx '0' invalid PromQL query 'sum(irate(foo{job=~"$job",instance=~"$instance"}[$__interval]))': should use $__rate_interval`, 103 | }, 104 | panel: Panel{ 105 | Title: "panel", 106 | Type: "singlestat", 107 | Targets: []Target{ 108 | { 109 | Expr: `sum(irate(foo{job=~"$job",instance=~"$instance"}[$__interval]))`, 110 | }, 111 | }, 112 | }, 113 | }, 114 | } { 115 | dashboard := Dashboard{ 116 | Title: "dashboard", 117 | Templating: struct { 118 | List []Template `json:"list"` 119 | }{ 120 | List: []Template{ 121 | { 122 | Type: "datasource", 123 | Query: "prometheus", 124 | }, 125 | }, 126 | }, 127 | Panels: []Panel{tc.panel}, 128 | } 129 | 130 | testRule(t, linter, dashboard, tc.result) 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /lint/rule_template_datasource.go: -------------------------------------------------------------------------------- 1 | package lint 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "golang.org/x/text/cases" 8 | "golang.org/x/text/language" 9 | ) 10 | 11 | func NewTemplateDatasourceRule() *DashboardRuleFunc { 12 | return &DashboardRuleFunc{ 13 | name: "template-datasource-rule", 14 | description: "Checks that the dashboard has a templated datasource.", 15 | fn: func(d Dashboard) DashboardRuleResults { 16 | r := DashboardRuleResults{} 17 | 18 | templatedDs := d.GetTemplateByType("datasource") 19 | if len(templatedDs) == 0 { 20 | r.AddError(d, "does not have a templated data source") 21 | } 22 | 23 | // TODO: Should there be a "Template" rule type which will iterate over all dashboard templates and execute rules? 24 | // This will only return one linting error at a time, when there may be multiple issues with templated datasources. 25 | 26 | titleCaser := cases.Title(language.English) 27 | 28 | for _, templDs := range templatedDs { 29 | querySpecificUID := fmt.Sprintf("%s_datasource", strings.ToLower(templDs.Query)) 30 | querySpecificName := fmt.Sprintf("%s data source", titleCaser.String(templDs.Query)) 31 | 32 | allowedDsUIDs := make(map[string]struct{}) 33 | allowedDsNames := make(map[string]struct{}) 34 | 35 | uidError := fmt.Sprintf("templated data source variable named '%s', should be named '%s'", templDs.Name, querySpecificUID) 36 | nameError := fmt.Sprintf("templated data source variable labeled '%s', should be labeled '%s'", templDs.Label, querySpecificName) 37 | if len(templatedDs) == 1 { 38 | allowedDsUIDs["datasource"] = struct{}{} 39 | allowedDsNames["Data source"] = struct{}{} 40 | 41 | uidError += ", or 'datasource'" 42 | nameError += ", or 'Data source'" 43 | } 44 | 45 | allowedDsUIDs[querySpecificUID] = struct{}{} 46 | allowedDsNames[querySpecificName] = struct{}{} 47 | 48 | // TODO: These are really two different rules 49 | _, ok := allowedDsUIDs[templDs.Name] 50 | if !ok { 51 | r.AddError(d, uidError) 52 | } 53 | 54 | _, ok = allowedDsNames[templDs.Label] 55 | if !ok { 56 | r.AddWarning(d, nameError) 57 | } 58 | } 59 | 60 | return r 61 | }, 62 | } 63 | } 64 | 65 | func getTemplateDatasource(d Dashboard) *Template { 66 | for _, template := range d.Templating.List { 67 | if template.Type != "datasource" { 68 | continue 69 | } 70 | return &template 71 | } 72 | return nil 73 | } 74 | -------------------------------------------------------------------------------- /lint/rule_template_datasource_test.go: -------------------------------------------------------------------------------- 1 | package lint 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestTemplateDatasource(t *testing.T) { 8 | linter := NewTemplateDatasourceRule() 9 | 10 | for _, tc := range []struct { 11 | name string 12 | result []Result 13 | dashboard Dashboard 14 | }{ 15 | // 0 Data Sources 16 | { 17 | name: "0 Data Sources", 18 | result: []Result{{ 19 | Severity: Error, 20 | Message: "Dashboard 'test' does not have a templated data source", 21 | }}, 22 | dashboard: Dashboard{ 23 | Title: "test", 24 | }, 25 | }, 26 | // 1 Data Source 27 | { 28 | name: "1 Data Source", 29 | result: []Result{ 30 | { 31 | Severity: Error, 32 | Message: "Dashboard 'test' templated data source variable named 'foo', should be named '_datasource', or 'datasource'", 33 | }, 34 | { 35 | Severity: Warning, 36 | Message: "Dashboard 'test' templated data source variable labeled '', should be labeled ' data source', or 'Data source'", 37 | }, 38 | }, 39 | dashboard: Dashboard{ 40 | Title: "test", 41 | Templating: struct { 42 | List []Template `json:"list"` 43 | }{ 44 | List: []Template{ 45 | { 46 | Type: "datasource", 47 | Name: "foo", 48 | }, 49 | }, 50 | }, 51 | }, 52 | }, 53 | { 54 | name: "wrong name", 55 | result: []Result{ 56 | { 57 | Severity: Warning, 58 | Message: "Dashboard 'test' templated data source variable labeled 'bar', should be labeled 'Bar data source', or 'Data source'", 59 | }, 60 | }, 61 | dashboard: Dashboard{ 62 | Title: "test", 63 | Templating: struct { 64 | List []Template `json:"list"` 65 | }{ 66 | List: []Template{ 67 | { 68 | Type: "datasource", 69 | Name: "datasource", 70 | Query: "bar", 71 | Label: "bar", 72 | }, 73 | }, 74 | }, 75 | }, 76 | }, 77 | { 78 | name: "OK - Data source ", 79 | result: []Result{ResultSuccess}, 80 | dashboard: Dashboard{ 81 | Title: "test", 82 | Templating: struct { 83 | List []Template `json:"list"` 84 | }{ 85 | List: []Template{ 86 | { 87 | Type: "datasource", 88 | Name: "datasource", 89 | Label: "Data source", 90 | Query: "prometheus", 91 | }, 92 | }, 93 | }, 94 | }, 95 | }, 96 | { 97 | name: "OK - Prometheus data source", 98 | result: []Result{ResultSuccess}, 99 | dashboard: Dashboard{ 100 | Title: "test", 101 | Templating: struct { 102 | List []Template `json:"list"` 103 | }{ 104 | List: []Template{ 105 | { 106 | Type: "datasource", 107 | Name: "datasource", 108 | Label: "Prometheus data source", 109 | Query: "prometheus", 110 | }, 111 | }, 112 | }, 113 | }, 114 | }, 115 | { 116 | name: "OK - name: prometheus_datasource", 117 | result: []Result{ResultSuccess}, 118 | dashboard: Dashboard{ 119 | Title: "test", 120 | Templating: struct { 121 | List []Template `json:"list"` 122 | }{ 123 | List: []Template{ 124 | { 125 | Type: "datasource", 126 | Name: "prometheus_datasource", 127 | Label: "Data source", 128 | Query: "prometheus", 129 | }, 130 | }, 131 | }, 132 | }, 133 | }, 134 | { 135 | name: "OK - name: prometheus_datasource, label: Prometheus data source", 136 | result: []Result{ResultSuccess}, 137 | dashboard: Dashboard{ 138 | Title: "test", 139 | Templating: struct { 140 | List []Template `json:"list"` 141 | }{ 142 | List: []Template{ 143 | { 144 | Type: "datasource", 145 | Name: "prometheus_datasource", 146 | Label: "Prometheus data source", 147 | Query: "prometheus", 148 | }, 149 | }, 150 | }, 151 | }, 152 | }, 153 | { 154 | name: "OK - name: loki_datasource, query: loki", 155 | result: []Result{ResultSuccess}, 156 | dashboard: Dashboard{ 157 | Title: "test", 158 | Templating: struct { 159 | List []Template `json:"list"` 160 | }{ 161 | List: []Template{ 162 | { 163 | Type: "datasource", 164 | Name: "loki_datasource", 165 | Label: "Data source", 166 | Query: "loki", 167 | }, 168 | }, 169 | }, 170 | }, 171 | }, 172 | { 173 | name: "OK - name: datasource, query: loki", 174 | result: []Result{ResultSuccess}, 175 | dashboard: Dashboard{ 176 | Title: "test", 177 | Templating: struct { 178 | List []Template `json:"list"` 179 | }{ 180 | List: []Template{ 181 | { 182 | Type: "datasource", 183 | Name: "datasource", 184 | Label: "Data source", 185 | Query: "loki", 186 | }, 187 | }, 188 | }, 189 | }, 190 | }, 191 | // 2 or more Data Sources 192 | { 193 | name: "3 Data Sources - 0", 194 | result: []Result{ 195 | { 196 | Severity: Error, 197 | Message: "Dashboard 'test' templated data source variable named 'datasource', should be named 'prometheus_datasource'", 198 | }, 199 | { 200 | Severity: Warning, 201 | Message: "Dashboard 'test' templated data source variable labeled 'Data source', should be labeled 'Prometheus data source'", 202 | }, 203 | { 204 | Severity: Warning, 205 | Message: "Dashboard 'test' templated data source variable labeled 'Data source', should be labeled 'Loki data source'", 206 | }, 207 | { 208 | Severity: Warning, 209 | Message: "Dashboard 'test' templated data source variable labeled 'Data source', should be labeled 'Influx data source'", 210 | }, 211 | }, 212 | dashboard: Dashboard{ 213 | Title: "test", 214 | Templating: struct { 215 | List []Template `json:"list"` 216 | }{ 217 | List: []Template{ 218 | { 219 | Type: "datasource", 220 | Name: "datasource", 221 | Label: "Data source", 222 | Query: "prometheus", 223 | }, 224 | { 225 | Type: "datasource", 226 | Name: "loki_datasource", 227 | Label: "Data source", 228 | Query: "loki", 229 | }, 230 | { 231 | Type: "datasource", 232 | Name: "influx_datasource", 233 | Label: "Data source", 234 | Query: "influx", 235 | }, 236 | }, 237 | }, 238 | }, 239 | }, 240 | { 241 | name: "3 Data Sources - 1", 242 | result: []Result{ 243 | { 244 | Severity: Warning, 245 | Message: "Dashboard 'test' templated data source variable labeled 'Data source', should be labeled 'Prometheus data source'", 246 | }, 247 | { 248 | Severity: Warning, 249 | Message: "Dashboard 'test' templated data source variable labeled 'Data source', should be labeled 'Loki data source'", 250 | }, 251 | { 252 | Severity: Warning, 253 | Message: "Dashboard 'test' templated data source variable labeled 'Data source', should be labeled 'Influx data source'", 254 | }, 255 | }, 256 | dashboard: Dashboard{ 257 | Title: "test", 258 | Templating: struct { 259 | List []Template `json:"list"` 260 | }{ 261 | List: []Template{ 262 | { 263 | Type: "datasource", 264 | Name: "prometheus_datasource", 265 | Label: "Data source", 266 | Query: "prometheus", 267 | }, 268 | { 269 | Type: "datasource", 270 | Name: "loki_datasource", 271 | Label: "Data source", 272 | Query: "loki", 273 | }, 274 | { 275 | Type: "datasource", 276 | Name: "influx_datasource", 277 | Label: "Data source", 278 | Query: "influx", 279 | }, 280 | }, 281 | }, 282 | }, 283 | }, 284 | { 285 | name: "3 Data Sources - 2", 286 | result: []Result{ResultSuccess}, 287 | dashboard: Dashboard{ 288 | Title: "test", 289 | Templating: struct { 290 | List []Template `json:"list"` 291 | }{ 292 | List: []Template{ 293 | { 294 | Type: "datasource", 295 | Name: "prometheus_datasource", 296 | Label: "Prometheus data source", 297 | Query: "prometheus", 298 | }, 299 | { 300 | Type: "datasource", 301 | Name: "loki_datasource", 302 | Label: "Loki data source", 303 | Query: "loki", 304 | }, 305 | { 306 | Type: "datasource", 307 | Name: "influx_datasource", 308 | Label: "Influx data source", 309 | Query: "influx", 310 | }, 311 | }, 312 | }, 313 | }, 314 | }, 315 | } { 316 | t.Run(tc.name, func(t *testing.T) { 317 | testMultiResultRule(t, linter, tc.dashboard, tc.result) 318 | }) 319 | } 320 | } 321 | -------------------------------------------------------------------------------- /lint/rule_template_instance.go: -------------------------------------------------------------------------------- 1 | package lint 2 | 3 | func NewTemplateInstanceRule() *DashboardRuleFunc { 4 | return &DashboardRuleFunc{ 5 | name: "template-instance-rule", 6 | description: "Checks that the dashboard has a templated instance.", 7 | fn: func(d Dashboard) DashboardRuleResults { 8 | r := DashboardRuleResults{} 9 | 10 | template := getTemplateDatasource(d) 11 | if template == nil || template.Query != Prometheus { 12 | return r 13 | } 14 | 15 | checkTemplate(d, "instance", &r) 16 | return r 17 | }, 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /lint/rule_template_instance_test.go: -------------------------------------------------------------------------------- 1 | package lint 2 | 3 | import "testing" 4 | 5 | func TestInstanceTemplate(t *testing.T) { 6 | linter := NewTemplateInstanceRule() 7 | 8 | for _, tc := range []struct { 9 | result Result 10 | dashboard Dashboard 11 | }{ 12 | // Non-promtheus dashboards shouldn't fail. 13 | { 14 | result: ResultSuccess, 15 | dashboard: Dashboard{ 16 | Title: "test", 17 | }, 18 | }, 19 | // Missing instance templates. 20 | { 21 | result: Result{ 22 | Severity: Error, 23 | Message: "Dashboard 'test' is missing the instance template", 24 | }, 25 | dashboard: Dashboard{ 26 | Title: "test", 27 | Templating: struct { 28 | List []Template `json:"list"` 29 | }{ 30 | List: []Template{ 31 | { 32 | Type: "datasource", 33 | Query: "prometheus", 34 | }, 35 | { 36 | Name: "job", 37 | Datasource: "$datasource", 38 | Type: "query", 39 | Label: "Job", 40 | Multi: true, 41 | AllValue: ".+", 42 | }, 43 | }, 44 | }, 45 | }, 46 | }, 47 | // What success looks like. 48 | { 49 | result: ResultSuccess, 50 | dashboard: Dashboard{ 51 | Title: "test", 52 | Templating: struct { 53 | List []Template `json:"list"` 54 | }{ 55 | List: []Template{ 56 | { 57 | Type: "datasource", 58 | Query: "prometheus", 59 | }, 60 | { 61 | Name: "job", 62 | Datasource: "$datasource", 63 | Type: "query", 64 | Label: "Job", 65 | Multi: true, 66 | AllValue: ".+", 67 | }, 68 | { 69 | Name: "instance", 70 | Datasource: "${datasource}", 71 | Type: "query", 72 | Label: "Instance", 73 | Multi: true, 74 | AllValue: ".+", 75 | }, 76 | }, 77 | }, 78 | }, 79 | }, 80 | } { 81 | testRule(t, linter, tc.dashboard, tc.result) 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /lint/rule_template_job.go: -------------------------------------------------------------------------------- 1 | package lint 2 | 3 | import ( 4 | "fmt" 5 | 6 | "golang.org/x/text/cases" 7 | "golang.org/x/text/language" 8 | ) 9 | 10 | func NewTemplateJobRule() *DashboardRuleFunc { 11 | return &DashboardRuleFunc{ 12 | name: "template-job-rule", 13 | description: "Checks that the dashboard has a templated job.", 14 | fn: func(d Dashboard) DashboardRuleResults { 15 | r := DashboardRuleResults{} 16 | 17 | template := getTemplateDatasource(d) 18 | if template == nil || template.Query != Prometheus { 19 | return r 20 | } 21 | 22 | checkTemplate(d, "job", &r) 23 | return r 24 | }, 25 | } 26 | } 27 | 28 | func checkTemplate(d Dashboard, name string, r *DashboardRuleResults) { 29 | t := getTemplate(d, name) 30 | if t == nil { 31 | r.AddError(d, fmt.Sprintf("is missing the %s template", name)) 32 | return 33 | } 34 | 35 | // TODO: Adding the prometheus_datasource here is hacky. This check function also assumes that all template vars which it will 36 | // ever check are only prometheus queries, which may not always be the case. 37 | src, err := t.GetDataSource() 38 | if err != nil { 39 | r.AddError(d, fmt.Sprintf("%s template has invalid datasource %v", name, err)) 40 | } 41 | 42 | srcUid := src.UID 43 | if srcUid != "$datasource" && srcUid != "${datasource}" && srcUid != "$prometheus_datasource" && srcUid != "${prometheus_datasource}" { 44 | r.AddError(d, fmt.Sprintf("%s template should use datasource '$datasource', is currently '%s'", name, srcUid)) 45 | } 46 | 47 | if t.Type != targetTypeQuery { 48 | r.AddError(d, fmt.Sprintf("%s template should be a Prometheus query, is currently '%s'", name, t.Type)) 49 | } 50 | 51 | titleCaser := cases.Title(language.English) 52 | labelTitle := titleCaser.String(name) 53 | 54 | if t.Label != labelTitle { 55 | r.AddWarning(d, fmt.Sprintf("%s template should be a labeled '%s', is currently '%s'", name, labelTitle, t.Label)) 56 | } 57 | 58 | if !t.Multi { 59 | r.AddError(d, fmt.Sprintf("%s template should be a multi select", name)) 60 | } 61 | 62 | if t.AllValue != ".+" { 63 | r.AddError(d, fmt.Sprintf("%s template allValue should be '.+', is currently '%s'", name, t.AllValue)) 64 | } 65 | } 66 | 67 | func getTemplate(d Dashboard, name string) *Template { 68 | for _, template := range d.Templating.List { 69 | if template.Name == name { 70 | return &template 71 | } 72 | } 73 | return nil 74 | } 75 | -------------------------------------------------------------------------------- /lint/rule_template_job_test.go: -------------------------------------------------------------------------------- 1 | package lint 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestJobTemplate(t *testing.T) { 8 | linter := NewTemplateJobRule() 9 | 10 | for _, tc := range []struct { 11 | name string 12 | result []Result 13 | dashboard Dashboard 14 | }{ 15 | { 16 | name: "Non-promtheus dashboards shouldn't fail.", 17 | result: []Result{ResultSuccess}, 18 | dashboard: Dashboard{ 19 | Title: "test", 20 | }, 21 | }, 22 | { 23 | name: "Missing job template.", 24 | result: []Result{{ 25 | Severity: Error, 26 | Message: "Dashboard 'test' is missing the job template", 27 | }}, 28 | dashboard: Dashboard{ 29 | Title: "test", 30 | Templating: struct { 31 | List []Template `json:"list"` 32 | }{ 33 | List: []Template{ 34 | { 35 | Type: "datasource", 36 | Query: "prometheus", 37 | }, 38 | }, 39 | }, 40 | }, 41 | }, 42 | { 43 | name: "Wrong datasource.", 44 | result: []Result{ 45 | {Severity: Error, Message: "Dashboard 'test' job template should use datasource '$datasource', is currently 'foo'"}, 46 | {Severity: Error, Message: "Dashboard 'test' job template should be a Prometheus query, is currently ''"}, 47 | {Severity: Warning, Message: "Dashboard 'test' job template should be a labeled 'Job', is currently ''"}, 48 | {Severity: Error, Message: "Dashboard 'test' job template should be a multi select"}, 49 | {Severity: Error, Message: "Dashboard 'test' job template allValue should be '.+', is currently ''"}}, 50 | dashboard: Dashboard{ 51 | Title: "test", 52 | Templating: struct { 53 | List []Template `json:"list"` 54 | }{ 55 | List: []Template{ 56 | { 57 | Type: "datasource", 58 | Query: "prometheus", 59 | }, 60 | { 61 | Name: "job", 62 | Datasource: "foo", 63 | }, 64 | }, 65 | }, 66 | }, 67 | }, 68 | { 69 | name: "Wrong type.", 70 | result: []Result{ 71 | {Severity: Error, Message: "Dashboard 'test' job template should be a Prometheus query, is currently 'bar'"}, 72 | {Severity: Warning, Message: "Dashboard 'test' job template should be a labeled 'Job', is currently ''"}, 73 | {Severity: Error, Message: "Dashboard 'test' job template should be a multi select"}, 74 | {Severity: Error, Message: "Dashboard 'test' job template allValue should be '.+', is currently ''"}}, 75 | dashboard: Dashboard{ 76 | Title: "test", 77 | Templating: struct { 78 | List []Template `json:"list"` 79 | }{ 80 | List: []Template{ 81 | { 82 | Type: "datasource", 83 | Query: "prometheus", 84 | }, 85 | { 86 | Name: "job", 87 | Datasource: "$datasource", 88 | Type: "bar", 89 | }, 90 | }, 91 | }, 92 | }, 93 | }, 94 | { 95 | name: "Wrong job label.", 96 | result: []Result{ 97 | {Severity: Warning, Message: "Dashboard 'test' job template should be a labeled 'Job', is currently 'bar'"}, 98 | {Severity: Error, Message: "Dashboard 'test' job template should be a multi select"}, 99 | {Severity: Error, Message: "Dashboard 'test' job template allValue should be '.+', is currently ''"}}, 100 | dashboard: Dashboard{ 101 | Title: "test", 102 | Templating: struct { 103 | List []Template `json:"list"` 104 | }{ 105 | List: []Template{ 106 | { 107 | Type: "datasource", 108 | Query: "prometheus", 109 | }, 110 | { 111 | Name: "job", 112 | Datasource: "$datasource", 113 | Type: "query", 114 | Label: "bar", 115 | }, 116 | }, 117 | }, 118 | }, 119 | }, 120 | { 121 | name: "OK", 122 | result: []Result{ResultSuccess}, 123 | dashboard: Dashboard{ 124 | Title: "test", 125 | Templating: struct { 126 | List []Template `json:"list"` 127 | }{ 128 | List: []Template{ 129 | { 130 | Type: "datasource", 131 | Query: "prometheus", 132 | }, 133 | { 134 | Name: "job", 135 | Datasource: "$datasource", 136 | Type: "query", 137 | Label: "Job", 138 | Multi: true, 139 | AllValue: ".+", 140 | }, 141 | { 142 | Name: "instance", 143 | Datasource: "${datasource}", 144 | Type: "query", 145 | Label: "Instance", 146 | Multi: true, 147 | AllValue: ".+", 148 | }, 149 | }, 150 | }, 151 | }, 152 | }, 153 | } { 154 | t.Run(tc.name, func(t *testing.T) { 155 | testMultiResultRule(t, linter, tc.dashboard, tc.result) 156 | }) 157 | } 158 | } 159 | -------------------------------------------------------------------------------- /lint/rule_template_label_promql.go: -------------------------------------------------------------------------------- 1 | package lint 2 | 3 | import ( 4 | "fmt" 5 | "regexp" 6 | ) 7 | 8 | var templatedLabelRegexp = regexp.MustCompile(`([a-z_]+)\((.+)\)`) 9 | 10 | func labelHasValidDataSourceFunction(name string) bool { 11 | // https://grafana.com/docs/grafana/v8.1/datasources/prometheus/#query-variable 12 | names := []string{"label_names", "label_values", "metrics", "query_result"} 13 | for _, n := range names { 14 | if name == n { 15 | return true 16 | } 17 | } 18 | return false 19 | } 20 | 21 | // parseTemplatedLabelPromQL returns error in case 22 | // 1) The given PromQL expressions is invalid 23 | // 2) Use of invalid label function 24 | func parseTemplatedLabelPromQL(t Template, variables []Template) error { 25 | // regex capture must return slice of 3 strings. 26 | // 1) given query 2) function name 3) function arg. 27 | tokens := templatedLabelRegexp.FindStringSubmatch(t.Query) 28 | if tokens == nil { 29 | return fmt.Errorf("invalid 'query': %v", t.Query) 30 | } 31 | 32 | if !labelHasValidDataSourceFunction(tokens[1]) { 33 | return fmt.Errorf("invalid 'function': %v", tokens[1]) 34 | } 35 | expr, err := parsePromQL(tokens[2], variables) 36 | if expr != nil { 37 | return nil 38 | } 39 | return err 40 | } 41 | 42 | func NewTemplateLabelPromQLRule() *DashboardRuleFunc { 43 | return &DashboardRuleFunc{ 44 | name: "template-label-promql-rule", 45 | description: "Checks that the dashboard templated labels have proper PromQL expressions.", 46 | fn: func(d Dashboard) DashboardRuleResults { 47 | r := DashboardRuleResults{} 48 | 49 | template := getTemplateDatasource(d) 50 | if template == nil || template.Query != Prometheus { 51 | return r 52 | } 53 | for _, template := range d.Templating.List { 54 | if template.Type != targetTypeQuery { 55 | continue 56 | } 57 | if err := parseTemplatedLabelPromQL(template, d.Templating.List); err != nil { 58 | r.AddError(d, fmt.Sprintf("template '%s' invalid templated label '%s': %v", template.Name, template.Query, err)) 59 | } 60 | } 61 | 62 | return r 63 | }, 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /lint/rule_template_label_promql_test.go: -------------------------------------------------------------------------------- 1 | package lint 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestTemplateLabelPromQLRule(t *testing.T) { 8 | linter := NewTemplateLabelPromQLRule() 9 | 10 | for _, tc := range []struct { 11 | name string 12 | result Result 13 | dashboard Dashboard 14 | }{ 15 | { 16 | name: "Don't fail on non prometheus template.", 17 | result: ResultSuccess, 18 | dashboard: Dashboard{ 19 | Title: "test", 20 | Templating: struct { 21 | List []Template `json:"list"` 22 | }{ 23 | List: []Template{ 24 | { 25 | Type: "datasource", 26 | Query: "foo", 27 | }, 28 | }, 29 | }, 30 | }, 31 | }, 32 | { 33 | name: "OK", 34 | result: ResultSuccess, 35 | dashboard: Dashboard{ 36 | Title: "test", 37 | Templating: struct { 38 | List []Template `json:"list"` 39 | }{ 40 | List: []Template{ 41 | { 42 | Type: "datasource", 43 | Query: "prometheus", 44 | }, 45 | { 46 | Name: "namespaces", 47 | Datasource: "$datasource", 48 | Query: "label_values(up{job=~\"$job\"}, namespace)", 49 | Type: "query", 50 | Label: "job", 51 | }, 52 | }, 53 | }, 54 | }, 55 | }, 56 | { 57 | name: "Error", 58 | result: Result{ 59 | Severity: Error, 60 | Message: `Dashboard 'test' template 'namespaces' invalid templated label 'label_values(up{, namespace)': 1:4: parse error: unexpected "," in label matching, expected identifier or "}"`, 61 | }, 62 | dashboard: Dashboard{ 63 | Title: "test", 64 | Templating: struct { 65 | List []Template `json:"list"` 66 | }{ 67 | List: []Template{ 68 | { 69 | Type: "datasource", 70 | Query: "prometheus", 71 | }, 72 | { 73 | Name: "namespaces", 74 | Datasource: "$datasource", 75 | Query: "label_values(up{, namespace)", 76 | Type: "query", 77 | Label: "job", 78 | }, 79 | }, 80 | }, 81 | }, 82 | }, 83 | { 84 | name: "Invalid function.", 85 | result: Result{ 86 | Severity: Error, 87 | Message: `Dashboard 'test' template 'namespaces' invalid templated label 'foo(up, namespace)': invalid 'function': foo`, 88 | }, 89 | dashboard: Dashboard{ 90 | Title: "test", 91 | Templating: struct { 92 | List []Template `json:"list"` 93 | }{ 94 | List: []Template{ 95 | { 96 | Type: "datasource", 97 | Query: "prometheus", 98 | }, 99 | { 100 | Name: "namespaces", 101 | Datasource: "$datasource", 102 | Query: "foo(up, namespace)", 103 | Type: "query", 104 | Label: "job", 105 | }, 106 | }, 107 | }, 108 | }, 109 | }, 110 | { 111 | name: "Invalid query expression.", 112 | result: Result{ 113 | Severity: Error, 114 | Message: `Dashboard 'test' template 'namespaces' invalid templated label 'foo': invalid 'query': foo`, 115 | }, 116 | dashboard: Dashboard{ 117 | Title: "test", 118 | Templating: struct { 119 | List []Template `json:"list"` 120 | }{ 121 | List: []Template{ 122 | { 123 | Type: "datasource", 124 | Query: "prometheus", 125 | }, 126 | { 127 | Name: "namespaces", 128 | Datasource: "$datasource", 129 | Query: "foo", 130 | Type: "query", 131 | Label: "job", 132 | }, 133 | }, 134 | }, 135 | }, 136 | }, 137 | // Support main grafana variables. 138 | { 139 | result: ResultSuccess, 140 | dashboard: Dashboard{ 141 | Title: "test", 142 | Templating: struct { 143 | List []Template `json:"list"` 144 | }{ 145 | List: []Template{ 146 | { 147 | Type: "datasource", 148 | Query: "prometheus", 149 | }, 150 | { 151 | Name: "namespaces", 152 | Datasource: "$datasource", 153 | Query: "query_result(max by(namespaces) (max_over_time(memory{}[$__range])))", 154 | Type: "query", 155 | Label: "job", 156 | }, 157 | }, 158 | }, 159 | }, 160 | }, 161 | } { 162 | t.Run(tc.name, func(t *testing.T) { 163 | testRule(t, linter, tc.dashboard, tc.result) 164 | }) 165 | } 166 | } 167 | -------------------------------------------------------------------------------- /lint/rule_template_on_time_change_reload.go: -------------------------------------------------------------------------------- 1 | package lint 2 | 3 | import ( 4 | "fmt" 5 | ) 6 | 7 | func NewTemplateOnTimeRangeReloadRule() *DashboardRuleFunc { 8 | return &DashboardRuleFunc{ 9 | name: "template-on-time-change-reload-rule", 10 | description: "Checks that the dashboard template variables are configured to reload on time change.", 11 | fn: func(d Dashboard) DashboardRuleResults { 12 | r := DashboardRuleResults{} 13 | 14 | for i, template := range d.Templating.List { 15 | if template.Type != targetTypeQuery { 16 | continue 17 | } 18 | 19 | if template.Refresh != 2 { 20 | r.AddFixableError(d, 21 | fmt.Sprintf("templated datasource variable named '%s', should be set to be refreshed "+ 22 | "'On Time Range Change (value 2)', is currently '%d'", template.Name, template.Refresh), 23 | fixTemplateOnTimeRangeReloadRule(i)) 24 | } 25 | } 26 | return r 27 | }, 28 | } 29 | } 30 | 31 | func fixTemplateOnTimeRangeReloadRule(i int) func(*Dashboard) { 32 | return func(d *Dashboard) { 33 | d.Templating.List[i].Refresh = 2 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /lint/rule_template_on_time_change_reload_test.go: -------------------------------------------------------------------------------- 1 | package lint 2 | 3 | import ( 4 | "encoding/json" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/require" 8 | ) 9 | 10 | func TestTemplateOnTimeRangeReloadRule(t *testing.T) { 11 | linter := NewTemplateOnTimeRangeReloadRule() 12 | 13 | good := []Template{ 14 | { 15 | Type: "datasource", 16 | Query: "prometheus", 17 | }, 18 | { 19 | Name: "namespaces", 20 | Datasource: "$datasource", 21 | Query: "label_values(up{job=~\"$job\"}, namespace)", 22 | Type: "query", 23 | Label: "job", 24 | Refresh: 2, 25 | }, 26 | } 27 | for _, tc := range []struct { 28 | name string 29 | result Result 30 | dashboard Dashboard 31 | fixed *Dashboard 32 | }{ 33 | { 34 | name: "OK", 35 | result: ResultSuccess, 36 | dashboard: Dashboard{ 37 | Title: "test", 38 | Templating: struct { 39 | List []Template `json:"list"` 40 | }{ 41 | List: good, 42 | }, 43 | }, 44 | }, 45 | { 46 | name: "autofix", 47 | result: Result{ 48 | Severity: Fixed, 49 | Message: `Dashboard 'test' templated datasource variable named 'namespaces', should be set to be refreshed 'On Time Range Change (value 2)', is currently '1'`, 50 | }, 51 | dashboard: Dashboard{ 52 | Title: "test", 53 | Templating: struct { 54 | List []Template `json:"list"` 55 | }{ 56 | List: ([]Template{ 57 | { 58 | Type: "datasource", 59 | Query: "prometheus", 60 | }, 61 | { 62 | Name: "namespaces", 63 | Datasource: "$datasource", 64 | Query: "label_values(up{job=~\"$job\"}, namespace)", 65 | Type: "query", 66 | Label: "job", 67 | Refresh: 1, 68 | }, 69 | }), 70 | }, 71 | }, 72 | fixed: &Dashboard{ 73 | Title: "test", 74 | Templating: struct { 75 | List []Template `json:"list"` 76 | }{ 77 | List: good, 78 | }, 79 | }, 80 | }, 81 | { 82 | name: "error", 83 | result: Result{ 84 | Severity: Error, 85 | Message: `Dashboard 'test' templated datasource variable named 'namespaces', should be set to be refreshed 'On Time Range Change (value 2)', is currently '1'`, 86 | }, 87 | dashboard: Dashboard{ 88 | Title: "test", 89 | Templating: struct { 90 | List []Template `json:"list"` 91 | }{ 92 | List: ([]Template{ 93 | { 94 | Type: "datasource", 95 | Query: "prometheus", 96 | }, 97 | { 98 | Name: "namespaces", 99 | Datasource: "$datasource", 100 | Query: "label_values(up{job=~\"$job\"}, namespace)", 101 | Type: "query", 102 | Label: "job", 103 | Refresh: 1, 104 | }, 105 | }), 106 | }, 107 | }, 108 | }, 109 | } { 110 | t.Run(tc.name, func(t *testing.T) { 111 | autofix := tc.fixed != nil 112 | testRuleWithAutofix(t, linter, &tc.dashboard, []Result{tc.result}, autofix) 113 | if autofix { 114 | expected, _ := json.Marshal(tc.fixed) 115 | actual, _ := json.Marshal(tc.dashboard) 116 | require.Equal(t, string(expected), string(actual)) 117 | } 118 | }) 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /lint/rule_uneditable.go: -------------------------------------------------------------------------------- 1 | package lint 2 | 3 | func NewUneditableRule() *DashboardRuleFunc { 4 | return &DashboardRuleFunc{ 5 | name: "uneditable-dashboard", 6 | description: "Checks that the dashboard is not editable.", 7 | fn: func(d Dashboard) DashboardRuleResults { 8 | r := DashboardRuleResults{} 9 | if d.Editable { 10 | r.AddFixableError(d, "is editable, it should be set to 'editable: false'", FixUneditableRule) 11 | } 12 | return r 13 | }, 14 | } 15 | } 16 | 17 | func FixUneditableRule(d *Dashboard) { 18 | d.Editable = false 19 | } 20 | -------------------------------------------------------------------------------- /lint/rule_uneditable_test.go: -------------------------------------------------------------------------------- 1 | package lint 2 | 3 | import ( 4 | "encoding/json" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/require" 8 | ) 9 | 10 | func TestNewUneditableRule(t *testing.T) { 11 | linter := NewUneditableRule() 12 | 13 | for _, tc := range []struct { 14 | name string 15 | result Result 16 | dashboard Dashboard 17 | fixed *Dashboard 18 | }{ 19 | { 20 | name: "OK", 21 | result: ResultSuccess, 22 | dashboard: Dashboard{ 23 | Title: "test", 24 | Editable: false, 25 | }, 26 | }, 27 | { 28 | name: "error", 29 | result: Result{ 30 | Severity: Error, 31 | Message: `Dashboard 'test' is editable, it should be set to 'editable: false'`, 32 | }, 33 | dashboard: Dashboard{ 34 | Title: "test", 35 | Editable: true, 36 | }, 37 | }, 38 | { 39 | name: "autofix", 40 | result: Result{ 41 | Severity: Fixed, 42 | Message: `Dashboard 'test' is editable, it should be set to 'editable: false'`, 43 | }, 44 | dashboard: Dashboard{ 45 | Title: "test", 46 | Editable: true, 47 | }, 48 | fixed: &Dashboard{ 49 | Title: "test", 50 | Editable: false, 51 | }, 52 | }, 53 | } { 54 | t.Run(tc.name, func(t *testing.T) { 55 | autofix := tc.fixed != nil 56 | testRuleWithAutofix(t, linter, &tc.dashboard, []Result{tc.result}, autofix) 57 | if autofix { 58 | expected, _ := json.Marshal(tc.fixed) 59 | actual, _ := json.Marshal(tc.dashboard) 60 | require.Equal(t, string(expected), string(actual)) 61 | } 62 | }) 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /lint/rules.go: -------------------------------------------------------------------------------- 1 | package lint 2 | 3 | type Rule interface { 4 | Description() string 5 | Name() string 6 | Lint(Dashboard, *ResultSet) 7 | } 8 | 9 | type DashboardRuleFunc struct { 10 | name, description string 11 | fn func(Dashboard) DashboardRuleResults 12 | } 13 | 14 | func NewDashboardRuleFunc(name, description string, fn func(Dashboard) DashboardRuleResults) Rule { 15 | return &DashboardRuleFunc{name, description, fn} 16 | } 17 | 18 | func (f DashboardRuleFunc) Name() string { return f.name } 19 | func (f DashboardRuleFunc) Description() string { return f.description } 20 | func (f DashboardRuleFunc) Lint(d Dashboard, s *ResultSet) { 21 | dashboardResults := f.fn(d).Results 22 | if len(dashboardResults) == 0 { 23 | dashboardResults = []DashboardResult{{ 24 | Result: ResultSuccess, 25 | }} 26 | } 27 | rr := make([]FixableResult, len(dashboardResults)) 28 | for i, r := range dashboardResults { 29 | r := r // capture loop variable 30 | var fix func(*Dashboard) 31 | if r.Fix != nil { 32 | fix = func(dashboard *Dashboard) { 33 | r.Fix(dashboard) 34 | } 35 | } 36 | rr[i] = FixableResult{ 37 | Result: Result{ 38 | Severity: r.Severity, 39 | Message: r.Message, 40 | }, 41 | Fix: fix, 42 | } 43 | } 44 | 45 | s.AddResult(ResultContext{ 46 | Result: RuleResults{rr}, 47 | Rule: f, 48 | Dashboard: &d, 49 | }) 50 | } 51 | 52 | type PanelRuleFunc struct { 53 | name, description string 54 | fn func(Dashboard, Panel) PanelRuleResults 55 | } 56 | 57 | func NewPanelRuleFunc(name, description string, fn func(Dashboard, Panel) PanelRuleResults) Rule { 58 | return &PanelRuleFunc{name, description, fn} 59 | } 60 | 61 | func (f PanelRuleFunc) Name() string { return f.name } 62 | func (f PanelRuleFunc) Description() string { return f.description } 63 | func (f PanelRuleFunc) Lint(d Dashboard, s *ResultSet) { 64 | for pi, p := range d.GetPanels() { 65 | p := p // capture loop variable 66 | pi := pi // capture loop variable 67 | var rr []FixableResult 68 | 69 | panelResults := f.fn(d, p).Results 70 | if len(panelResults) == 0 { 71 | panelResults = []PanelResult{{ 72 | Result: ResultSuccess, 73 | }} 74 | } 75 | 76 | for _, r := range panelResults { 77 | var fix func(*Dashboard) 78 | if r.Fix != nil { 79 | fix = fixPanel(pi, r) 80 | } 81 | rr = append(rr, FixableResult{ 82 | Result: Result{ 83 | Severity: r.Severity, 84 | Message: r.Message, 85 | }, 86 | Fix: fix, 87 | }) 88 | } 89 | 90 | s.AddResult(ResultContext{ 91 | Result: RuleResults{rr}, 92 | Rule: f, 93 | Dashboard: &d, 94 | Panel: &p, 95 | }) 96 | } 97 | } 98 | 99 | func fixPanel(pi int, r PanelResult) func(dashboard *Dashboard) { 100 | return func(dashboard *Dashboard) { 101 | p := dashboard.GetPanels()[pi] 102 | r.Fix(*dashboard, &p) 103 | dashboard.Panels[pi] = p 104 | } 105 | } 106 | 107 | type TargetRuleFunc struct { 108 | name, description string 109 | fn func(Dashboard, Panel, Target) TargetRuleResults 110 | } 111 | 112 | func NewTargetRuleFunc(name, description string, fn func(Dashboard, Panel, Target) TargetRuleResults) Rule { 113 | return &TargetRuleFunc{name, description, fn} 114 | } 115 | 116 | func (f TargetRuleFunc) Name() string { return f.name } 117 | func (f TargetRuleFunc) Description() string { return f.description } 118 | func (f TargetRuleFunc) Lint(d Dashboard, s *ResultSet) { 119 | for pi, p := range d.GetPanels() { 120 | p := p // capture loop variable 121 | pi := pi // capture loop variable 122 | for ti, t := range p.Targets { 123 | t := t // capture loop variable 124 | ti := ti // capture loop variable 125 | var rr []FixableResult 126 | 127 | targetResults := f.fn(d, p, t).Results 128 | if len(targetResults) == 0 { 129 | targetResults = []TargetResult{{ 130 | Result: ResultSuccess, 131 | }} 132 | } 133 | 134 | for _, r := range targetResults { 135 | var fix func(*Dashboard) 136 | if r.Fix != nil { 137 | fix = fixTarget(pi, ti, r) 138 | } 139 | rr = append(rr, FixableResult{ 140 | Result: Result{ 141 | Severity: r.Severity, 142 | Message: r.Message, 143 | }, 144 | Fix: fix, 145 | }) 146 | } 147 | s.AddResult(ResultContext{ 148 | Result: RuleResults{rr}, 149 | Rule: f, 150 | Dashboard: &d, 151 | Panel: &p, 152 | Target: &t, 153 | }) 154 | } 155 | } 156 | } 157 | 158 | func fixTarget(pi int, ti int, r TargetResult) func(dashboard *Dashboard) { 159 | return func(dashboard *Dashboard) { 160 | p := dashboard.GetPanels()[pi] 161 | t := p.Targets[ti] 162 | r.Fix(*dashboard, p, &t) 163 | p.Targets[ti] = t 164 | dashboard.Panels[pi] = p 165 | } 166 | } 167 | 168 | // RuleSet contains a list of linting rules. 169 | type RuleSet struct { 170 | rules []Rule 171 | } 172 | 173 | func NewRuleSet() RuleSet { 174 | return RuleSet{ 175 | rules: []Rule{ 176 | NewTemplateDatasourceRule(), 177 | NewTemplateJobRule(), 178 | NewTemplateInstanceRule(), 179 | NewTemplateLabelPromQLRule(), 180 | NewTemplateOnTimeRangeReloadRule(), 181 | NewPanelDatasourceRule(), 182 | NewPanelTitleDescriptionRule(), 183 | NewPanelUnitsRule(), 184 | NewPanelNoTargetsRule(), 185 | NewTargetLogQLRule(), 186 | NewTargetLogQLAutoRule(), 187 | NewTargetPromQLRule(), 188 | NewTargetRateIntervalRule(), 189 | NewTargetJobRule(), 190 | NewTargetInstanceRule(), 191 | NewTargetCounterAggRule(), 192 | NewUneditableRule(), 193 | }, 194 | } 195 | } 196 | 197 | func (s *RuleSet) Rules() []Rule { 198 | return s.rules 199 | } 200 | 201 | func (s *RuleSet) Add(r Rule) { 202 | s.rules = append(s.rules, r) 203 | } 204 | 205 | func (s *RuleSet) Lint(dashboards []Dashboard) (*ResultSet, error) { 206 | resSet := &ResultSet{} 207 | for _, d := range dashboards { 208 | for _, r := range s.rules { 209 | r.Lint(d, resSet) 210 | } 211 | } 212 | return resSet, nil 213 | } 214 | -------------------------------------------------------------------------------- /lint/rules_test.go: -------------------------------------------------------------------------------- 1 | package lint_test 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | 7 | "github.com/grafana/dashboard-linter/lint" 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | func TestCustomRules(t *testing.T) { 12 | sampleDashboard, err := os.ReadFile("testdata/dashboard.json") 13 | assert.NoError(t, err) 14 | 15 | for _, tc := range []struct { 16 | desc string 17 | rule lint.Rule 18 | }{ 19 | { 20 | desc: "Should allow addition of dashboard rule", 21 | rule: lint.NewDashboardRuleFunc( 22 | "test-dashboard-rule", "Test dashboard rule", 23 | func(lint.Dashboard) lint.DashboardRuleResults { 24 | return lint.DashboardRuleResults{Results: []lint.DashboardResult{{ 25 | Result: lint.Result{Severity: lint.Error, Message: "Error found"}, 26 | }}} 27 | }, 28 | ), 29 | }, 30 | { 31 | desc: "Should allow addition of panel rule", 32 | rule: lint.NewPanelRuleFunc( 33 | "test-panel-rule", "Test panel rule", 34 | func(d lint.Dashboard, p lint.Panel) lint.PanelRuleResults { 35 | return lint.PanelRuleResults{Results: []lint.PanelResult{{ 36 | Result: lint.Result{Severity: lint.Error, Message: "Error found"}, 37 | }}} 38 | }, 39 | ), 40 | }, 41 | { 42 | desc: "Should allow addition of target rule", 43 | rule: lint.NewTargetRuleFunc( 44 | "test-target-rule", "Test target rule", 45 | func(lint.Dashboard, lint.Panel, lint.Target) lint.TargetRuleResults { 46 | return lint.TargetRuleResults{Results: []lint.TargetResult{{ 47 | Result: lint.Result{Severity: lint.Error, Message: "Error found"}, 48 | }}} 49 | }, 50 | ), 51 | }, 52 | } { 53 | t.Run(tc.desc, func(t *testing.T) { 54 | rules := lint.RuleSet{} 55 | rules.Add(tc.rule) 56 | 57 | dashboard, err := lint.NewDashboard(sampleDashboard) 58 | assert.NoError(t, err) 59 | 60 | results, err := rules.Lint([]lint.Dashboard{dashboard}) 61 | assert.NoError(t, err) 62 | 63 | // Validate the error was added 64 | assert.GreaterOrEqual(t, len(results.ByRule()[tc.rule.Name()]), 1) 65 | r := results.ByRule()[tc.rule.Name()][0].Result 66 | assert.Equal(t, lint.Result{Severity: lint.Error, Message: "Error found"}, r.Results[0].Result) 67 | }) 68 | } 69 | } 70 | 71 | func TestFixableRules(t *testing.T) { 72 | sampleDashboard, err := os.ReadFile("testdata/dashboard.json") 73 | assert.NoError(t, err) 74 | 75 | rule := lint.NewDashboardRuleFunc( 76 | "test-fixable-rule", "Test fixable rule", 77 | func(d lint.Dashboard) lint.DashboardRuleResults { 78 | rr := lint.DashboardRuleResults{} 79 | rr.AddFixableError(d, "fixing first issue", func(d *lint.Dashboard) { 80 | d.Title += " fixed-once" 81 | }) 82 | rr.AddFixableError(d, "fixing second issue", func(d *lint.Dashboard) { 83 | d.Title += " fixed-twice" 84 | }) 85 | return rr 86 | }, 87 | ) 88 | 89 | rules := lint.RuleSet{} 90 | rules.Add(rule) 91 | 92 | dashboard, err := lint.NewDashboard(sampleDashboard) 93 | assert.NoError(t, err) 94 | 95 | results, err := rules.Lint([]lint.Dashboard{dashboard}) 96 | assert.NoError(t, err) 97 | 98 | results.AutoFix(&dashboard) 99 | 100 | assert.Equal(t, "Sample dashboard fixed-once fixed-twice", dashboard.Title) 101 | } 102 | -------------------------------------------------------------------------------- /lint/target_utils.go: -------------------------------------------------------------------------------- 1 | package lint 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/prometheus/prometheus/model/labels" 7 | ) 8 | 9 | func checkForMatcher(selector []*labels.Matcher, name string, ty labels.MatchType, value string) error { 10 | var result error 11 | result = fmt.Errorf("%s selector not found", name) 12 | 13 | for _, matcher := range selector { 14 | if matcher.Name != name { 15 | continue 16 | } 17 | if matcher.Type == ty && matcher.Value == value { 18 | result = nil 19 | break 20 | } 21 | 22 | if matcher.Type != ty { 23 | result = fmt.Errorf("%s selector is %s, not %s", name, matcher.Type, ty) 24 | } 25 | 26 | if matcher.Value != value { 27 | result = fmt.Errorf("%s selector is %s, not %s", name, matcher.Value, value) 28 | } 29 | } 30 | 31 | return result 32 | } 33 | -------------------------------------------------------------------------------- /lint/testdata/dashboard.json: -------------------------------------------------------------------------------- 1 | { 2 | "editable": true, 3 | "__inputs": [ 4 | { 5 | "name": "DS_PROMETHEUS", 6 | "label": "prom", 7 | "type": "datasource", 8 | "pluginId": "prom" 9 | } 10 | ], 11 | "annotations": { 12 | "list": [ 13 | { 14 | "builtIn": 1, 15 | "datasource": { 16 | "type": "grafana", 17 | "uid": "-- Grafana --" 18 | }, 19 | "enable": true, 20 | "hide": true, 21 | "name": "Annotations & Alerts", 22 | "type": "dashboard" 23 | } 24 | ] 25 | }, 26 | "rows": [ 27 | { 28 | "panels": [ 29 | { 30 | "type": "timeseries", 31 | "title": "Timeseries", 32 | "targets": [ 33 | { 34 | "expr": "up{job=\"$job\"}" 35 | } 36 | ] 37 | } 38 | ] 39 | } 40 | ], 41 | "panels": [ 42 | { 43 | "type": "timeseries", 44 | "title": "Timeseries", 45 | "targets": [ 46 | { 47 | "expr": "up{job=\"$job\"}" 48 | } 49 | ] 50 | }, 51 | { 52 | "type": "row", 53 | "title": "Dashboard row", 54 | "panels": [ 55 | { 56 | "type": "timeseries", 57 | "title": "Timeseries", 58 | "targets": [ 59 | { 60 | "expr": "up{job=\"$job\"}" 61 | } 62 | ] 63 | } 64 | ] 65 | } 66 | ], 67 | "templating": { 68 | "list": [ 69 | { 70 | "current": { 71 | "text": "default", 72 | "value": "default" 73 | }, 74 | "hide": 0, 75 | "label": "Data Source", 76 | "name": "datasource", 77 | "options": [], 78 | "query": "prometheus", 79 | "refresh": 1, 80 | "regex": "", 81 | "type": "datasource" 82 | }, 83 | { 84 | "name": "job", 85 | "label": "job", 86 | "datasource": "$datasource", 87 | "type": "query", 88 | "query": "query_result(up{})", 89 | "multi": true, 90 | "allValue": ".+" 91 | }, 92 | { 93 | "filters": [], 94 | "hide": 0, 95 | "name": "query0", 96 | "skipUrlSync": false, 97 | "type": "adhoc" 98 | }, 99 | { 100 | "current": { 101 | "selected": true, 102 | "text": "10", 103 | "value": "10" 104 | }, 105 | "hide": 0, 106 | "includeAll": false, 107 | "multi": false, 108 | "name": "limit", 109 | "options": [ 110 | { 111 | "selected": true, 112 | "text": "10", 113 | "value": "10" 114 | } 115 | ], 116 | "type": "custom" 117 | } 118 | ] 119 | }, 120 | "title": "Sample dashboard" 121 | } 122 | -------------------------------------------------------------------------------- /lint/variables.go: -------------------------------------------------------------------------------- 1 | package lint 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "net/url" 7 | "regexp" 8 | "strconv" 9 | "strings" 10 | "time" 11 | ) 12 | 13 | // https://grafana.com/docs/grafana/latest/variables/variable-types/global-variables/ 14 | var globalVariables = map[string]interface{}{ 15 | "__rate_interval": "8869990787ms", 16 | "__interval": "4867856611ms", 17 | "__interval_ms": "7781188786", 18 | "__range_ms": "6737667980", 19 | "__range_s": "9397795485", 20 | "__range": "6069770749ms", 21 | "__dashboard": "AwREbnft", 22 | "__from": time.Date(2020, 7, 13, 20, 19, 9, 254000000, time.UTC), 23 | "__to": time.Date(2020, 7, 13, 20, 19, 9, 254000000, time.UTC), 24 | "__name": "name", 25 | "__org": 42, 26 | "__org.name": "orgname", 27 | "__user.id": 42, 28 | "__user.login": "user", 29 | "__user.email": "user@test.com", 30 | "timeFilter": "time > now() - 7d", 31 | "__timeFilter": "time > now() - 7d", 32 | "__auto": "12345ms", 33 | } 34 | 35 | func stringValue(name string, value interface{}, kind, format string) (string, error) { 36 | switch val := value.(type) { 37 | case int: 38 | return strconv.Itoa(val), nil 39 | case time.Time: 40 | // Implements https://grafana.com/docs/grafana/latest/variables/variable-types/global-variables/#__from-and-__to 41 | switch kind { 42 | case "date": 43 | switch format { 44 | case "seconds": 45 | return strconv.FormatInt(val.Unix(), 10), nil 46 | case "iso": 47 | return val.Format(time.RFC3339), nil 48 | default: 49 | return "", fmt.Errorf("Unsupported momentjs time format: %s", format) 50 | } 51 | default: 52 | switch format { 53 | case "date": 54 | return val.Format(time.RFC3339), nil 55 | default: 56 | return strconv.FormatInt(val.UnixMilli(), 10), nil 57 | } 58 | } 59 | default: 60 | // Use variable name as sample value 61 | svalue := fmt.Sprintf("%s", value) 62 | // For list types, repeat it 3 times (arbitrary value) 63 | svalueList := []string{svalue, svalue, svalue} 64 | // Implements https://grafana.com/docs/grafana/latest/variables/advanced-variable-format-options/ 65 | switch format { 66 | case "csv": 67 | return strings.Join(svalueList, ","), nil 68 | case "doublequote": 69 | return "\"" + strings.Join(svalueList, "\",\"") + "\"", nil 70 | case "glob": 71 | return "{" + strings.Join(svalueList, ",") + "}", nil 72 | case "json": 73 | data, err := json.Marshal(svalueList) 74 | if err != nil { 75 | return "", err 76 | } 77 | return string(data), nil 78 | case "lucene": 79 | return "(\"" + strings.Join(svalueList, "\" OR \"") + "\")", nil 80 | case "percentencode": 81 | return url.QueryEscape(strings.Join(svalueList, ",")), nil 82 | case "pipe": 83 | return strings.Join(svalueList, "|"), nil 84 | case "raw": 85 | return strings.Join(svalueList, ","), nil 86 | case "regex": 87 | return strings.Join(svalueList, "|"), nil 88 | case "singlequote": 89 | return "'" + strings.Join(svalueList, "','") + "'", nil 90 | case "sqlstring": 91 | return "'" + strings.Join(svalueList, "','") + "'", nil 92 | case "text": 93 | return strings.Join(svalueList, " + "), nil 94 | case "queryparam": 95 | values := url.Values{} 96 | for _, svalue := range svalueList { 97 | values.Add("var-"+name, svalue) 98 | } 99 | return values.Encode(), nil 100 | default: 101 | return svalue, nil 102 | } 103 | } 104 | } 105 | 106 | func removeVariableByName(name string, variables []Template) []Template { 107 | vars := make([]Template, 0, len(variables)) 108 | for _, v := range variables { 109 | if v.Name == name { 110 | continue 111 | } 112 | vars = append(vars, v) 113 | } 114 | return vars 115 | } 116 | 117 | func variableSampleValue(s string, variables []Template) (string, error) { 118 | var name, kind, format string 119 | parts := strings.Split(s, ":") 120 | switch len(parts) { 121 | case 1: 122 | // No format 123 | name = s 124 | case 2: 125 | // Could be __from:date, variable:csv, ... 126 | name = parts[0] 127 | format = parts[1] 128 | case 3: 129 | // Could be __from:date:iso, ... 130 | name = parts[0] 131 | kind = parts[1] 132 | format = parts[2] 133 | default: 134 | return "", fmt.Errorf("unknown variable format: %s", s) 135 | } 136 | // If it is part of the globals, return a string representation of a sample value 137 | if value, ok := globalVariables[name]; ok { 138 | return stringValue(name, value, kind, format) 139 | } 140 | // If it is an auto interval variable, replace with a sample value of 10s 141 | if strings.HasPrefix(name, "__auto_interval") { 142 | return "10s", nil 143 | } 144 | // If it is a template variable and we have a value, we use it 145 | for _, v := range variables { 146 | if v.Name != name { 147 | continue 148 | } 149 | // if it has a current value, use it 150 | c, err := v.Current.Get() 151 | if err != nil { 152 | return "", err 153 | } 154 | if c.Value != "" { 155 | // Recursively expand, without the current variable to avoid infinite recursion 156 | return expandVariables(c.Value, removeVariableByName(name, variables)) 157 | } 158 | // If it has options, use the first option 159 | if len(v.Options) > 0 { 160 | // Recursively expand, without the current variable to avoid infinite recursion 161 | o, err := v.Options[0].Get() 162 | if err != nil { 163 | return "", err 164 | } 165 | return expandVariables(o.Value, removeVariableByName(name, variables)) 166 | } 167 | } 168 | // Assume variable type is a string 169 | return stringValue(name, name, kind, format) 170 | } 171 | 172 | var variableRegexp = regexp.MustCompile( 173 | strings.Join([]string{ 174 | `\$([[:word:]]+)`, // $var syntax 175 | `\$\{([^}]+)\}`, // ${var} syntax 176 | `\[\[([^\[\]]+)\]\]`, // [[var]] syntax 177 | }, "|"), 178 | ) 179 | 180 | func expandVariables(expr string, variables []Template) (string, error) { 181 | parts := strings.Split(expr, "\"") 182 | for i, part := range parts { 183 | if i%2 == 1 { 184 | // Inside a double quote string, just add it 185 | continue 186 | } 187 | 188 | // Accumulator to store the processed submatches 189 | var subparts []string 190 | // Cursor indicates where we are in the part being processed 191 | cursor := 0 192 | for _, v := range variableRegexp.FindAllStringSubmatchIndex(part, -1) { 193 | // Add all until match starts 194 | subparts = append(subparts, part[cursor:v[0]]) 195 | // Iterate on all the subgroups and find the one that matched 196 | for j := 2; j < len(v); j += 2 { 197 | if v[j] < 0 { 198 | continue 199 | } 200 | // Replace the match with sample value 201 | val, err := variableSampleValue(part[v[j]:v[j+1]], variables) 202 | if err != nil { 203 | return "", err 204 | } 205 | subparts = append(subparts, val) 206 | } 207 | // Move the start cursor at the end of the current match 208 | cursor = v[1] 209 | } 210 | // Add rest of the string 211 | subparts = append(subparts, parts[i][cursor:]) 212 | // Merge all back into the parts 213 | parts[i] = strings.Join(subparts, "") 214 | } 215 | return strings.Join(parts, "\""), nil 216 | } 217 | 218 | func expandLogQLVariables(expr string, variables []Template) (string, error) { 219 | lines := strings.Split(expr, "\n") 220 | for i, line := range lines { 221 | parts := strings.Split(line, "\"") 222 | for j, part := range parts { 223 | if j%2 == 1 { 224 | // Inside a double quote string, just add it 225 | continue 226 | } 227 | 228 | // Accumulator to store the processed submatches 229 | var subparts []string 230 | // Cursor indicates where we are in the part being processed 231 | cursor := 0 232 | for _, v := range variableRegexp.FindAllStringSubmatchIndex(part, -1) { 233 | // Add all until match starts 234 | subparts = append(subparts, part[cursor:v[0]]) 235 | // Iterate on all the subgroups and find the one that matched 236 | for k := 2; k < len(v); k += 2 { 237 | if v[k] < 0 { 238 | continue 239 | } 240 | // Replace the match with sample value 241 | val, err := variableSampleValue(part[v[k]:v[k+1]], variables) 242 | if err != nil { 243 | return "", err 244 | } 245 | // If the variable is within square brackets, remove the '$' prefix 246 | if strings.HasPrefix(part[v[0]-1:v[0]], "[") && strings.HasSuffix(part[v[1]:v[1]+1], "]") { 247 | val = strings.TrimPrefix(val, "$") 248 | } 249 | subparts = append(subparts, val) 250 | } 251 | // Move the start cursor at the end of the current match 252 | cursor = v[1] 253 | } 254 | // Add rest of the string 255 | subparts = append(subparts, part[cursor:]) 256 | // Merge all back into the parts 257 | parts[j] = strings.Join(subparts, "") 258 | } 259 | lines[i] = strings.Join(parts, "\"") 260 | } 261 | result := strings.Join(lines, "\n") 262 | return result, nil 263 | } 264 | -------------------------------------------------------------------------------- /lint/variables_test.go: -------------------------------------------------------------------------------- 1 | package lint 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/require" 8 | ) 9 | 10 | func TestVariableExpansion(t *testing.T) { 11 | for _, tc := range []struct { 12 | desc string 13 | expr string 14 | variables []Template 15 | result string 16 | err error 17 | }{ 18 | { 19 | desc: "Should not replace variables in quoted strings", 20 | expr: "up{job=~\"$job\"}", 21 | result: "up{job=~\"$job\"}", 22 | }, 23 | // https://grafana.com/docs/grafana/latest/variables/syntax/ 24 | { 25 | desc: "Should replace variables in metric name", 26 | expr: "up$var{job=~\"$job\"}", 27 | result: "upvar{job=~\"$job\"}", 28 | }, 29 | { 30 | desc: "Should replace global rate/range variables", 31 | expr: "rate(metric{}[$__rate_interval])", 32 | result: "rate(metric{}[8869990787ms])", 33 | }, 34 | { 35 | desc: "Should support ${...} syntax", 36 | expr: "rate(metric{}[${__rate_interval}])", 37 | result: "rate(metric{}[8869990787ms])", 38 | }, 39 | { 40 | desc: "Should support [[...]] syntax", 41 | expr: "rate(metric{}[[[__rate_interval]]])", 42 | result: "rate(metric{}[8869990787ms])", 43 | }, 44 | // https://grafana.com/docs/grafana/latest/variables/variable-types/global-variables/ 45 | { 46 | desc: "Should support ${__user.id}", 47 | expr: "sum(http_requests_total{method=\"GET\"} @ ${__user.id})", 48 | result: "sum(http_requests_total{method=\"GET\"} @ 42)", 49 | }, 50 | { 51 | desc: "Should support $__from/$__to", 52 | expr: "sum(http_requests_total{method=\"GET\"} @ $__from)", 53 | result: "sum(http_requests_total{method=\"GET\"} @ 1594671549254)", 54 | }, 55 | { 56 | desc: "Should support $__from/$__to with formatting option (unix seconds)", 57 | expr: "sum(http_requests_total{method=\"GET\"} @ ${__from:date:seconds}000)", 58 | result: "sum(http_requests_total{method=\"GET\"} @ 1594671549000)", 59 | }, 60 | { 61 | desc: "Should support $__from/$__to with formatting option (iso default)", 62 | expr: "sum(http_requests_total{method=\"GET\"} @ ${__from:date})", 63 | result: "sum(http_requests_total{method=\"GET\"} @ 2020-07-13T20:19:09Z)", 64 | }, 65 | { 66 | desc: "Should support $__from/$__to with formatting option (iso)", 67 | expr: "sum(http_requests_total{method=\"GET\"} @ ${__from:date:iso})", 68 | result: "sum(http_requests_total{method=\"GET\"} @ 2020-07-13T20:19:09Z)", 69 | }, 70 | { 71 | desc: "Should not support $__from/$__to with momentjs formatting option (iso)", 72 | expr: "sum(http_requests_total{method=\"GET\"} @ ${__from:date:YYYY-MM})", 73 | err: fmt.Errorf("Unsupported momentjs time format: YYYY-MM"), 74 | }, 75 | // https://grafana.com/docs/grafana/latest/variables/advanced-variable-format-options/ 76 | { 77 | desc: "Should support ${variable:csv} syntax", 78 | expr: "max by(${variable:csv}) (rate(cpu{}[$__rate_interval]))", 79 | result: "max by(variable,variable,variable) (rate(cpu{}[8869990787ms]))", 80 | }, 81 | { 82 | desc: "Should support ${variable:doublequote} syntax", 83 | expr: "max by(${variable:doublequote}) (rate(cpu{}[$__rate_interval]))", 84 | result: "max by(\"variable\",\"variable\",\"variable\") (rate(cpu{}[8869990787ms]))", 85 | }, 86 | { 87 | desc: "Should support ${variable:glob} syntax", 88 | expr: "max by(${variable:glob}) (rate(cpu{}[$__rate_interval]))", 89 | result: "max by({variable,variable,variable}) (rate(cpu{}[8869990787ms]))", 90 | }, 91 | { 92 | desc: "Should support ${variable:json} syntax", 93 | expr: "max by(${variable:json}) (rate(cpu{}[$__rate_interval]))", 94 | result: "max by([\"variable\",\"variable\",\"variable\"]) (rate(cpu{}[8869990787ms]))", 95 | }, 96 | { 97 | desc: "Should support ${variable:lucene} syntax", 98 | expr: "max by(${variable:lucene}) (rate(cpu{}[$__rate_interval]))", 99 | result: "max by((\"variable\" OR \"variable\" OR \"variable\")) (rate(cpu{}[8869990787ms]))", 100 | }, 101 | { 102 | desc: "Should support ${variable:percentencode} syntax", 103 | expr: "max by(${variable:percentencode}) (rate(cpu{}[$__rate_interval]))", 104 | result: "max by(variable%2Cvariable%2Cvariable) (rate(cpu{}[8869990787ms]))", 105 | }, 106 | { 107 | desc: "Should support ${variable:pipe} syntax", 108 | expr: "max by(${variable:pipe}) (rate(cpu{}[$__rate_interval]))", 109 | result: "max by(variable|variable|variable) (rate(cpu{}[8869990787ms]))", 110 | }, 111 | { 112 | desc: "Should support ${variable:raw} syntax", 113 | expr: "max by(${variable:raw}) (rate(cpu{}[$__rate_interval]))", 114 | result: "max by(variable,variable,variable) (rate(cpu{}[8869990787ms]))", 115 | }, 116 | { 117 | desc: "Should support ${variable:regex} syntax", 118 | expr: "max by(${variable:regex}) (rate(cpu{}[$__rate_interval]))", 119 | result: "max by(variable|variable|variable) (rate(cpu{}[8869990787ms]))", 120 | }, 121 | { 122 | desc: "Should support ${variable:singlequote} syntax", 123 | expr: "max by(${variable:singlequote}) (rate(cpu{}[$__rate_interval]))", 124 | result: "max by('variable','variable','variable') (rate(cpu{}[8869990787ms]))", 125 | }, 126 | { 127 | desc: "Should support ${variable:sqlstring} syntax", 128 | expr: "max by(${variable:sqlstring}) (rate(cpu{}[$__rate_interval]))", 129 | result: "max by('variable','variable','variable') (rate(cpu{}[8869990787ms]))", 130 | }, 131 | { 132 | desc: "Should support ${variable:text} syntax", 133 | expr: "max by(${variable:text}) (rate(cpu{}[$__rate_interval]))", 134 | result: "max by(variable + variable + variable) (rate(cpu{}[8869990787ms]))", 135 | }, 136 | { 137 | desc: "Should support ${variable:queryparam} syntax", 138 | expr: "max by(${variable:queryparam}) (rate(cpu{}[$__rate_interval]))", 139 | result: "max by(var-variable=variable&var-variable=variable&var-variable=variable) (rate(cpu{}[8869990787ms]))", 140 | }, 141 | { 142 | desc: "Should return an error for unknown syntax", 143 | expr: "max by(${a:b:c:d}) (rate(cpu{}[$__rate_interval]))", 144 | err: fmt.Errorf("unknown variable format: a:b:c:d"), 145 | }, 146 | { 147 | desc: "Should replace variables present in the templating", 148 | expr: "max by($var) (rate(cpu{}[$interval:$resolution]))", 149 | variables: []Template{ 150 | { 151 | Name: "interval", 152 | Options: []RawTemplateValue{ 153 | map[string]interface{}{ 154 | "value": "4h", 155 | }, 156 | }, 157 | }, 158 | { 159 | Name: "resolution", 160 | Options: []RawTemplateValue{ 161 | map[string]interface{}{ 162 | "value": "5m", 163 | }, 164 | }}, 165 | { 166 | Name: "var", 167 | Type: "query", 168 | Current: map[string]interface{}{ 169 | "value": "value", 170 | }}, 171 | }, 172 | result: "max by(value) (rate(cpu{}[4h:5m]))", 173 | }, 174 | { 175 | desc: "Should recursively replace variables", 176 | expr: "sum (rate(cpu{}[$interval]))", 177 | variables: []Template{ 178 | {Name: "interval", Current: map[string]interface{}{"value": "$__auto_interval_interval"}}, 179 | }, 180 | result: "sum (rate(cpu{}[10s]))", 181 | }, 182 | { 183 | desc: "Should support plain $__auto_interval, generated by grafonnet-lib (https://github.com/grafana/grafonnet-lib/blob/master/grafonnet/template.libsonnet#L100)", 184 | expr: "sum (rate(cpu{}[$interval]))", 185 | variables: []Template{ 186 | {Name: "interval", Current: map[string]interface{}{"value": "$__auto_interval"}}, 187 | }, 188 | result: "sum (rate(cpu{}[10s]))", 189 | }, 190 | { 191 | desc: "Should recursively replace variables, but not run into an infinite loop", 192 | expr: "sum (rate(cpu{}[$interval]))", 193 | variables: []Template{ 194 | {Name: "interval", Current: map[string]interface{}{"value": "$interval"}}, 195 | }, 196 | result: "sum (rate(cpu{}[interval]))", 197 | }, 198 | } { 199 | s, err := expandVariables(tc.expr, tc.variables) 200 | require.Equal(t, tc.err, err) 201 | require.Equal(t, tc.result, s, tc.desc) 202 | } 203 | } 204 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "os" 7 | "path" 8 | "strings" 9 | 10 | "github.com/spf13/cobra" 11 | "github.com/spf13/viper" 12 | "github.com/zeitlinger/conflate" 13 | 14 | "github.com/grafana/dashboard-linter/lint" 15 | ) 16 | 17 | var lintStrictFlag bool 18 | var lintVerboseFlag bool 19 | var lintAutofixFlag bool 20 | var lintReadFromStdIn bool 21 | var lintConfigFlag string 22 | 23 | // lintCmd represents the lint command 24 | var lintCmd = &cobra.Command{ 25 | Use: "lint [dashboard.json]", 26 | Short: "Lint a dashboard", 27 | Long: `Returns warnings or errors for dashboard which do not adhere to accepted standards`, 28 | PreRun: func(cmd *cobra.Command, args []string) { 29 | _ = viper.BindPFlags(cmd.PersistentFlags()) 30 | }, 31 | SilenceUsage: true, 32 | Args: cobra.RangeArgs(0, 1), 33 | RunE: func(cmd *cobra.Command, args []string) error { 34 | var buf []byte 35 | var err error 36 | var filename string 37 | 38 | if lintReadFromStdIn { 39 | if lintAutofixFlag { 40 | return fmt.Errorf("can't read from stdin and autofix") 41 | } 42 | 43 | buf, err = io.ReadAll(os.Stdin) 44 | if err != nil { 45 | return fmt.Errorf("failed to read stdin: %v", err) 46 | } 47 | } else { 48 | filename = args[0] 49 | buf, err = os.ReadFile(filename) 50 | if err != nil { 51 | return fmt.Errorf("failed to read file %s: %v", filename, err) 52 | } 53 | } 54 | 55 | dashboard, err := lint.NewDashboard(buf) 56 | if err != nil { 57 | return fmt.Errorf("failed to parse dashboard: %v", err) 58 | } 59 | 60 | // if no config flag was passed, set a default path of a .lint file in the dashboards directory 61 | if lintConfigFlag == "" { 62 | lintConfigFlag = path.Join(path.Dir(filename), ".lint") 63 | } 64 | 65 | config := lint.NewConfigurationFile() 66 | if err := config.Load(lintConfigFlag); err != nil { 67 | return fmt.Errorf("failed to load lint config: %v", err) 68 | } 69 | config.Verbose = lintVerboseFlag 70 | config.Autofix = lintAutofixFlag 71 | 72 | rules := lint.NewRuleSet() 73 | results, err := rules.Lint([]lint.Dashboard{dashboard}) 74 | if err != nil { 75 | return fmt.Errorf("failed to lint dashboard: %v", err) 76 | } 77 | 78 | if config.Autofix { 79 | changes := results.AutoFix(&dashboard) 80 | if changes > 0 { 81 | err = write(dashboard, filename, buf) 82 | if err != nil { 83 | return err 84 | } 85 | } 86 | } 87 | 88 | results.Configure(config) 89 | results.ReportByRule() 90 | 91 | if lintStrictFlag && results.MaximumSeverity() >= lint.Warning { 92 | return fmt.Errorf("there were linting errors, please see previous output") 93 | } 94 | return nil 95 | }, 96 | } 97 | 98 | func write(dashboard lint.Dashboard, filename string, old []byte) error { 99 | newBytes, err := dashboard.Marshal() 100 | if err != nil { 101 | return err 102 | } 103 | c := conflate.New() 104 | err = c.AddData(old, newBytes) 105 | if err != nil { 106 | return err 107 | } 108 | b, err := c.MarshalJSON() 109 | if err != nil { 110 | return err 111 | } 112 | json := strings.ReplaceAll(string(b), "\"options\": null,", "\"options\": [],") 113 | 114 | return os.WriteFile(filename, []byte(json), 0600) 115 | } 116 | 117 | var rulesCmd = &cobra.Command{ 118 | Use: "rules", 119 | Short: "Print documentation about each lint rule.", 120 | SilenceUsage: true, 121 | RunE: func(cmd *cobra.Command, args []string) error { 122 | rules := lint.NewRuleSet() 123 | for _, rule := range rules.Rules() { 124 | fmt.Fprintf(os.Stdout, "* `%s` - %s\n", rule.Name(), rule.Description()) 125 | } 126 | return nil 127 | }, 128 | } 129 | 130 | func init() { 131 | rootCmd.AddCommand(lintCmd) 132 | rootCmd.AddCommand(rulesCmd) 133 | lintCmd.Flags().BoolVar( 134 | &lintStrictFlag, 135 | "strict", 136 | false, 137 | "fail upon linting error or warning", 138 | ) 139 | lintCmd.Flags().BoolVar( 140 | &lintVerboseFlag, 141 | "verbose", 142 | false, 143 | "show more information about linting", 144 | ) 145 | lintCmd.Flags().BoolVar( 146 | &lintAutofixFlag, 147 | "fix", 148 | false, 149 | "automatically fix problems if possible", 150 | ) 151 | lintCmd.Flags().StringVarP( 152 | &lintConfigFlag, 153 | "config", 154 | "c", 155 | "", 156 | "path to a configuration file", 157 | ) 158 | lintCmd.Flags().BoolVar( 159 | &lintReadFromStdIn, 160 | "stdin", 161 | false, 162 | "read from stdin", 163 | ) 164 | } 165 | 166 | var rootCmd = &cobra.Command{ 167 | Use: "dashboard-linter", 168 | Short: "A command-line application to lint Grafana dashboards.", 169 | Run: func(cmd *cobra.Command, args []string) { 170 | _ = cmd.Help() 171 | os.Exit(0) 172 | }, 173 | } 174 | 175 | func main() { 176 | if err := rootCmd.Execute(); err != nil { 177 | fmt.Fprintln(os.Stderr, err) 178 | os.Exit(1) 179 | } 180 | } 181 | -------------------------------------------------------------------------------- /scripts/replace-rulenames-with-doclinks.sh: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env bash 2 | 3 | for rulefile in ./docs/rules/*.md 4 | do 5 | rulename=$(basename $rulefile .md) 6 | for docfile in $(find ./docs -regex ".*\.md\|.*_intermediate/.*\.txt" -print) 7 | do 8 | sed -i".bak" "s,\`${rulename}\`,\[${rulename}\]\(./rules/${rulename}.md\),g" "${docfile}" 9 | rm "${docfile}.bak" 10 | done 11 | done 12 | 13 | --------------------------------------------------------------------------------