runspec-grafana 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,61 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.pyo
5
+ *.pyd
6
+ .Python
7
+ *.egg
8
+ *.egg-info/
9
+ dist/
10
+ build/
11
+ .eggs/
12
+ .venv/
13
+ venv/
14
+ env/
15
+ .env
16
+ pip-wheel-metadata/
17
+ .pytest_cache/
18
+ .mypy_cache/
19
+ .ruff_cache/
20
+ htmlcov/
21
+ .coverage
22
+ coverage.xml
23
+ *.cover
24
+
25
+ # Node
26
+ node_modules/
27
+ dist/
28
+ *.js.map
29
+ .npm
30
+
31
+ # Go
32
+ *.exe
33
+ *.test
34
+ *.out
35
+ vendor/
36
+
37
+ # IDE
38
+ .idea/
39
+ .vscode/
40
+ *.iml
41
+ *.iws
42
+ *.ipr
43
+ .DS_Store
44
+ Thumbs.db
45
+
46
+ # Docs
47
+ site/
48
+
49
+ # Misc
50
+ *.log
51
+ *.tmp
52
+
53
+ # External reference repos (cloned locally, not committed)
54
+ chainlit-docs/
55
+ .chainlit/
56
+
57
+ # Claude Code local config (machine-specific)
58
+ .claude/launch.json
59
+
60
+ # Stray committed test venv (removed from tracking)
61
+ .venv-test/
@@ -0,0 +1,30 @@
1
+ # Changelog
2
+
3
+ All notable changes to `runspec-grafana` are documented here.
4
+
5
+ ## [0.1.0]
6
+
7
+ First release. One runnable, `grafana`, with a verb per read tool of the
8
+ [Grafana MCP server](https://github.com/grafana/mcp-grafana), ported onto the
9
+ Grafana HTTP API (no proxy process):
10
+
11
+ - **Reads** (`autonomous`): `whoami`, `health`, `search-dashboards`,
12
+ `search-folders`, `get-dashboard`, `list-dashboard-versions`,
13
+ `list-datasources`, `get-datasource`, `check-datasource`,
14
+ `query-prometheus`, `query-prometheus-histogram`,
15
+ `list-prometheus-metric-names`, `list-prometheus-label-names`,
16
+ `list-prometheus-label-values`, `list-prometheus-metric-metadata`,
17
+ `query-loki-logs`, `list-loki-label-names`, `list-loki-label-values`,
18
+ `query-loki-stats`, `query-loki-patterns`, `list-alert-rules`,
19
+ `get-alert-rule`, `list-contact-points`, `list-silences`,
20
+ `get-annotations`, `get-annotation-tags`, and `api-get` (GET any `/api/`
21
+ path).
22
+ - **Changes** (`confirm`): `create-annotation`.
23
+ - Compact output by default, with `--include` / `--raw` / `--include-values` /
24
+ `--limit` for more; a person at a terminal gets text, everyone else JSON.
25
+ - Globals `--env` (free-form instance label → `GRAFANA_<ENV>_*`), `--url`,
26
+ `--org-id`, and a `password`-typed `--grafana-token` for the runspec-console
27
+ secret channel. With nothing set it talks to `http://127.0.0.1:3000`.
28
+ - Not ported yet: dashboard and alert-rule writes, silences writes, OnCall,
29
+ Incident, Tempo, Pyroscope, SQL/CloudWatch/Elasticsearch queries, panel
30
+ rendering, and the Grafana Assistant.
@@ -0,0 +1,113 @@
1
+ Metadata-Version: 2.5
2
+ Name: runspec-grafana
3
+ Version: 0.1.0
4
+ Summary: Grafana runnable for runspec — dashboards, datasources, PromQL and LogQL through Grafana, alert rules, silences, and annotations, ported from the Grafana MCP server
5
+ Project-URL: Documentation, https://runspec.app/
6
+ Project-URL: Source, https://github.com/jasonfinestone/runspec/tree/main/packages/python/runspec-grafana
7
+ Project-URL: Changelog, https://github.com/jasonfinestone/runspec/blob/main/packages/python/runspec-grafana/CHANGELOG.md
8
+ Project-URL: Issues, https://github.com/jasonfinestone/runspec/issues
9
+ Keywords: grafana,loki,mcp,monitoring,observability,prometheus,runnable,runspec
10
+ Requires-Python: >=3.10
11
+ Requires-Dist: runspec-grafana-core>=0.1.0
12
+ Requires-Dist: runspec>=0.73.1
13
+ Provides-Extra: dev
14
+ Requires-Dist: mypy; extra == 'dev'
15
+ Requires-Dist: pytest>=8.0; extra == 'dev'
16
+ Requires-Dist: ruff==0.15.20; extra == 'dev'
17
+ Description-Content-Type: text/markdown
18
+
19
+ # runspec-grafana
20
+
21
+ A Grafana runnable for [runspec](https://pypi.org/project/runspec/), ported from
22
+ the read tools of the [Grafana MCP server](https://github.com/grafana/mcp-grafana)
23
+ onto the Grafana HTTP API — **not** proxying that server. One runnable —
24
+ **`grafana`** — with a verb per tool. `pip install` it into a venv and it's
25
+ discoverable by `runspec local`, `runspec serve` (MCP), and runspec-console.
26
+
27
+ `runspec serve` flattens each verb into a leaf MCP tool
28
+ (`grafana_search-dashboards`, `grafana_query-prometheus`, …).
29
+
30
+ ## Install
31
+
32
+ ```
33
+ pip install runspec-grafana
34
+ ```
35
+
36
+ ## Verbs
37
+
38
+ All `autonomous` (read-only) except `create-annotation`, which is `confirm`.
39
+
40
+ | Area | Verbs |
41
+ |---|---|
42
+ | Identity | `whoami`, `health` |
43
+ | Dashboards | `search-dashboards`, `search-folders`, `get-dashboard`, `list-dashboard-versions` |
44
+ | Datasources | `list-datasources`, `get-datasource`, `check-datasource` |
45
+ | Prometheus | `query-prometheus`, `query-prometheus-histogram`, `list-prometheus-metric-names`, `list-prometheus-label-names`, `list-prometheus-label-values`, `list-prometheus-metric-metadata` |
46
+ | Loki | `query-loki-logs`, `list-loki-label-names`, `list-loki-label-values`, `query-loki-stats`, `query-loki-patterns` |
47
+ | Alerting | `list-alert-rules`, `get-alert-rule`, `list-contact-points`, `list-silences` |
48
+ | Annotations | `get-annotations`, `get-annotation-tags`, `create-annotation` |
49
+ | Anything else | `api-get` (GET any `/api/` path, returned as-is) |
50
+
51
+ PromQL and LogQL run through Grafana's datasource proxy, so only the Grafana
52
+ token is needed — never the datasource's own credentials. `--datasource` takes a
53
+ UID or a name; without it the default (or only) Prometheus or Loki datasource is
54
+ used.
55
+
56
+ ## Output
57
+
58
+ Results are compact by default, because an agent re-reads every tool result on
59
+ each later step:
60
+
61
+ - `get-dashboard` returns a summary: folder, tags, variables, and one row per
62
+ panel. `--include queries` adds each panel's query text, `--panel <id>` keeps
63
+ one panel, `--raw` returns the full dashboard JSON.
64
+ - `query-prometheus --query-type range` summarises each series (points, first,
65
+ last, min, max); `--include-values` returns every sample.
66
+ - `query-loki-logs` lists each stream's labels once and gives the lines newest
67
+ first, each cut to `--max-line-chars` (500).
68
+ - `list-alert-rules` puts firing rules first with counts by state;
69
+ `--include alerts` adds the active alert instances.
70
+ - Every list takes `--limit` and says `truncated: true` when it cut something.
71
+
72
+ A person at a terminal gets readable text; agents, pipes, the console's Forms
73
+ tab and schedulers get JSON.
74
+
75
+ Times accept Unix seconds or milliseconds, RFC 3339, `now-1h`, or a duration
76
+ back from now (`5m`, `1h30m`).
77
+
78
+ ## Auth — env vars
79
+
80
+ ```
81
+ export GRAFANA_URL=https://grafana.example.com # default http://127.0.0.1:3000
82
+ export GRAFANA_SERVICE_ACCOUNT_TOKEN=glsa_... # or GRAFANA_TOKEN, or:
83
+ export GRAFANA_USERNAME=<user> GRAFANA_PASSWORD=<password>
84
+ export GRAFANA_ORG_ID=2 # X-Grafana-Org-Id
85
+ export GRAFANA_CA_BUNDLE=/etc/pki/internal-ca.pem # private CA
86
+ ```
87
+
88
+ Use a [service account](https://grafana.com/docs/grafana/latest/administration/service-accounts/)
89
+ token. The **Viewer** role covers every read except `list-dashboard-versions`;
90
+ that and `create-annotation` need **Editor**.
91
+
92
+ `--env <label>` reads `GRAFANA_<LABEL>_*` first (`--env prod-eu` →
93
+ `GRAFANA_PROD_EU_URL`). `--url`, `--org-id` and `--grafana-token` override the
94
+ environment; the token arg is a `password` type, so it comes through the
95
+ runspec-console secret channel and never on a command line. A runspec-console
96
+ credential labelled `Grafana` of the token kind supplies `GRAFANA_TOKEN`, and
97
+ one of the username + password kind supplies `GRAFANA_USERNAME`/`PASSWORD`,
98
+ with no extra configuration.
99
+
100
+ ## Building your own runnables
101
+
102
+ The logic lives in [`runspec-grafana-core`](../runspec-grafana-core), which has
103
+ no runspec dependency and surfaces no runnables. A private package can depend on
104
+ it alone, build a `GrafanaConfig` in code, and wrap the helpers as narrower
105
+ runnables — for example a `checkout-errors` runnable that runs one fixed LogQL
106
+ query for a team:
107
+
108
+ ```python
109
+ from runspec_grafana_core import config_from_env, query_loki_logs
110
+
111
+ cfg = config_from_env("prod")
112
+ errors = query_loki_logs(cfg, '{app="checkout"} |= "level=error"', start="30m", limit=20)
113
+ ```
@@ -0,0 +1,95 @@
1
+ # runspec-grafana
2
+
3
+ A Grafana runnable for [runspec](https://pypi.org/project/runspec/), ported from
4
+ the read tools of the [Grafana MCP server](https://github.com/grafana/mcp-grafana)
5
+ onto the Grafana HTTP API — **not** proxying that server. One runnable —
6
+ **`grafana`** — with a verb per tool. `pip install` it into a venv and it's
7
+ discoverable by `runspec local`, `runspec serve` (MCP), and runspec-console.
8
+
9
+ `runspec serve` flattens each verb into a leaf MCP tool
10
+ (`grafana_search-dashboards`, `grafana_query-prometheus`, …).
11
+
12
+ ## Install
13
+
14
+ ```
15
+ pip install runspec-grafana
16
+ ```
17
+
18
+ ## Verbs
19
+
20
+ All `autonomous` (read-only) except `create-annotation`, which is `confirm`.
21
+
22
+ | Area | Verbs |
23
+ |---|---|
24
+ | Identity | `whoami`, `health` |
25
+ | Dashboards | `search-dashboards`, `search-folders`, `get-dashboard`, `list-dashboard-versions` |
26
+ | Datasources | `list-datasources`, `get-datasource`, `check-datasource` |
27
+ | Prometheus | `query-prometheus`, `query-prometheus-histogram`, `list-prometheus-metric-names`, `list-prometheus-label-names`, `list-prometheus-label-values`, `list-prometheus-metric-metadata` |
28
+ | Loki | `query-loki-logs`, `list-loki-label-names`, `list-loki-label-values`, `query-loki-stats`, `query-loki-patterns` |
29
+ | Alerting | `list-alert-rules`, `get-alert-rule`, `list-contact-points`, `list-silences` |
30
+ | Annotations | `get-annotations`, `get-annotation-tags`, `create-annotation` |
31
+ | Anything else | `api-get` (GET any `/api/` path, returned as-is) |
32
+
33
+ PromQL and LogQL run through Grafana's datasource proxy, so only the Grafana
34
+ token is needed — never the datasource's own credentials. `--datasource` takes a
35
+ UID or a name; without it the default (or only) Prometheus or Loki datasource is
36
+ used.
37
+
38
+ ## Output
39
+
40
+ Results are compact by default, because an agent re-reads every tool result on
41
+ each later step:
42
+
43
+ - `get-dashboard` returns a summary: folder, tags, variables, and one row per
44
+ panel. `--include queries` adds each panel's query text, `--panel <id>` keeps
45
+ one panel, `--raw` returns the full dashboard JSON.
46
+ - `query-prometheus --query-type range` summarises each series (points, first,
47
+ last, min, max); `--include-values` returns every sample.
48
+ - `query-loki-logs` lists each stream's labels once and gives the lines newest
49
+ first, each cut to `--max-line-chars` (500).
50
+ - `list-alert-rules` puts firing rules first with counts by state;
51
+ `--include alerts` adds the active alert instances.
52
+ - Every list takes `--limit` and says `truncated: true` when it cut something.
53
+
54
+ A person at a terminal gets readable text; agents, pipes, the console's Forms
55
+ tab and schedulers get JSON.
56
+
57
+ Times accept Unix seconds or milliseconds, RFC 3339, `now-1h`, or a duration
58
+ back from now (`5m`, `1h30m`).
59
+
60
+ ## Auth — env vars
61
+
62
+ ```
63
+ export GRAFANA_URL=https://grafana.example.com # default http://127.0.0.1:3000
64
+ export GRAFANA_SERVICE_ACCOUNT_TOKEN=glsa_... # or GRAFANA_TOKEN, or:
65
+ export GRAFANA_USERNAME=<user> GRAFANA_PASSWORD=<password>
66
+ export GRAFANA_ORG_ID=2 # X-Grafana-Org-Id
67
+ export GRAFANA_CA_BUNDLE=/etc/pki/internal-ca.pem # private CA
68
+ ```
69
+
70
+ Use a [service account](https://grafana.com/docs/grafana/latest/administration/service-accounts/)
71
+ token. The **Viewer** role covers every read except `list-dashboard-versions`;
72
+ that and `create-annotation` need **Editor**.
73
+
74
+ `--env <label>` reads `GRAFANA_<LABEL>_*` first (`--env prod-eu` →
75
+ `GRAFANA_PROD_EU_URL`). `--url`, `--org-id` and `--grafana-token` override the
76
+ environment; the token arg is a `password` type, so it comes through the
77
+ runspec-console secret channel and never on a command line. A runspec-console
78
+ credential labelled `Grafana` of the token kind supplies `GRAFANA_TOKEN`, and
79
+ one of the username + password kind supplies `GRAFANA_USERNAME`/`PASSWORD`,
80
+ with no extra configuration.
81
+
82
+ ## Building your own runnables
83
+
84
+ The logic lives in [`runspec-grafana-core`](../runspec-grafana-core), which has
85
+ no runspec dependency and surfaces no runnables. A private package can depend on
86
+ it alone, build a `GrafanaConfig` in code, and wrap the helpers as narrower
87
+ runnables — for example a `checkout-errors` runnable that runs one fixed LogQL
88
+ query for a team:
89
+
90
+ ```python
91
+ from runspec_grafana_core import config_from_env, query_loki_logs
92
+
93
+ cfg = config_from_env("prod")
94
+ errors = query_loki_logs(cfg, '{app="checkout"} |= "level=error"', start="30m", limit=20)
95
+ ```
@@ -0,0 +1,47 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "runspec-grafana"
7
+ version = "0.1.0"
8
+ requires-python = ">=3.10"
9
+ description = "Grafana runnable for runspec — dashboards, datasources, PromQL and LogQL through Grafana, alert rules, silences, and annotations, ported from the Grafana MCP server"
10
+ readme = "README.md"
11
+ keywords = ["runspec", "runnable", "grafana", "prometheus", "loki", "monitoring", "observability", "mcp"]
12
+ dependencies = [
13
+ # 0.73.1 keeps httpx's INFO request lines off stdout, so the JSON result
14
+ # is the only thing an agent reads.
15
+ "runspec>=0.73.1",
16
+ "runspec-grafana-core>=0.1.0",
17
+ ]
18
+
19
+ [project.urls]
20
+ Documentation = "https://runspec.app/"
21
+ Source = "https://github.com/jasonfinestone/runspec/tree/main/packages/python/runspec-grafana"
22
+ Changelog = "https://github.com/jasonfinestone/runspec/blob/main/packages/python/runspec-grafana/CHANGELOG.md"
23
+ Issues = "https://github.com/jasonfinestone/runspec/issues"
24
+
25
+ [project.scripts]
26
+ # Entry point name MUST match the runnable name in runspec.toml (`grafana`).
27
+ grafana = "runspec_grafana.cli:main"
28
+
29
+ [project.optional-dependencies]
30
+ dev = [
31
+ "ruff==0.15.20",
32
+ "mypy",
33
+ "pytest>=8.0",
34
+ ]
35
+
36
+ [tool.pytest.ini_options]
37
+ testpaths = ["tests"]
38
+
39
+ [tool.mypy]
40
+ python_version = "3.10"
41
+
42
+ [tool.ruff]
43
+ line-length = 200
44
+ target-version = "py310"
45
+
46
+ [tool.ruff.lint]
47
+ select = ["E", "F", "I", "UP", "B", "SIM"]
@@ -0,0 +1,15 @@
1
+ """runspec-grafana — Grafana as a runspec runnable (the ``grafana`` runnable).
2
+
3
+ Ported from the read tools of the
4
+ `Grafana MCP server <https://github.com/grafana/mcp-grafana>`_. The pure HTTP
5
+ logic lives in ``runspec-grafana-core`` (importable with **no** runspec
6
+ dependency); this package wraps each helper as a verb of the ``grafana``
7
+ runnable and is what ``runspec local`` / ``runspec serve`` discover.
8
+
9
+ The core helpers are re-exported here for convenience, so a caller that already
10
+ depends on ``runspec-grafana`` can ``from runspec_grafana import search_dashboards``
11
+ without also naming ``runspec-grafana-core``.
12
+ """
13
+
14
+ from runspec_grafana_core import * # noqa: F403
15
+ from runspec_grafana_core import __all__ # noqa: F401
@@ -0,0 +1,274 @@
1
+ """cli.py — the ``grafana`` entry point.
2
+
3
+ One binary, a verb per Grafana MCP read tool. ``rs.parse("grafana")`` resolves
4
+ the subcommand and its args; we build a :class:`GrafanaConfig` from the
5
+ environment (scoped by the global ``--env``, with ``--url`` / ``--org-id`` /
6
+ ``--grafana-token`` overriding), unpack each ``.value`` at the boundary, and
7
+ call the matching :mod:`runspec_grafana_core` helper.
8
+
9
+ Agents, pipes, the console's Forms tab and schedulers get compact JSON; a
10
+ person at a terminal gets readable text (:mod:`runspec_grafana.render`).
11
+ Failures (:class:`GrafanaError` and arg-shape ``ValueError``s) become a JSON
12
+ ``{"error": ...}`` payload + a non-zero exit.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ import sys
19
+ from typing import Any
20
+
21
+ import runspec as rs
22
+ from runspec_grafana_core import (
23
+ GrafanaConfig,
24
+ GrafanaError,
25
+ api_get,
26
+ check_datasources,
27
+ config_from_env,
28
+ create_annotation,
29
+ get_alert_rule,
30
+ get_annotation_tags,
31
+ get_annotations,
32
+ get_dashboard,
33
+ get_datasource,
34
+ health,
35
+ list_alert_rules,
36
+ list_contact_points,
37
+ list_dashboard_versions,
38
+ list_datasources,
39
+ list_loki_label_names,
40
+ list_loki_label_values,
41
+ list_prometheus_label_names,
42
+ list_prometheus_label_values,
43
+ list_prometheus_metric_metadata,
44
+ list_prometheus_metric_names,
45
+ list_silences,
46
+ query_loki_logs,
47
+ query_loki_patterns,
48
+ query_loki_stats,
49
+ query_prometheus,
50
+ query_prometheus_histogram,
51
+ search_dashboards,
52
+ search_folders,
53
+ whoami,
54
+ )
55
+
56
+ from runspec_grafana.render import human_text
57
+
58
+
59
+ def _emit(payload: Any, *, text: str | None = None) -> None:
60
+ print(text if text is not None else json.dumps(payload, default=str))
61
+
62
+
63
+ def _list(arg: rs.Arg) -> list[str]:
64
+ return list(arg.value) if arg.value else []
65
+
66
+
67
+ def _dispatch(spec: rs.RunSpec, config: GrafanaConfig) -> Any:
68
+ """Run the active verb against ``config``, unpacking ``spec.<arg>.value`` for
69
+ only the args the verb declares (globals + its own)."""
70
+ command = spec.runspec_command
71
+
72
+ # ── identity / dashboards ──
73
+ if command == "whoami":
74
+ return whoami(config)
75
+ if command == "health":
76
+ return health(config)
77
+ if command == "search-dashboards":
78
+ return search_dashboards(
79
+ config,
80
+ query=spec.query.value,
81
+ tags=_list(spec.tag),
82
+ folder_uids=_list(spec.folder_uid),
83
+ starred=spec.starred.value,
84
+ limit=spec.limit.value,
85
+ )
86
+ if command == "search-folders":
87
+ return search_folders(config, query=spec.query.value, limit=spec.limit.value)
88
+ if command == "get-dashboard":
89
+ return get_dashboard(
90
+ config,
91
+ spec.uid.value,
92
+ version=spec.version.value,
93
+ include=_list(spec.include),
94
+ panel_id=spec.panel.value,
95
+ raw=spec.raw.value,
96
+ )
97
+ if command == "list-dashboard-versions":
98
+ return list_dashboard_versions(config, spec.uid.value, limit=spec.limit.value)
99
+
100
+ # ── datasources ──
101
+ if command == "list-datasources":
102
+ return list_datasources(config, ds_type=spec.type.value, limit=spec.limit.value)
103
+ if command == "get-datasource":
104
+ return get_datasource(config, spec.datasource.value)
105
+ if command == "check-datasource":
106
+ return check_datasources(config, refs=_list(spec.datasource), limit=spec.limit.value)
107
+
108
+ # ── Prometheus ──
109
+ if command == "query-prometheus":
110
+ return query_prometheus(
111
+ config,
112
+ spec.query.value,
113
+ datasource=spec.datasource.value,
114
+ query_type=spec.query_type.value,
115
+ at=spec.time.value,
116
+ start=spec.start.value,
117
+ end=spec.end.value,
118
+ step=spec.step.value,
119
+ limit=spec.limit.value,
120
+ include_values=spec.include_values.value,
121
+ )
122
+ if command == "query-prometheus-histogram":
123
+ return query_prometheus_histogram(
124
+ config,
125
+ spec.metric.value,
126
+ percentile=spec.percentile.value,
127
+ selector=spec.selector.value,
128
+ by=_list(spec.by),
129
+ rate_interval=spec.rate_interval.value,
130
+ datasource=spec.datasource.value,
131
+ query_type=spec.query_type.value,
132
+ at=spec.time.value,
133
+ start=spec.start.value,
134
+ end=spec.end.value,
135
+ step=spec.step.value,
136
+ limit=spec.limit.value,
137
+ include_values=spec.include_values.value,
138
+ )
139
+ if command == "list-prometheus-metric-names":
140
+ return list_prometheus_metric_names(
141
+ config,
142
+ datasource=spec.datasource.value,
143
+ regex=spec.regex.value,
144
+ matches=_list(spec.match),
145
+ start=spec.start.value,
146
+ end=spec.end.value,
147
+ limit=spec.limit.value,
148
+ )
149
+ if command == "list-prometheus-label-names":
150
+ return list_prometheus_label_names(config, datasource=spec.datasource.value, matches=_list(spec.match), start=spec.start.value, end=spec.end.value, limit=spec.limit.value)
151
+ if command == "list-prometheus-label-values":
152
+ return list_prometheus_label_values(
153
+ config,
154
+ spec.label.value,
155
+ datasource=spec.datasource.value,
156
+ matches=_list(spec.match),
157
+ start=spec.start.value,
158
+ end=spec.end.value,
159
+ limit=spec.limit.value,
160
+ )
161
+ if command == "list-prometheus-metric-metadata":
162
+ return list_prometheus_metric_metadata(config, datasource=spec.datasource.value, metric=spec.metric.value, limit=spec.limit.value)
163
+
164
+ # ── Loki ──
165
+ if command == "query-loki-logs":
166
+ return query_loki_logs(
167
+ config,
168
+ spec.query.value,
169
+ datasource=spec.datasource.value,
170
+ start=spec.start.value,
171
+ end=spec.end.value,
172
+ limit=spec.limit.value,
173
+ direction=spec.direction.value,
174
+ max_line_chars=spec.max_line_chars.value,
175
+ include_values=spec.include_values.value,
176
+ )
177
+ if command == "list-loki-label-names":
178
+ return list_loki_label_names(config, datasource=spec.datasource.value, query=spec.query.value, start=spec.start.value, end=spec.end.value, limit=spec.limit.value)
179
+ if command == "list-loki-label-values":
180
+ return list_loki_label_values(
181
+ config,
182
+ spec.label.value,
183
+ datasource=spec.datasource.value,
184
+ query=spec.query.value,
185
+ start=spec.start.value,
186
+ end=spec.end.value,
187
+ limit=spec.limit.value,
188
+ )
189
+ if command == "query-loki-stats":
190
+ return query_loki_stats(config, spec.query.value, datasource=spec.datasource.value, start=spec.start.value, end=spec.end.value)
191
+ if command == "query-loki-patterns":
192
+ return query_loki_patterns(config, spec.query.value, datasource=spec.datasource.value, start=spec.start.value, end=spec.end.value, limit=spec.limit.value)
193
+
194
+ # ── alerting ──
195
+ if command == "list-alert-rules":
196
+ return list_alert_rules(
197
+ config,
198
+ state=spec.state.value,
199
+ search=spec.search.value,
200
+ folder=spec.folder.value,
201
+ datasource=spec.datasource.value,
202
+ include=_list(spec.include),
203
+ limit=spec.limit.value,
204
+ )
205
+ if command == "get-alert-rule":
206
+ return get_alert_rule(config, spec.uid.value, raw=spec.raw.value)
207
+ if command == "list-contact-points":
208
+ return list_contact_points(config, name=spec.name.value, include_settings=spec.include_settings.value, limit=spec.limit.value)
209
+ if command == "list-silences":
210
+ return list_silences(config, state=spec.state.value, limit=spec.limit.value)
211
+
212
+ # ── annotations ──
213
+ if command == "get-annotations":
214
+ return get_annotations(
215
+ config,
216
+ start=spec.start.value,
217
+ end=spec.end.value,
218
+ dashboard_uid=spec.dashboard_uid.value,
219
+ panel_id=spec.panel.value,
220
+ tags=_list(spec.tag),
221
+ match_any=spec.match_any.value,
222
+ type=spec.type.value,
223
+ limit=spec.limit.value,
224
+ )
225
+ if command == "get-annotation-tags":
226
+ return get_annotation_tags(config, tag=spec.tag.value, limit=spec.limit.value)
227
+ if command == "create-annotation":
228
+ return create_annotation(
229
+ config,
230
+ spec.text.value,
231
+ tags=_list(spec.tag),
232
+ dashboard_uid=spec.dashboard_uid.value,
233
+ panel_id=spec.panel.value,
234
+ at=spec.time.value,
235
+ end=spec.end.value,
236
+ )
237
+
238
+ # ── escape hatch ──
239
+ if command == "api-get":
240
+ return api_get(config, spec.path.value, params=_list(spec.param))
241
+
242
+ # require-command = true makes a bare invocation unreachable.
243
+ raise SystemExit("no command — see `grafana --help`") # pragma: no cover
244
+
245
+
246
+ def _wants_raw(spec: rs.RunSpec) -> bool:
247
+ return "raw" in spec and bool(spec.raw.value)
248
+
249
+
250
+ def _terminal() -> bool:
251
+ # runspec's logging replaces sys.stdout with a tee whose isatty() is always
252
+ # False, so ask the real stdout.
253
+ stream = sys.__stdout__
254
+ return bool(stream and stream.isatty())
255
+
256
+
257
+ def main() -> None:
258
+ spec = rs.parse("grafana")
259
+ try:
260
+ config = config_from_env(env=spec.env.value, url=spec.url.value, token=spec.grafana_token.value, org_id=spec.org_id.value)
261
+ result = _dispatch(spec, config)
262
+ except (GrafanaError, ValueError) as exc:
263
+ _emit({"error": str(exc), "kind": type(exc).__name__})
264
+ sys.exit(1)
265
+ # A person at a terminal gets readable text; agents, pipes, the console's
266
+ # Forms tab and schedulers get JSON.
267
+ text = None
268
+ if not spec.runspec_agent and _terminal() and not _wants_raw(spec) and spec.runspec_command != "api-get":
269
+ text = human_text(result)
270
+ _emit(result, text=text)
271
+
272
+
273
+ if __name__ == "__main__":
274
+ main()
@@ -0,0 +1,93 @@
1
+ """render.py — readable text for a person at a terminal.
2
+
3
+ ``grafana`` prints JSON to agents, pipes, the console's Forms tab and
4
+ schedulers. When a person runs it in an interactive terminal the compact views
5
+ read better as text: scalar fields as ``Label value`` lines, the main list as
6
+ a table (or one string per line), and Loki log lines as ``time {labels} line``.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Any
12
+
13
+ _MAX_CELL = 60
14
+
15
+
16
+ def _cell(value: Any) -> str:
17
+ if value is None:
18
+ return ""
19
+ if isinstance(value, dict):
20
+ text = ", ".join(f"{k}={v}" for k, v in value.items())
21
+ elif isinstance(value, list):
22
+ text = ", ".join(_cell(v) for v in value)
23
+ else:
24
+ text = str(value)
25
+ return text if len(text) <= _MAX_CELL else text[: _MAX_CELL - 1] + "…"
26
+
27
+
28
+ def _table(rows: list[dict[str, Any]]) -> list[str]:
29
+ cols: list[str] = []
30
+ for row in rows:
31
+ for key, value in row.items():
32
+ # Long nested parts (queries, alerts, values) stay out of the table.
33
+ if key not in cols and not (isinstance(value, list) and value and isinstance(value[0], (dict, list))):
34
+ cols.append(key)
35
+ widths = {c: max(len(c), *(len(_cell(r.get(c))) for r in rows)) for c in cols}
36
+ lines = [" ".join(c.upper().ljust(widths[c]) for c in cols).rstrip()]
37
+ lines += [" ".join(_cell(r.get(c)).ljust(widths[c]) for c in cols).rstrip() for r in rows]
38
+ return lines
39
+
40
+
41
+ def _logs(payload: dict[str, Any]) -> list[str]:
42
+ streams = payload.get("streams") or []
43
+ lines = []
44
+ for entry in payload.get("entries") or []:
45
+ labels = streams[entry["stream"]]["labels"] if entry.get("stream") is not None and entry["stream"] < len(streams) else {}
46
+ label_text = "{" + ", ".join(f'{k}="{v}"' for k, v in labels.items()) + "}"
47
+ lines.append(f"{entry.get('time', '')} {label_text} {entry.get('line', '')}")
48
+ return lines
49
+
50
+
51
+ def _is_rows(value: Any) -> bool:
52
+ return isinstance(value, list) and bool(value) and all(isinstance(v, dict) for v in value)
53
+
54
+
55
+ def human_text(payload: Any) -> str | None:
56
+ """Text for a terminal, or ``None`` to print JSON instead."""
57
+ if not isinstance(payload, dict):
58
+ return None
59
+ if "entries" in payload and "streams" in payload:
60
+ body = _logs(payload)
61
+ footer = f"{payload.get('line_count', len(body))} lines" + (" (more — raise --limit)" if payload.get("truncated") else "")
62
+ return "\n".join([*body, "", footer]) if body else "No log lines."
63
+
64
+ lists = {k: v for k, v in payload.items() if isinstance(v, list) and k not in ("tags", "orgs", "variables", "warnings", "infos")}
65
+ main_key = next(iter(lists), None)
66
+ header = {k: v for k, v in payload.items() if k not in lists and k != "truncated" and not _is_rows(v)}
67
+ sub_tables = {k: v for k, v in payload.items() if k not in lists and _is_rows(v)}
68
+
69
+ lines: list[str] = []
70
+ if header:
71
+ labels = {k: k.replace("_", " ").capitalize() for k in header}
72
+ width = max(len(label) for label in labels.values())
73
+ lines += [f"{labels[k]:<{width}} {_cell(v) if not isinstance(v, str) else v}" for k, v in header.items()]
74
+ for key, rows in sub_tables.items():
75
+ lines += ["", f"{key.replace('_', ' ').capitalize()}:", *(f" {line}" for line in _table(rows))]
76
+ if main_key is not None:
77
+ rows = lists[main_key]
78
+ lines.append("")
79
+ if not rows:
80
+ lines.append(f"No {main_key.replace('_', ' ')}.")
81
+ elif all(isinstance(r, dict) for r in rows):
82
+ lines += _table(rows)
83
+ # Nested row lists (a panel's queries, a rule's alerts) follow the table.
84
+ for row in rows:
85
+ for key, value in row.items():
86
+ if _is_rows(value):
87
+ name = row.get("title") or row.get("name") or row.get("id")
88
+ lines += ["", f"{name} — {key.replace('_', ' ')}:", *(f" {line}" for line in _table(value))]
89
+ else:
90
+ lines += [_cell(r) for r in rows]
91
+ if payload.get("truncated"):
92
+ lines += ["", "(more — raise --limit)"]
93
+ return "\n".join(lines).strip("\n")
@@ -0,0 +1,324 @@
1
+ #:schema https://runspec.app/runspec.schema.json
2
+
3
+ # runspec-grafana — Grafana as a runspec runnable, ported from the read tools of
4
+ # the Grafana MCP server: https://github.com/grafana/mcp-grafana
5
+ #
6
+ # One runnable, `grafana`, with a verb per tool. Everything is read-only and
7
+ # `autonomous` except create-annotation, which is `confirm`. `runspec serve`
8
+ # re-expands each verb into its own leaf MCP tool (grafana_search-dashboards,
9
+ # grafana_query-prometheus, …), so a runspec-console agent calls them
10
+ # individually — over SSH on any fleet host.
11
+ #
12
+ # The tools are PORTED onto the Grafana HTTP API, not proxied from the MCP
13
+ # server, so no extra process is involved. PromQL and LogQL go through Grafana's
14
+ # datasource proxy, so only the Grafana token is needed, never the datasource's.
15
+ # Auth is env-var driven:
16
+ # GRAFANA_URL (default http://127.0.0.1:3000 — right when this runs on the
17
+ # Grafana host), GRAFANA_SERVICE_ACCOUNT_TOKEN (or GRAFANA_TOKEN) — a service
18
+ # account token, Viewer role covers every read — or GRAFANA_USERNAME +
19
+ # GRAFANA_PASSWORD (basic), GRAFANA_ORG_ID, GRAFANA_CA_BUNDLE,
20
+ # GRAFANA_TLS_VERIFY, GRAFANA_PROXY. Per-instance overrides use a
21
+ # GRAFANA_<ENV>_ prefix, selected with --env.
22
+ #
23
+ # Times accept Unix seconds or milliseconds, RFC 3339, now-1h, or a duration
24
+ # back from now (5m, 1h30m).
25
+
26
+ [config]
27
+ autonomy-default = "confirm"
28
+
29
+ [config.logging]
30
+ store = "per-run"
31
+
32
+ [grafana]
33
+ description = "Search Grafana dashboards and datasources, query Prometheus and Loki through Grafana, and read alert rules, silences, contact points, and annotations"
34
+ output = "json"
35
+ require-command = true
36
+ examples = [
37
+ {cmd = "grafana search-dashboards --query api", description = "Find dashboards with 'api' in the title"},
38
+ {cmd = "grafana get-dashboard --uid abc123 --include queries", description = "A dashboard's panels with the query behind each"},
39
+ {cmd = "grafana query-prometheus --query 'up == 0'", description = "Which targets are down, via the default Prometheus datasource"},
40
+ {cmd = "grafana query-loki-logs --query '{app=\"api\"} |= \"error\"' --start 30m", description = "Recent error lines from one app"},
41
+ {cmd = "grafana list-alert-rules --state firing --include alerts", description = "Firing alerts and the series behind them"},
42
+ {cmd = "grafana --env prod create-annotation --text 'deploy v2.3' --tag deploy", description = "Mark a deploy on every dashboard that shows deploy annotations"},
43
+ ]
44
+
45
+ # ── global args (inherited by every verb) ──────────────────────────────────
46
+ [grafana.args]
47
+ env = {type = "str", short = "-e", required = false, description = "Instance label: reads GRAFANA_<ENV>_* vars first (e.g. prod, prod-eu), falling back to GRAFANA_*"}
48
+ url = {type = "str", required = false, description = "Grafana base URL; overrides GRAFANA_URL (default http://127.0.0.1:3000)"}
49
+ org-id = {type = "str", required = false, description = "Organization ID (X-Grafana-Org-Id); overrides GRAFANA_ORG_ID. whoami lists the orgs you can reach"}
50
+ grafana-token = {type = "password", required = false, description = "Service account token; overrides GRAFANA_SERVICE_ACCOUNT_TOKEN. Supplied via the runspec-console credential/secret channel (RUNSPEC_GRAFANA_ARG_GRAFANA_TOKEN), never on the command line."}
51
+
52
+ # ── identity / server → autonomous ─────────────────────────────────────────
53
+
54
+ [grafana.commands.whoami]
55
+ description = "Show who the credentials are: login, server-admin flag, current org, and the orgs (with roles) they can reach"
56
+ autonomy = "autonomous"
57
+
58
+ [grafana.commands.health]
59
+ description = "Check the Grafana server: version, commit, and database status"
60
+ autonomy = "autonomous"
61
+
62
+ # ── search / dashboards → autonomous ───────────────────────────────────────
63
+
64
+ [grafana.commands.search-dashboards]
65
+ description = "Find dashboards by title text, tag, folder, or starred; returns uid, title, folder, tags, and URL for each"
66
+ autonomy = "autonomous"
67
+ [grafana.commands.search-dashboards.args]
68
+ query = {type = "str", short = "-q", required = false, description = "Text to find in the dashboard title"}
69
+ tag = {type = "str", multiple = true, required = false, description = "Only dashboards with this tag (repeat; all must match)"}
70
+ folder-uid = {type = "str", multiple = true, required = false, description = "Only dashboards in this folder (UID from search-folders; repeat)"}
71
+ starred = {type = "flag", default = false, description = "Only dashboards the caller starred"}
72
+ limit = {type = "int", default = 50, description = "Most dashboards to return (0 = all)"}
73
+
74
+ [grafana.commands.search-folders]
75
+ description = "Find dashboard folders by title text"
76
+ autonomy = "autonomous"
77
+ [grafana.commands.search-folders.args]
78
+ query = {type = "str", short = "-q", required = false, description = "Text to find in the folder title"}
79
+ limit = {type = "int", default = 50, description = "Most folders to return (0 = all)"}
80
+
81
+ [grafana.commands.get-dashboard]
82
+ description = "Show a dashboard as a compact summary: folder, tags, variables, and one row per panel (title, type, datasource, query count). Add --include queries for each panel's query text, or --raw for the full JSON"
83
+ autonomy = "autonomous"
84
+ [grafana.commands.get-dashboard.args]
85
+ uid = {type = "str", short = "-u", required = true, description = "Dashboard UID (from search-dashboards or the /d/<uid>/ part of its URL)"}
86
+ include = {type = "choice", options = ["queries", "links"], multiple = true, delimiter = ",", required = false, description = "Add parts: queries (each panel's query text + datasource), links (dashboard links)"}
87
+ panel = {type = "int", required = false, description = "Only this panel (its id from the summary)"}
88
+ version = {type = "int", required = false, description = "A saved version number (from list-dashboard-versions) instead of the current dashboard"}
89
+ raw = {type = "flag", default = false, description = "Return the full dashboard JSON as the API sends it (large)"}
90
+
91
+ [grafana.commands.list-dashboard-versions]
92
+ description = "List a dashboard's saved versions, newest first: version, time, author, message"
93
+ autonomy = "autonomous"
94
+ [grafana.commands.list-dashboard-versions.args]
95
+ uid = {type = "str", short = "-u", required = true, description = "Dashboard UID"}
96
+ limit = {type = "int", default = 20, description = "Most versions to return (0 = all)"}
97
+
98
+ # ── datasources → autonomous ───────────────────────────────────────────────
99
+
100
+ [grafana.commands.list-datasources]
101
+ description = "List configured datasources: uid, name, type, and which is the default"
102
+ autonomy = "autonomous"
103
+ [grafana.commands.list-datasources.args]
104
+ type = {type = "str", short = "-t", required = false, description = "Only this plugin type, e.g. prometheus, loki, tempo, postgres"}
105
+ limit = {type = "int", default = 100, description = "Most datasources to return (0 = all)"}
106
+
107
+ [grafana.commands.get-datasource]
108
+ description = "Show one datasource's settings; secrets are named only, never shown"
109
+ autonomy = "autonomous"
110
+ [grafana.commands.get-datasource.args]
111
+ datasource = {type = "str", short = "-d", required = true, description = "Datasource UID or name"}
112
+
113
+ [grafana.commands.check-datasource]
114
+ description = "Run datasource health checks (Grafana's Save & test) and report which fail"
115
+ autonomy = "autonomous"
116
+ [grafana.commands.check-datasource.args]
117
+ datasource = {type = "str", short = "-d", multiple = true, required = false, description = "Datasource UID or name (repeat); all datasources when omitted"}
118
+ limit = {type = "int", default = 50, description = "Most datasources to check when none are named (0 = all)"}
119
+
120
+ # ── Prometheus through Grafana → autonomous ────────────────────────────────
121
+
122
+ [grafana.commands.query-prometheus]
123
+ description = "Run a PromQL query against a Prometheus datasource. Instant by default; --query-type range summarises each series (points, first, last, min, max) unless --include-values"
124
+ autonomy = "autonomous"
125
+ [grafana.commands.query-prometheus.args]
126
+ query = {type = "str", short = "-q", required = true, description = "The PromQL expression"}
127
+ datasource = {type = "str", short = "-d", required = false, description = "Prometheus datasource UID or name; default: the default (or only) Prometheus datasource"}
128
+ query-type = {type = "choice", options = ["instant", "range"], default = "instant", description = "instant: one value per series; range: values over a window"}
129
+ time = {type = "str", required = false, description = "Instant query time (Unix seconds, RFC 3339, now-1h, or 1h). Default now"}
130
+ start = {type = "str", required = false, description = "Range start, same forms. Default 1h before end"}
131
+ end = {type = "str", required = false, description = "Range end. Default now"}
132
+ step = {type = "str", required = false, description = "Range resolution step (e.g. 30s, 5m). Default gives about 250 points"}
133
+ include-values = {type = "flag", default = false, description = "Range queries: return every sample, not just the per-series summary"}
134
+ limit = {type = "int", default = 50, description = "Most series to return (0 = all)"}
135
+
136
+ [grafana.commands.query-prometheus-histogram]
137
+ description = "Compute a percentile (p50, p95, p99 …) from a histogram metric with histogram_quantile; returns the PromQL it ran"
138
+ autonomy = "autonomous"
139
+ [grafana.commands.query-prometheus-histogram.args]
140
+ metric = {type = "str", short = "-m", required = true, description = "Histogram base name, e.g. http_request_duration_seconds (_bucket is added)"}
141
+ percentile = {type = "float", default = 95.0, range = [0.1, 99.99], description = "Percentile, e.g. 50, 95, 99.9"}
142
+ selector = {type = "str", required = false, description = "Label matchers, e.g. job=\"api\",route=\"/login\" (braces optional)"}
143
+ by = {type = "str", multiple = true, required = false, description = "Keep series apart by this label (repeat), e.g. route"}
144
+ rate-interval = {type = "str", default = "5m", description = "rate() window"}
145
+ datasource = {type = "str", short = "-d", required = false, description = "Prometheus datasource UID or name; default: the default Prometheus datasource"}
146
+ query-type = {type = "choice", options = ["instant", "range"], default = "instant", description = "instant: now (or --time); range: over --start..--end"}
147
+ time = {type = "str", required = false, description = "Instant query time. Default now"}
148
+ start = {type = "str", required = false, description = "Range start. Default 1h before end"}
149
+ end = {type = "str", required = false, description = "Range end. Default now"}
150
+ step = {type = "str", required = false, description = "Range resolution step"}
151
+ include-values = {type = "flag", default = false, description = "Range queries: return every sample"}
152
+ limit = {type = "int", default = 50, description = "Most series to return (0 = all)"}
153
+
154
+ [grafana.commands.list-prometheus-metric-names]
155
+ description = "List metric names in a Prometheus datasource, optionally filtered by a regex"
156
+ autonomy = "autonomous"
157
+ [grafana.commands.list-prometheus-metric-names.args]
158
+ regex = {type = "str", short = "-r", required = false, description = "Keep names matching this regex (searched, case-insensitive), e.g. ^http_"}
159
+ match = {type = "str", multiple = true, required = false, description = "Only metrics on series matching this selector (repeat)"}
160
+ datasource = {type = "str", short = "-d", required = false, description = "Prometheus datasource UID or name"}
161
+ start = {type = "str", required = false, description = "Window start"}
162
+ end = {type = "str", required = false, description = "Window end"}
163
+ limit = {type = "int", default = 100, description = "Most names to return (0 = all)"}
164
+
165
+ [grafana.commands.list-prometheus-label-names]
166
+ description = "List label names in a Prometheus datasource, optionally only those on series matching a selector"
167
+ autonomy = "autonomous"
168
+ [grafana.commands.list-prometheus-label-names.args]
169
+ match = {type = "str", multiple = true, required = false, description = "Series selector to filter by (repeat)"}
170
+ datasource = {type = "str", short = "-d", required = false, description = "Prometheus datasource UID or name"}
171
+ start = {type = "str", required = false, description = "Window start"}
172
+ end = {type = "str", required = false, description = "Window end"}
173
+ limit = {type = "int", default = 100, description = "Most names to return (0 = all)"}
174
+
175
+ [grafana.commands.list-prometheus-label-values]
176
+ description = "List the values of one label (e.g. job, instance) in a Prometheus datasource"
177
+ autonomy = "autonomous"
178
+ [grafana.commands.list-prometheus-label-values.args]
179
+ label = {type = "str", short = "-l", required = true, description = "Label name, e.g. job"}
180
+ match = {type = "str", multiple = true, required = false, description = "Series selector to filter by (repeat)"}
181
+ datasource = {type = "str", short = "-d", required = false, description = "Prometheus datasource UID or name"}
182
+ start = {type = "str", required = false, description = "Window start"}
183
+ end = {type = "str", required = false, description = "Window end"}
184
+ limit = {type = "int", default = 100, description = "Most values to return (0 = all)"}
185
+
186
+ [grafana.commands.list-prometheus-metric-metadata]
187
+ description = "Show type, help text, and unit for metrics in a Prometheus datasource"
188
+ autonomy = "autonomous"
189
+ [grafana.commands.list-prometheus-metric-metadata.args]
190
+ metric = {type = "str", short = "-m", required = false, description = "Metric name; all metrics when omitted"}
191
+ datasource = {type = "str", short = "-d", required = false, description = "Prometheus datasource UID or name"}
192
+ limit = {type = "int", default = 50, description = "Most metrics to return (0 = all)"}
193
+
194
+ # ── Loki through Grafana → autonomous ──────────────────────────────────────
195
+
196
+ [grafana.commands.query-loki-logs]
197
+ description = "Run a LogQL query against a Loki datasource. Log queries return lines newest first (labels listed once per stream); metric queries return per-series summaries"
198
+ autonomy = "autonomous"
199
+ [grafana.commands.query-loki-logs.args]
200
+ query = {type = "str", short = "-q", required = true, description = "LogQL, e.g. {app=\"api\"} |= \"error\""}
201
+ datasource = {type = "str", short = "-d", required = false, description = "Loki datasource UID or name; default: the default (or only) Loki datasource"}
202
+ start = {type = "str", required = false, description = "Window start (Unix seconds, RFC 3339, now-1h, or 1h). Default 1h before end"}
203
+ end = {type = "str", required = false, description = "Window end. Default now"}
204
+ direction = {type = "choice", options = ["backward", "forward"], default = "backward", description = "backward: newest first; forward: oldest first"}
205
+ max-line-chars = {type = "int", default = 500, description = "Cut each log line to this many characters (0 = whole line)"}
206
+ include-values = {type = "flag", default = false, description = "Metric queries: return every sample, not just the summary"}
207
+ limit = {type = "int", default = 50, description = "Most log lines (or series) to return (0 = Loki's maximum)"}
208
+
209
+ [grafana.commands.list-loki-label-names]
210
+ description = "List log label names in a Loki datasource"
211
+ autonomy = "autonomous"
212
+ [grafana.commands.list-loki-label-names.args]
213
+ query = {type = "str", short = "-q", required = false, description = "Only labels on streams matching this selector, e.g. {namespace=\"prod\"}"}
214
+ datasource = {type = "str", short = "-d", required = false, description = "Loki datasource UID or name"}
215
+ start = {type = "str", required = false, description = "Window start. Default 1h ago"}
216
+ end = {type = "str", required = false, description = "Window end. Default now"}
217
+ limit = {type = "int", default = 100, description = "Most names to return (0 = all)"}
218
+
219
+ [grafana.commands.list-loki-label-values]
220
+ description = "List the values of one log label (e.g. app, namespace) in a Loki datasource"
221
+ autonomy = "autonomous"
222
+ [grafana.commands.list-loki-label-values.args]
223
+ label = {type = "str", short = "-l", required = true, description = "Label name, e.g. app"}
224
+ query = {type = "str", short = "-q", required = false, description = "Only values on streams matching this selector"}
225
+ datasource = {type = "str", short = "-d", required = false, description = "Loki datasource UID or name"}
226
+ start = {type = "str", required = false, description = "Window start. Default 1h ago"}
227
+ end = {type = "str", required = false, description = "Window end. Default now"}
228
+ limit = {type = "int", default = 100, description = "Most values to return (0 = all)"}
229
+
230
+ [grafana.commands.query-loki-stats]
231
+ description = "Show how much a stream selector covers (streams, chunks, entries, bytes) — check before a broad log query"
232
+ autonomy = "autonomous"
233
+ [grafana.commands.query-loki-stats.args]
234
+ query = {type = "str", short = "-q", required = true, description = "Stream selector, e.g. {app=\"api\"}"}
235
+ datasource = {type = "str", short = "-d", required = false, description = "Loki datasource UID or name"}
236
+ start = {type = "str", required = false, description = "Window start. Default 1h ago"}
237
+ end = {type = "str", required = false, description = "Window end. Default now"}
238
+
239
+ [grafana.commands.query-loki-patterns]
240
+ description = "Show the log patterns Loki detected for a stream selector, most frequent first (needs Loki's pattern ingester)"
241
+ autonomy = "autonomous"
242
+ [grafana.commands.query-loki-patterns.args]
243
+ query = {type = "str", short = "-q", required = true, description = "Stream selector, e.g. {app=\"api\"}"}
244
+ datasource = {type = "str", short = "-d", required = false, description = "Loki datasource UID or name"}
245
+ start = {type = "str", required = false, description = "Window start. Default 1h ago"}
246
+ end = {type = "str", required = false, description = "Window end. Default now"}
247
+ limit = {type = "int", default = 20, description = "Most patterns to return (0 = all)"}
248
+
249
+ # ── alerting → autonomous ──────────────────────────────────────────────────
250
+
251
+ [grafana.commands.list-alert-rules]
252
+ description = "List alert rules and their state (firing first), with counts by state. Add --include alerts for the firing series"
253
+ autonomy = "autonomous"
254
+ [grafana.commands.list-alert-rules.args]
255
+ state = {type = "choice", options = ["firing", "pending", "inactive", "error", "nodata"], required = false, description = "Only rules in this state (or health)"}
256
+ search = {type = "str", short = "-s", required = false, description = "Only rules whose name contains this text"}
257
+ folder = {type = "str", required = false, description = "Only rules in this folder (title)"}
258
+ datasource = {type = "str", short = "-d", required = false, description = "Read a Prometheus/Loki/Mimir datasource's own rules instead of Grafana-managed rules"}
259
+ include = {type = "choice", options = ["alerts", "query", "annotations"], multiple = true, delimiter = ",", required = false, description = "Add parts: alerts (up to 10 active alerts per rule), query, annotations"}
260
+ limit = {type = "int", default = 100, description = "Most rules to return (0 = all)"}
261
+
262
+ [grafana.commands.get-alert-rule]
263
+ description = "Show a Grafana-managed alert rule's definition: condition, queries, labels, annotations, pending period"
264
+ autonomy = "autonomous"
265
+ [grafana.commands.get-alert-rule.args]
266
+ uid = {type = "str", short = "-u", required = true, description = "Alert rule UID (from list-alert-rules)"}
267
+ raw = {type = "flag", default = false, description = "Return the rule as the provisioning API sends it"}
268
+
269
+ [grafana.commands.list-contact-points]
270
+ description = "List notification contact points: uid, name, and type"
271
+ autonomy = "autonomous"
272
+ [grafana.commands.list-contact-points.args]
273
+ name = {type = "str", short = "-n", required = false, description = "Only the contact point with this name"}
274
+ include-settings = {type = "flag", default = false, description = "Add each contact point's settings (Grafana redacts secrets)"}
275
+ limit = {type = "int", default = 100, description = "Most contact points to return (0 = all)"}
276
+
277
+ [grafana.commands.list-silences]
278
+ description = "List silences in Grafana's Alertmanager, with their matchers and end times"
279
+ autonomy = "autonomous"
280
+ [grafana.commands.list-silences.args]
281
+ state = {type = "choice", options = ["active", "pending", "expired", "any"], default = "active", description = "Which silences to show"}
282
+ limit = {type = "int", default = 100, description = "Most silences to return (0 = all)"}
283
+
284
+ # ── annotations ────────────────────────────────────────────────────────────
285
+
286
+ [grafana.commands.get-annotations]
287
+ description = "List annotations (deploys, incidents, alert state changes) in a time window, newest first"
288
+ autonomy = "autonomous"
289
+ [grafana.commands.get-annotations.args]
290
+ start = {type = "str", required = false, description = "Window start (Unix seconds or ms, RFC 3339, now-1h, or 1h). Default 24h before end"}
291
+ end = {type = "str", required = false, description = "Window end. Default now"}
292
+ dashboard-uid = {type = "str", required = false, description = "Only annotations on this dashboard"}
293
+ panel = {type = "int", required = false, description = "Only annotations on this panel id"}
294
+ tag = {type = "str", multiple = true, required = false, description = "Only annotations with this tag (repeat; all must match unless --match-any)"}
295
+ match-any = {type = "flag", default = false, description = "Match annotations with any of the tags"}
296
+ type = {type = "choice", options = ["annotation", "alert"], required = false, description = "annotation: made by people or tools; alert: alert state changes"}
297
+ limit = {type = "int", default = 100, description = "Most annotations to return (0 = all)"}
298
+
299
+ [grafana.commands.get-annotation-tags]
300
+ description = "List annotation tags in use, with a count for each"
301
+ autonomy = "autonomous"
302
+ [grafana.commands.get-annotation-tags.args]
303
+ tag = {type = "str", required = false, description = "Only tags starting with this text"}
304
+ limit = {type = "int", default = 100, description = "Most tags to return"}
305
+
306
+ [grafana.commands.create-annotation]
307
+ description = "Add an annotation (e.g. a deploy marker) at a time or over a time range — on one dashboard, or organization-wide with tags"
308
+ autonomy = "confirm"
309
+ [grafana.commands.create-annotation.args]
310
+ text = {type = "str", short = "-t", required = true, description = "Annotation text"}
311
+ tag = {type = "str", multiple = true, required = false, description = "Tag (repeat), e.g. deploy"}
312
+ dashboard-uid = {type = "str", required = false, description = "Put it on this dashboard; omit for an organization-wide annotation"}
313
+ panel = {type = "int", required = false, description = "Put it on this panel (needs --dashboard-uid)"}
314
+ time = {type = "str", required = false, description = "When (Unix seconds or ms, RFC 3339, now-5m, or 5m). Default now"}
315
+ end = {type = "str", required = false, description = "End time, for a region annotation"}
316
+
317
+ # ── escape hatch → autonomous (GET only) ───────────────────────────────────
318
+
319
+ [grafana.commands.api-get]
320
+ description = "GET any Grafana HTTP API path (read-only) and return its JSON as-is — for what the other verbs don't cover, e.g. /api/org, /api/teams/search"
321
+ autonomy = "autonomous"
322
+ [grafana.commands.api-get.args]
323
+ path = {type = "str", short = "-p", required = true, description = "API path starting with /api/, e.g. /api/org"}
324
+ param = {type = "str", multiple = true, required = false, description = "Query parameter as key=value (repeat)"}
File without changes
@@ -0,0 +1,139 @@
1
+ """Wrapper logic: real parsed spec → `_dispatch` → core helper, with the core's
2
+ httpx routed through a MockTransport (no real network), plus main()'s output paths."""
3
+
4
+ import json
5
+ import os
6
+ from pathlib import Path
7
+
8
+ import httpx
9
+ import pytest
10
+ from runspec.testing import ParseHarness, no_run_as_check
11
+ from runspec_grafana_core import GrafanaConfig
12
+ from runspec_grafana_core import client as client_mod
13
+
14
+ from runspec_grafana import cli
15
+
16
+ SPEC = Path(__file__).parent.parent / "runspec_grafana" / "runspec.toml"
17
+ CFG = GrafanaConfig(url="https://graf")
18
+ _REAL_CLIENT = httpx.Client
19
+ PROM = {"uid": "prom1", "name": "Prometheus", "type": "prometheus", "isDefault": True}
20
+
21
+
22
+ @pytest.fixture(autouse=True)
23
+ def _clean_env(monkeypatch):
24
+ for var in list(os.environ):
25
+ if var.startswith("GRAFANA_"):
26
+ monkeypatch.delenv(var, raising=False)
27
+ monkeypatch.setenv("RUNSPEC_AUDIT", "off")
28
+
29
+
30
+ def _route(monkeypatch, routes: dict) -> list[httpx.Request]:
31
+ seen: list[httpx.Request] = []
32
+
33
+ def handler(request: httpx.Request) -> httpx.Response:
34
+ seen.append(request)
35
+ answer = routes.get(request.url.path)
36
+ if answer is None:
37
+ return httpx.Response(404, json={"message": "Not found"})
38
+ return answer if isinstance(answer, httpx.Response) else httpx.Response(200, json=answer)
39
+
40
+ def fake_client(**kwargs):
41
+ kwargs.pop("proxy", None)
42
+ kwargs.pop("verify", None)
43
+ return _REAL_CLIENT(transport=httpx.MockTransport(handler), **kwargs)
44
+
45
+ monkeypatch.setattr(client_mod.httpx, "Client", fake_client)
46
+ return seen
47
+
48
+
49
+ def _run(argv: list[str], config: GrafanaConfig = CFG):
50
+ h = ParseHarness(script_name="grafana", config_path=SPEC)
51
+ spec = h.expect_ok(argv)
52
+ try:
53
+ return cli._dispatch(spec, config)
54
+ finally:
55
+ h.close()
56
+
57
+
58
+ def test_dispatch_search_dashboards(monkeypatch) -> None:
59
+ seen = _route(monkeypatch, {"/api/search": [{"uid": "a", "title": "API", "url": "/d/a"}]})
60
+ out = _run(["search-dashboards", "--query", "api", "--tag", "prod", "--limit", "5"])
61
+ assert ("query", "api") in seen[0].url.params.multi_items() and ("tag", "prod") in seen[0].url.params.multi_items()
62
+ assert out["dashboards"] == [{"uid": "a", "title": "API", "url": "https://graf/d/a"}]
63
+
64
+
65
+ def test_dispatch_get_dashboard_with_queries(monkeypatch) -> None:
66
+ dash = {"dashboard": {"uid": "a", "title": "API", "panels": [{"id": 1, "title": "Rate", "type": "timeseries", "targets": [{"refId": "A", "expr": "up"}]}]}, "meta": {}}
67
+ _route(monkeypatch, {"/api/dashboards/uid/a": dash})
68
+ out = _run(["get-dashboard", "--uid", "a", "--include", "queries"])
69
+ assert out["panels"][0]["queries"] == [{"ref_id": "A", "query": "up"}]
70
+
71
+
72
+ def test_dispatch_query_prometheus_range(monkeypatch) -> None:
73
+ seen = _route(
74
+ monkeypatch,
75
+ {
76
+ "/api/datasources": [PROM],
77
+ "/api/datasources/proxy/uid/prom1/api/v1/query_range": {"status": "success", "data": {"resultType": "matrix", "result": []}},
78
+ },
79
+ )
80
+ out = _run(["query-prometheus", "--query", "up", "--query-type", "range", "--start", "0", "--end", "3600", "--step", "1m"])
81
+ assert dict(seen[-1].url.params.multi_items())["step"] == "60.0"
82
+ assert out["result_type"] == "matrix" and out["datasource"]["uid"] == "prom1"
83
+
84
+
85
+ def test_dispatch_histogram(monkeypatch) -> None:
86
+ _route(monkeypatch, {"/api/datasources": [PROM], "/api/datasources/proxy/uid/prom1/api/v1/query": {"status": "success", "data": {"resultType": "vector", "result": []}}})
87
+ out = _run(["query-prometheus-histogram", "--metric", "lat", "--percentile", "99", "--by", "route"])
88
+ assert out["query"] == "histogram_quantile(0.99, sum by (le, route) (rate(lat_bucket[5m])))"
89
+
90
+
91
+ def test_dispatch_list_datasources_type(monkeypatch) -> None:
92
+ _route(monkeypatch, {"/api/datasources": [PROM, {"uid": "l", "name": "Loki", "type": "loki"}]})
93
+ assert _run(["list-datasources", "--type", "loki"])["datasources"] == [{"uid": "l", "name": "Loki", "type": "loki"}]
94
+
95
+
96
+ def test_dispatch_create_annotation(monkeypatch) -> None:
97
+ seen = _route(monkeypatch, {"/api/annotations": {"id": 9, "message": "Annotation added"}})
98
+ out = _run(["create-annotation", "--text", "deploy", "--tag", "deploy", "--time", "60"])
99
+ assert seen[0].method == "POST" and json.loads(seen[0].content) == {"text": "deploy", "tags": ["deploy"], "time": 60000}
100
+ assert out["id"] == 9
101
+
102
+
103
+ def test_dispatch_api_get(monkeypatch) -> None:
104
+ seen = _route(monkeypatch, {"/api/org": {"name": "Main"}})
105
+ assert _run(["api-get", "--path", "/api/org", "--param", "x=1"]) == {"name": "Main"}
106
+ assert seen[0].method == "GET"
107
+
108
+
109
+ def _main(monkeypatch, argv: list[str], *, tty: bool = False) -> int:
110
+ monkeypatch.setattr("sys.argv", ["grafana", *argv])
111
+ monkeypatch.setattr(cli, "_terminal", lambda: tty)
112
+ code = 0
113
+ with no_run_as_check():
114
+ try:
115
+ cli.main()
116
+ except SystemExit as exc:
117
+ code = int(exc.code or 0)
118
+ return code
119
+
120
+
121
+ def test_main_reports_errors_as_json(monkeypatch, capsys) -> None:
122
+ _route(monkeypatch, {"/api/user": httpx.Response(401, json={"message": "invalid API key"})})
123
+ code = _main(monkeypatch, ["--url", "https://graf", "whoami"])
124
+ payload = json.loads(capsys.readouterr().out.strip().splitlines()[-1])
125
+ assert code == 1 and payload["kind"] == "GrafanaError" and "invalid API key" in payload["error"]
126
+
127
+
128
+ def test_main_prints_compact_json_when_piped(monkeypatch, capsys) -> None:
129
+ _route(monkeypatch, {"/api/datasources": [PROM]})
130
+ _main(monkeypatch, ["--url", "https://graf", "list-datasources"])
131
+ out = capsys.readouterr().out.strip().splitlines()[-1]
132
+ assert json.loads(out)["datasources"][0]["uid"] == "prom1" and "\n" not in out
133
+
134
+
135
+ def test_main_prints_text_at_a_terminal(monkeypatch, capsys) -> None:
136
+ _route(monkeypatch, {"/api/datasources": [PROM]})
137
+ _main(monkeypatch, ["--url", "https://graf", "list-datasources"], tty=True)
138
+ out = capsys.readouterr().out
139
+ assert "UID" in out and "prom1" in out and "{" not in out
@@ -0,0 +1,35 @@
1
+ """Terminal text rendering — pure, no runspec."""
2
+
3
+ from runspec_grafana.render import human_text
4
+
5
+
6
+ def test_rows_become_a_table() -> None:
7
+ text = human_text({"count": 1, "truncated": True, "dashboards": [{"uid": "a", "title": "API", "tags": ["prod", "eu"]}]})
8
+ lines = text.splitlines()
9
+ assert lines[0].startswith("Count") and "UID" in lines[2] and "TAGS" in lines[2]
10
+ assert "prod, eu" in lines[3] and lines[-1] == "(more — raise --limit)"
11
+
12
+
13
+ def test_string_lists_one_per_line() -> None:
14
+ text = human_text({"datasource": {"uid": "p", "name": "Prom"}, "count": 2, "truncated": False, "values": ["api", "node"]})
15
+ assert text.splitlines()[-2:] == ["api", "node"]
16
+
17
+
18
+ def test_empty_list_says_so() -> None:
19
+ assert human_text({"count": 0, "truncated": False, "silences": []}).endswith("No silences.")
20
+
21
+
22
+ def test_log_lines() -> None:
23
+ payload = {"line_count": 1, "truncated": False, "streams": [{"labels": {"app": "api"}, "lines": 1}], "entries": [{"time": "T", "stream": 0, "line": "boom"}]}
24
+ assert human_text(payload).splitlines()[0] == 'T {app="api"} boom'
25
+
26
+
27
+ def test_nested_row_lists_follow_the_table() -> None:
28
+ text = human_text({"panel_count": 1, "panels": [{"id": 1, "title": "Rate", "queries": [{"ref_id": "A", "query": "up"}]}]})
29
+ table, _, details = text.partition("Rate — queries:")
30
+ assert "QUERIES" not in table and "Rate" in table
31
+ assert "REF_ID" in details and "up" in details
32
+
33
+
34
+ def test_non_dict_falls_back_to_json() -> None:
35
+ assert human_text([1, 2]) is None
@@ -0,0 +1,79 @@
1
+ """Wiring / parsing tests for the `grafana` runnable — no mocks, real spec."""
2
+
3
+ import sys
4
+ from pathlib import Path
5
+
6
+ from runspec.testing import ParseHarness
7
+
8
+ if sys.version_info >= (3, 11):
9
+ import tomllib
10
+ else: # pragma: no cover
11
+ import tomli as tomllib
12
+
13
+ SPEC = Path(__file__).parent.parent / "runspec_grafana" / "runspec.toml"
14
+
15
+
16
+ def _harness() -> ParseHarness:
17
+ return ParseHarness(script_name="grafana", config_path=SPEC)
18
+
19
+
20
+ def test_smoke_all_verbs() -> None:
21
+ h = _harness()
22
+ # --help exits 0, require-command is enforced, omitting required args exits 1,
23
+ # and a fully-populated invocation parses — for every verb.
24
+ h.smoke(
25
+ values={
26
+ "query": "up",
27
+ "uid": "abc",
28
+ "datasource": "prom1",
29
+ "label": "job",
30
+ "metric": "http_request_duration_seconds",
31
+ "text": "deploy",
32
+ "path": "/api/org",
33
+ }
34
+ )
35
+ h.close()
36
+
37
+
38
+ def test_autonomy_tiers() -> None:
39
+ commands = tomllib.loads(SPEC.read_text())["grafana"]["commands"]
40
+ tiers = {name: body["autonomy"] for name, body in commands.items()}
41
+ assert {n for n, t in tiers.items() if t != "autonomous"} == {"create-annotation"}
42
+ assert tiers["create-annotation"] == "confirm"
43
+
44
+
45
+ def test_include_is_a_comma_list() -> None:
46
+ h = _harness()
47
+ spec = h.expect_ok(["get-dashboard", "--uid", "abc", "--include", "queries,links"])
48
+ assert list(spec.include.value) == ["queries", "links"]
49
+ h.expect_exit(["get-dashboard", "--uid", "abc", "--include", "everything"], contains="include")
50
+ h.close()
51
+
52
+
53
+ def test_query_prometheus_defaults() -> None:
54
+ h = _harness()
55
+ spec = h.expect_ok(["query-prometheus", "--query", "up"])
56
+ assert spec.query_type.value == "instant" and spec.limit.value == 50 and spec.datasource.value is None
57
+ h.expect_exit(["query-prometheus", "--query", "up", "--query-type", "sideways"])
58
+ h.close()
59
+
60
+
61
+ def test_globals_and_repeatables() -> None:
62
+ h = _harness()
63
+ spec = h.expect_ok(["--env", "prod-eu", "--org-id", "2", "search-dashboards", "--tag", "prod", "--tag", "api"])
64
+ assert spec.env.value == "prod-eu" and spec.org_id.value == "2"
65
+ assert list(spec.tag.value) == ["prod", "api"]
66
+ h.close()
67
+
68
+
69
+ def test_token_refused_on_command_line() -> None:
70
+ h = _harness()
71
+ h.expect_exit(["--grafana-token", "glsa_secret", "whoami"], contains="grafana-token")
72
+ h.close()
73
+
74
+
75
+ def test_histogram_percentile_range() -> None:
76
+ h = _harness()
77
+ assert h.expect_ok(["query-prometheus-histogram", "--metric", "lat"]).percentile.value == 95.0
78
+ h.expect_exit(["query-prometheus-histogram", "--metric", "lat", "--percentile", "150"], contains="percentile")
79
+ h.close()