id: grafana-dashboards-backup-to-git
namespace: company.team
description: |
Export every Grafana dashboard through the Grafana HTTP API, commit the JSON models to a Git
repository (one file per dashboard, grouped by folder) and post a Slack summary of the dashboards
that were added, changed or deleted since the last backup.
inputs:
- id: grafana_url
type: STRING
displayName: Grafana URL
description: Base URL of the Grafana instance, without a trailing slash.
defaults: https://grafana.example.com
- id: git_url
type: STRING
displayName: Backup repository
description: HTTPS URL of the Git repository that stores the backups.
defaults: https://github.com/my-org/grafana-backup
- id: branch
type: STRING
displayName: Branch
defaults: main
- id: path
type: STRING
displayName: Directory in the repository
defaults: dashboards
tasks:
- id: load_hashes
type: io.kestra.plugin.core.kv.Get
description: Content hash of every dashboard file at the last backup. Missing on
the first run.
key: "grafana_backup_{{ inputs.grafana_url | slugify }}"
errorOnMissing: false
- id: export_dashboards
type: io.kestra.plugin.scripts.python.Script
description: Download every dashboard model, write it as stable, sorted JSON
(one file per dashboard, by folder) and compare it with the last backup.
containerImage: python:3.12-slim
env:
GRAFANA_URL: "{{ inputs.grafana_url }}"
GRAFANA_TOKEN: "{{ secret('GRAFANA_TOKEN') }}"
inputFiles:
previous.json: "{{ (outputs.load_hashes.value ?? {}) | toJson }}"
outputFiles:
- "**/*.json"
script: |
import hashlib
import json
import os
import pathlib
import re
import urllib.request
base = os.environ["GRAFANA_URL"].rstrip("/")
with open("previous.json") as f:
previous = json.load(f) or {}
if isinstance(previous, str):
previous = json.loads(previous)
os.remove("previous.json")
def get(path):
request = urllib.request.Request(base + path, headers={
"Authorization": f"Bearer {os.environ['GRAFANA_TOKEN']}", "Accept": "application/json"})
with urllib.request.urlopen(request, timeout=30) as response:
return json.load(response)
def slug(text):
return re.sub(r"[^a-z0-9]+", "-", (text or "general").lower()).strip("-") or "general"
hashes, titles, added, changed = {}, {}, [], []
for hit in get("/api/search?type=dash-db&limit=5000"):
model = get(f"/api/dashboards/uid/{hit['uid']}")["dashboard"]
# id and version change on every save or import and would make every backup a diff.
model.pop("id", None)
model.pop("version", None)
path = f"{slug(hit.get('folderTitle'))}/{hit['uid']}.json"
text = json.dumps(model, indent=2, sort_keys=True, ensure_ascii=False) + "\n"
pathlib.Path(path).parent.mkdir(parents=True, exist_ok=True)
pathlib.Path(path).write_text(text, encoding="utf-8")
hashes[path] = {"sha": hashlib.sha256(text.encode()).hexdigest(),
"title": f"{hit.get('folderTitle') or 'General'} / {hit['title']}"}
if path not in previous:
added.append(hashes[path]["title"])
elif previous[path]["sha"] != hashes[path]["sha"]:
changed.append(hashes[path]["title"])
removed = [v["title"] for k, v in previous.items() if k not in hashes]
print(f"{len(hashes)} dashboards exported: {len(added)} added, {len(changed)} changed, {len(removed)} removed")
print("::" + json.dumps({"outputs": {
"total": len(hashes), "added": added, "changed": changed, "removed": removed,
"change_count": len(added) + len(changed) + len(removed), "hashes": hashes,
}}) + "::")
- id: if_changed
type: io.kestra.plugin.core.flow.If
description: Commit and notify only when at least one dashboard was added,
changed or deleted.
condition: "{{ outputs.export_dashboards.vars.change_count > 0 }}"
then:
- id: push
type: io.kestra.plugin.git.PushExecutionFiles
description: Commit the exported files under `path`. Files of dashboards deleted
in Grafana are removed from the branch (they stay in Git history).
filesMap: "{{ outputs.export_dashboards.outputFiles | toJson }}"
gitDirectory: "{{ inputs.path }}"
delete: true
url: "{{ inputs.git_url }}"
branch: "{{ inputs.branch }}"
username: "{{ secret('GIT_USERNAME') }}"
password: "{{ secret('GIT_TOKEN') }}"
authorName: Kestra Grafana Backup
authorEmail: grafana-backup@kestra.local
commitMessage: "Grafana backup: {{ outputs.export_dashboards.vars.added | length
}} added, {{ outputs.export_dashboards.vars.changed | length }}
changed, {{ outputs.export_dashboards.vars.removed | length }}
removed"
- id: notify
type: io.kestra.plugin.slack.notifications.SlackIncomingWebhook
description: Summarize what changed in Grafana since the last backup.
url: "{{ secret('SLACK_WEBHOOK_URL') }}"
messageText: |
:bar_chart: *Grafana backup committed* to {{ inputs.git_url }} ({{ inputs.branch }}), {{ outputs.export_dashboards.vars.total }} dashboard(s) in Grafana.
{% if outputs.export_dashboards.vars.added | length > 0 %}*Added:* {{ outputs.export_dashboards.vars.added | join(', ') }}
{% endif %}{% if outputs.export_dashboards.vars.changed | length > 0 %}*Changed:* {{ outputs.export_dashboards.vars.changed | join(', ') }}
{% endif %}{% if outputs.export_dashboards.vars.removed | length > 0 %}*Deleted in Grafana:* {{ outputs.export_dashboards.vars.removed | join(', ') }} (still in Git history)
{% endif %}
- id: remember_hashes
type: io.kestra.plugin.core.kv.Set
description: Store the hashes of what was committed. Runs after the push, so a
failed push is retried on the next run.
key: "grafana_backup_{{ inputs.grafana_url | slugify }}"
kvType: JSON
overwrite: true
value: "{{ outputs.export_dashboards.vars.hashes | toJson }}"
errors:
- id: alert_failure
type: io.kestra.plugin.slack.notifications.SlackIncomingWebhook
description: A missed backup must not go unnoticed.
url: "{{ secret('SLACK_WEBHOOK_URL') }}"
messageText: ":warning: Grafana dashboard backup failed in execution {{
execution.id }} ({{ flow.namespace }}.{{ flow.id }}). Check GRAFANA_TOKEN
and the Git credentials."
triggers:
- id: nightly
type: io.kestra.plugin.core.trigger.Schedule
description: Back up every night. Disabled until the secrets are set.
cron: "0 2 * * *"
disabled: true