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.
- runspec_grafana-0.1.0/.gitignore +61 -0
- runspec_grafana-0.1.0/CHANGELOG.md +30 -0
- runspec_grafana-0.1.0/PKG-INFO +113 -0
- runspec_grafana-0.1.0/README.md +95 -0
- runspec_grafana-0.1.0/pyproject.toml +47 -0
- runspec_grafana-0.1.0/runspec_grafana/__init__.py +15 -0
- runspec_grafana-0.1.0/runspec_grafana/cli.py +274 -0
- runspec_grafana-0.1.0/runspec_grafana/render.py +93 -0
- runspec_grafana-0.1.0/runspec_grafana/runspec.toml +324 -0
- runspec_grafana-0.1.0/tests/__init__.py +0 -0
- runspec_grafana-0.1.0/tests/test_cli.py +139 -0
- runspec_grafana-0.1.0/tests/test_render.py +35 -0
- runspec_grafana-0.1.0/tests/test_spec.py +79 -0
|
@@ -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()
|