@intentius/chant-lexicon-grafana 0.124.31 → 0.124.33
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.
- package/dist/actions/grafana.js +36 -0
- package/dist/actions/index.js +1 -0
- package/dist/alerting-build.js +262 -0
- package/dist/alerting.js +125 -0
- package/dist/annotations.js +47 -0
- package/dist/api/apply.js +320 -0
- package/dist/api/bind.js +44 -0
- package/dist/api/client.js +113 -0
- package/dist/api/converge.js +61 -0
- package/dist/api/dashboards.js +210 -0
- package/dist/api/datasources.js +24 -0
- package/dist/api/fake-grafana-writes.js +185 -0
- package/dist/api/fake-grafana.js +161 -0
- package/dist/api/folders.js +290 -0
- package/dist/api/library-panels.js +82 -0
- package/dist/build.js +767 -0
- package/dist/catalog.js +198 -0
- package/dist/codegen/docs-cli.js +3 -0
- package/dist/codegen/docs.js +130 -0
- package/dist/codegen/generate-cli.js +7 -0
- package/dist/codegen/generate.js +97 -0
- package/dist/codegen/package.js +28 -0
- package/dist/codegen/schema-types.js +159 -0
- package/dist/composites/agent-dashboard.js +460 -0
- package/dist/composites/catalog.js +306 -0
- package/dist/composites/index.js +8 -0
- package/dist/composites/red-dashboard.js +135 -0
- package/dist/composites/shared.js +74 -0
- package/dist/composites/slo-alert-rules.js +96 -0
- package/dist/composites/slo-dashboard.js +153 -0
- package/dist/config.js +110 -0
- package/dist/contact-point-check.js +61 -0
- package/dist/contact-point-secrets.js +32 -0
- package/dist/contact-point-settings.gen.js +421 -0
- package/dist/dashboard.js +60 -0
- package/dist/datasource-refs.js +170 -0
- package/dist/datasource-settings.js +57 -0
- package/dist/datasource.js +68 -0
- package/dist/deep-observe-hooks.js +266 -0
- package/dist/deep-observe.js +171 -0
- package/dist/describe-resources.js +249 -0
- package/dist/detect.js +119 -0
- package/dist/export-resources.js +73 -0
- package/dist/folder.js +86 -0
- package/dist/import/alerting.js +514 -0
- package/dist/import/edits.js +90 -0
- package/dist/import/embedded.js +60 -0
- package/dist/import/generator.js +656 -0
- package/dist/import/live-export.js +246 -0
- package/dist/import/mappings.js +317 -0
- package/dist/import/model.js +23 -0
- package/dist/import/normalize-alerting.js +106 -0
- package/dist/import/normalize.js +426 -0
- package/dist/import/operator.js +252 -0
- package/dist/import/parser.js +1480 -0
- package/dist/import/report.js +58 -0
- package/dist/import/testdata/fixtures.js +122 -0
- package/dist/import/v2.js +814 -0
- package/dist/index.js +59 -0
- package/dist/init-templates.js +305 -0
- package/dist/integrity.json +2 -2
- package/dist/k8s.js +548 -0
- package/dist/library-panel.js +83 -0
- package/dist/lint/audit-catalog.js +40 -0
- package/dist/lint/audit-lineage.js +9 -0
- package/dist/lint/post-synth/graf101.js +13 -0
- package/dist/lint/post-synth/graf102.js +13 -0
- package/dist/lint/post-synth/graf103.js +13 -0
- package/dist/lint/post-synth/graf104.js +13 -0
- package/dist/lint/post-synth/graf105.js +13 -0
- package/dist/lint/post-synth/graf106.js +13 -0
- package/dist/lint/post-synth/graf107.js +13 -0
- package/dist/lint/post-synth/graf108.js +13 -0
- package/dist/lint/post-synth/graf109.js +13 -0
- package/dist/lint/post-synth/graf110.js +13 -0
- package/dist/lint/post-synth/graf111.js +13 -0
- package/dist/lint/post-synth/graf112.js +13 -0
- package/dist/lint/post-synth/graf113.js +13 -0
- package/dist/lint/post-synth/graf114.js +13 -0
- package/dist/lint/post-synth/graf115.js +13 -0
- package/dist/lint/post-synth/graf116.js +13 -0
- package/dist/lint/post-synth/graf117.js +13 -0
- package/dist/lint/post-synth/graf118.js +37 -0
- package/dist/lint/post-synth/grafana-helpers.js +89 -0
- package/dist/lint/post-synth/index.js +38 -0
- package/dist/lint/rules/grafana-ast.js +70 -0
- package/dist/lint/rules/index.js +6 -0
- package/dist/lint/rules/literal-secret.js +109 -0
- package/dist/lint/rules/uid-syntax.js +53 -0
- package/dist/lsp/completions.js +11 -0
- package/dist/lsp/hover.js +29 -0
- package/dist/manifest.json +1 -1
- package/dist/op/activities/grafana-apply.js +147 -0
- package/dist/op/activities/index.js +7 -0
- package/dist/op/builders.js +19 -0
- package/dist/ownership.js +102 -0
- package/dist/package-cli.js +14 -0
- package/dist/panel-options.js +12 -0
- package/dist/panels.js +296 -0
- package/dist/pin.js +113 -0
- package/dist/plugin.js +172 -0
- package/dist/promql-check.js +176 -0
- package/dist/query-models.js +13 -0
- package/dist/query-syntax.js +129 -0
- package/dist/query.js +227 -0
- package/dist/schema/annotationslist.gen.js +5 -0
- package/dist/schema/azuremonitor.gen.js +5 -0
- package/dist/schema/barchart.gen.js +5 -0
- package/dist/schema/bargauge.gen.js +5 -0
- package/dist/schema/bigquery.gen.js +5 -0
- package/dist/schema/candlestick.gen.js +5 -0
- package/dist/schema/canvas.gen.js +5 -0
- package/dist/schema/cloudwatch.gen.js +5 -0
- package/dist/schema/dashboard.gen.js +6 -0
- package/dist/schema/dashboardlist.gen.js +5 -0
- package/dist/schema/datagrid.gen.js +5 -0
- package/dist/schema/elasticsearch.gen.js +5 -0
- package/dist/schema/expr.gen.js +5 -0
- package/dist/schema/gauge.gen.js +5 -0
- package/dist/schema/geomap.gen.js +5 -0
- package/dist/schema/googlecloudmonitoring.gen.js +5 -0
- package/dist/schema/grafanapyroscope.gen.js +5 -0
- package/dist/schema/heatmap.gen.js +5 -0
- package/dist/schema/histogram.gen.js +5 -0
- package/dist/schema/index.js +40 -0
- package/dist/schema/logs.gen.js +5 -0
- package/dist/schema/loki.gen.js +5 -0
- package/dist/schema/news.gen.js +5 -0
- package/dist/schema/nodegraph.gen.js +5 -0
- package/dist/schema/piechart.gen.js +5 -0
- package/dist/schema/prometheus.gen.js +5 -0
- package/dist/schema/stat.gen.js +5 -0
- package/dist/schema/statetimeline.gen.js +5 -0
- package/dist/schema/statushistory.gen.js +5 -0
- package/dist/schema/table.gen.js +5 -0
- package/dist/schema/tempo.gen.js +5 -0
- package/dist/schema/text.gen.js +5 -0
- package/dist/schema/timeseries.gen.js +5 -0
- package/dist/schema/trend.gen.js +5 -0
- package/dist/schema/xychart.gen.js +5 -0
- package/dist/schema-validate.js +265 -0
- package/dist/serializer.js +40 -0
- package/dist/skill-defs.js +60 -0
- package/dist/spec/fetch-cli.js +26 -0
- package/dist/spec/fetch-notifiers-cli.js +53 -0
- package/dist/spec/fetch-units-cli.js +40 -0
- package/dist/spec/notifiers.js +126 -0
- package/dist/spec/overlay.js +92 -0
- package/dist/spec/schemas.gen.js +42 -0
- package/dist/spec/schemas.js +62 -0
- package/dist/spec/units.gen.js +280 -0
- package/dist/spec/units.js +72 -0
- package/dist/transformations.js +127 -0
- package/dist/util.js +34 -0
- package/dist/validate-alerting.js +444 -0
- package/dist/validate-cli.js +7 -0
- package/dist/validate-output.js +635 -0
- package/dist/validate.js +122 -0
- package/dist/validation.js +13 -0
- package/dist/variables.js +70 -0
- package/package.json +10 -10
- package/src/codegen/package.ts +2 -2
- package/src/spec/fetch-notifiers-cli.ts +2 -2
- package/src/spec/fetch-units-cli.ts +2 -2
- package/src/spec/overlay.ts +2 -2
- package/src/spec/schemas.ts +2 -2
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Grafana API applier (#2948): folders, library panels and dashboards
|
|
3
|
+
* written over Grafana's HTTP API, stamped with chant's ownership labels,
|
|
4
|
+
* and an owned-only prune.
|
|
5
|
+
*
|
|
6
|
+
* ## The API path
|
|
7
|
+
*
|
|
8
|
+
* The server's dashboard API is discovered once per client
|
|
9
|
+
* (`dashboardApi` in ./dashboards.ts, shared with observe):
|
|
10
|
+
*
|
|
11
|
+
* - **Grafana 12.4 and 13.x**: `dashboard.grafana.app` (`v1beta1` on 12.4,
|
|
12
|
+
* `v1` on 13.x). A dashboard is created with `POST` to the collection and
|
|
13
|
+
* updated with `PUT` to its name, without a `resourceVersion`, which both
|
|
14
|
+
* versions accept as an unconditional update. The resource carries the
|
|
15
|
+
* classic JSON in `spec`, chant's labels in `metadata.labels`
|
|
16
|
+
* (`grafanaOwnershipLabels`, the marker observe reads back), and its
|
|
17
|
+
* folder in the `grafana.app/folder` annotation.
|
|
18
|
+
* - **Grafana 11**: `POST /api/dashboards/db` with `overwrite: true` and the
|
|
19
|
+
* folder's uid. There is no metadata channel, so nothing is stamped and
|
|
20
|
+
* nothing is pruned: the prune reports both kinds as `not-prunable`.
|
|
21
|
+
*
|
|
22
|
+
* Folders are ./folders.ts and library panels ./library-panels.ts. The write
|
|
23
|
+
* order is folders (parents first), then library panels, then dashboards, so
|
|
24
|
+
* nothing is written before what it references.
|
|
25
|
+
*
|
|
26
|
+
* ## Unchanged
|
|
27
|
+
*
|
|
28
|
+
* Every write is preceded by a read, and a resource whose live content
|
|
29
|
+
* already covers what would be sent (./converge.ts), with the same folder
|
|
30
|
+
* and labels, is `unchanged` and not written.
|
|
31
|
+
*
|
|
32
|
+
* ## Prune
|
|
33
|
+
*
|
|
34
|
+
* Only with `prune`, and only what carries this project's marker: chant's
|
|
35
|
+
* managed-by label with the same stack and the same env (an env-less marker
|
|
36
|
+
* matches only an env-less apply). Dashboards go first, then folders, a
|
|
37
|
+
* child before its parent, and a folder that still holds anything after
|
|
38
|
+
* that (a dashboard saved into it in the UI, a library panel) is left, as
|
|
39
|
+
* `not-prunable` with what it holds. Library panels are never pruned: their
|
|
40
|
+
* API has no labels.
|
|
41
|
+
*/
|
|
42
|
+
import { hasOwnershipMarker } from "@intentius/chant/ownership";
|
|
43
|
+
import { GrafanaApiError } from "./client.js";
|
|
44
|
+
import { DASHBOARD_GROUP, classicDashboardOf, dashboardApi, dashboardPath, listDashboards, readDashboard } from "./dashboards.js";
|
|
45
|
+
import { FOLDER_ANNOTATION, childrenFirst, deleteFolder, ensureFolder, folderApi, folderContentCount, listFolders, resolveFolders, send, } from "./folders.js";
|
|
46
|
+
import { ensureLibraryPanel, libraryPanelsOf, withoutExportKeys } from "./library-panels.js";
|
|
47
|
+
import { converged } from "./converge.js";
|
|
48
|
+
import { GRAFANA_OWNERSHIP_KEYS, grafanaOwnershipLabels } from "../ownership.js";
|
|
49
|
+
function isObject(v) {
|
|
50
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
51
|
+
}
|
|
52
|
+
/** The kinds the applier names in its result: Grafana's own type names. */
|
|
53
|
+
export const GRAFANA_APPLY_KINDS = {
|
|
54
|
+
dashboard: "Dashboard",
|
|
55
|
+
folder: "Folder",
|
|
56
|
+
libraryPanel: "LibraryPanel",
|
|
57
|
+
/** An `__elements` entry that is not a panel (a library variable), which the applier cannot write. */
|
|
58
|
+
libraryElement: "LibraryElement",
|
|
59
|
+
};
|
|
60
|
+
function sameJson(a, b) {
|
|
61
|
+
return converged(a, b) && converged(b, a);
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Turn built dashboards into an apply plan: the folders they name (each
|
|
65
|
+
* level of a path, with its parent) and the build's own `folders` (its
|
|
66
|
+
* index lists every one, `Folder`s holding no dashboard included), the
|
|
67
|
+
* library panels their `__elements` carry, and the dashboards themselves.
|
|
68
|
+
* Throws on a plan no apply could satisfy: a dashboard with no uid, two
|
|
69
|
+
* dashboards with one uid, two folders with one uid, or one library panel
|
|
70
|
+
* uid with two models.
|
|
71
|
+
*/
|
|
72
|
+
export function planFromDashboards(dashboards, declaredFolders = []) {
|
|
73
|
+
const resolved = resolveFolders({
|
|
74
|
+
declared: [
|
|
75
|
+
...declaredFolders.flatMap((f) => (f.path ? [{ uid: f.uid, path: f.path }] : [])),
|
|
76
|
+
...dashboards.flatMap((d) => (d.folder && d.folderUid ? [{ uid: d.folderUid, path: d.folder }] : [])),
|
|
77
|
+
],
|
|
78
|
+
paths: dashboards.map((d) => d.folder),
|
|
79
|
+
});
|
|
80
|
+
const folders = resolved.folders;
|
|
81
|
+
const libraryPanels = new Map();
|
|
82
|
+
const unsupported = [];
|
|
83
|
+
const out = [];
|
|
84
|
+
const seen = new Set();
|
|
85
|
+
for (const d of dashboards) {
|
|
86
|
+
const uid = d.json.uid;
|
|
87
|
+
if (typeof uid !== "string" || uid === "")
|
|
88
|
+
throw new Error(`grafana apply: dashboard "${String(d.json.title ?? "?")}" has no uid`);
|
|
89
|
+
if (seen.has(uid))
|
|
90
|
+
throw new Error(`grafana apply: two dashboards have the uid "${uid}"`);
|
|
91
|
+
seen.add(uid);
|
|
92
|
+
const inFolder = d.folderUid ?? resolved.uidOf(d.folder);
|
|
93
|
+
const { panels, skipped } = libraryPanelsOf(d.json, inFolder);
|
|
94
|
+
for (const p of panels) {
|
|
95
|
+
const prior = libraryPanels.get(p.uid);
|
|
96
|
+
if (prior && (prior.name !== p.name || !sameJson(prior.model, p.model))) {
|
|
97
|
+
throw new Error(`grafana apply: library panel "${p.uid}" is carried by more than one dashboard with different content`);
|
|
98
|
+
}
|
|
99
|
+
if (!prior)
|
|
100
|
+
libraryPanels.set(p.uid, p);
|
|
101
|
+
}
|
|
102
|
+
for (const name of skipped) {
|
|
103
|
+
unsupported.push({ kind: GRAFANA_APPLY_KINDS.libraryElement, name, detail: `dashboard "${uid}" carries it in __elements, and only library panels (kind 1) can be applied` });
|
|
104
|
+
}
|
|
105
|
+
out.push({ uid, json: withoutExportKeys(d.json), ...(inFolder ? { folderUid: inFolder } : {}) });
|
|
106
|
+
}
|
|
107
|
+
return { folders: parentsFirst(folders), libraryPanels: [...libraryPanels.values()], dashboards: out, unsupported };
|
|
108
|
+
}
|
|
109
|
+
function parentsFirst(folders) {
|
|
110
|
+
const byUid = new Map(folders.map((f) => [f.uid, f]));
|
|
111
|
+
const out = [];
|
|
112
|
+
const done = new Set();
|
|
113
|
+
const visit = (f, active) => {
|
|
114
|
+
if (done.has(f.uid))
|
|
115
|
+
return;
|
|
116
|
+
if (active.has(f.uid))
|
|
117
|
+
throw new Error(`grafana apply: folder "${f.uid}" is its own ancestor`);
|
|
118
|
+
active.add(f.uid);
|
|
119
|
+
const parent = f.parentUid ? byUid.get(f.parentUid) : undefined;
|
|
120
|
+
if (parent)
|
|
121
|
+
visit(parent, active);
|
|
122
|
+
done.add(f.uid);
|
|
123
|
+
out.push(f);
|
|
124
|
+
};
|
|
125
|
+
for (const f of folders)
|
|
126
|
+
visit(f, new Set());
|
|
127
|
+
return out;
|
|
128
|
+
}
|
|
129
|
+
/** Every resource in a plan, as `{ kind, name }`: what the result must account for. */
|
|
130
|
+
export function planRefs(plan) {
|
|
131
|
+
return [
|
|
132
|
+
...plan.folders.map((f) => ({ kind: GRAFANA_APPLY_KINDS.folder, name: f.uid })),
|
|
133
|
+
...plan.libraryPanels.map((p) => ({ kind: GRAFANA_APPLY_KINDS.libraryPanel, name: p.uid })),
|
|
134
|
+
...plan.dashboards.map((d) => ({ kind: GRAFANA_APPLY_KINDS.dashboard, name: d.uid })),
|
|
135
|
+
...plan.unsupported.map((u) => ({ kind: u.kind, name: u.name })),
|
|
136
|
+
];
|
|
137
|
+
}
|
|
138
|
+
function apiLabel(api) {
|
|
139
|
+
return api.kind === "apis" ? `apis/${api.version}` : "legacy";
|
|
140
|
+
}
|
|
141
|
+
/** The labels an apply stamps: the full marker, or only managed-by when no stack is known. */
|
|
142
|
+
export function stampLabels(marker) {
|
|
143
|
+
return marker ? grafanaOwnershipLabels(marker) : { [GRAFANA_OWNERSHIP_KEYS.managedBy]: "chant" };
|
|
144
|
+
}
|
|
145
|
+
/** True when `labels` carry exactly this project's marker: chant's, this stack, this env. */
|
|
146
|
+
export function carriesMarker(labels, marker) {
|
|
147
|
+
if (!hasOwnershipMarker(labels, GRAFANA_OWNERSHIP_KEYS))
|
|
148
|
+
return false;
|
|
149
|
+
return labels[GRAFANA_OWNERSHIP_KEYS.stack] === marker.stack && (labels[GRAFANA_OWNERSHIP_KEYS.env] ?? undefined) === (marker.env || undefined);
|
|
150
|
+
}
|
|
151
|
+
function sameLabels(live, want) {
|
|
152
|
+
return Object.entries(want).every(([k, v]) => live[k] === v);
|
|
153
|
+
}
|
|
154
|
+
/** The spec sent for a dashboard: its JSON without the identity keys the resource carries elsewhere. */
|
|
155
|
+
function specOf(json) {
|
|
156
|
+
const { id: _id, uid: _uid, version: _version, ...spec } = json;
|
|
157
|
+
return spec;
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Create or update one dashboard. Unchanged, with nothing written, when the
|
|
161
|
+
* live dashboard already covers the JSON and sits in the same folder with
|
|
162
|
+
* the same labels.
|
|
163
|
+
*/
|
|
164
|
+
export async function ensureDashboard(client, plan, labels) {
|
|
165
|
+
const api = await dashboardApi(client);
|
|
166
|
+
const read = await readDashboard(client, plan.uid);
|
|
167
|
+
const live = "present" in read ? read.present : undefined;
|
|
168
|
+
const address = dashboardPath(api, client.namespace, plan.uid);
|
|
169
|
+
const classic = live ? classicDashboardOf(live) : undefined;
|
|
170
|
+
const sameFolder = (live?.folderUid ?? "") === (plan.folderUid ?? "");
|
|
171
|
+
const sameContent = classic !== undefined && "json" in classic && converged(plan.json, classic.json, { dashboard: true });
|
|
172
|
+
if (api.kind === "apis") {
|
|
173
|
+
if (live && sameContent && sameFolder && sameLabels(live.labels, labels))
|
|
174
|
+
return { action: "unchanged", address };
|
|
175
|
+
// Labels are merged, so a label another tool put on the dashboard stays.
|
|
176
|
+
const metadata = {
|
|
177
|
+
name: plan.uid,
|
|
178
|
+
labels: { ...(live?.labels ?? {}), ...labels },
|
|
179
|
+
...(plan.folderUid ? { annotations: { [FOLDER_ANNOTATION]: plan.folderUid } } : {}),
|
|
180
|
+
};
|
|
181
|
+
const resource = { apiVersion: `${DASHBOARD_GROUP}/${api.version}`, kind: "Dashboard", metadata, spec: specOf(plan.json) };
|
|
182
|
+
if (live) {
|
|
183
|
+
await send(client, "PUT", address, resource);
|
|
184
|
+
return { action: "updated", address };
|
|
185
|
+
}
|
|
186
|
+
await send(client, "POST", address.slice(0, address.lastIndexOf("/")), resource);
|
|
187
|
+
return { action: "created", address };
|
|
188
|
+
}
|
|
189
|
+
if (live && sameContent && sameFolder)
|
|
190
|
+
return { action: "unchanged", address };
|
|
191
|
+
const { id: _id, version: _version, ...dashboard } = plan.json;
|
|
192
|
+
await send(client, "POST", "/api/dashboards/db", {
|
|
193
|
+
dashboard: { ...dashboard, uid: plan.uid },
|
|
194
|
+
folderUid: plan.folderUid ?? "",
|
|
195
|
+
overwrite: true,
|
|
196
|
+
message: "chant apply",
|
|
197
|
+
});
|
|
198
|
+
return { action: live ? "updated" : "created", address };
|
|
199
|
+
}
|
|
200
|
+
/** Delete one dashboard over `/apis`. False when it was already gone. */
|
|
201
|
+
async function deleteDashboard(client, address) {
|
|
202
|
+
const res = await client.http("DELETE", address);
|
|
203
|
+
if (res.status === 404)
|
|
204
|
+
return false;
|
|
205
|
+
if (res.status < 200 || res.status >= 300)
|
|
206
|
+
throw new GrafanaApiError(res.status, "DELETE", address, res.json);
|
|
207
|
+
return true;
|
|
208
|
+
}
|
|
209
|
+
function checkAborted(signal) {
|
|
210
|
+
if (signal?.aborted)
|
|
211
|
+
throw new Error("grafana apply aborted");
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* Apply `plan` to the Grafana behind `client`. Throws `GrafanaApiError` when
|
|
215
|
+
* a write fails; a refused first read (401/403) is returned instead, with
|
|
216
|
+
* every resource `no-credentials`, since nothing was written.
|
|
217
|
+
*/
|
|
218
|
+
export async function applyGrafana(client, plan, opts = {}) {
|
|
219
|
+
const outcome = {
|
|
220
|
+
target: client.target.source,
|
|
221
|
+
api: "",
|
|
222
|
+
applied: [],
|
|
223
|
+
pruned: [],
|
|
224
|
+
notAttempted: plan.unsupported.map((u) => ({ kind: u.kind, name: u.name, reason: "unsupported-kind", detail: u.detail })),
|
|
225
|
+
notPrunable: [],
|
|
226
|
+
};
|
|
227
|
+
let api;
|
|
228
|
+
try {
|
|
229
|
+
api = await dashboardApi(client);
|
|
230
|
+
}
|
|
231
|
+
catch (err) {
|
|
232
|
+
if (err instanceof GrafanaApiError && err.verdict === "refused") {
|
|
233
|
+
const detail = `${err.message} (the credentials for ${client.target.source} were refused)`;
|
|
234
|
+
for (const ref of planRefs(plan)) {
|
|
235
|
+
if (!plan.unsupported.some((u) => u.kind === ref.kind && u.name === ref.name))
|
|
236
|
+
outcome.notAttempted.push({ ...ref, reason: "no-credentials", detail });
|
|
237
|
+
}
|
|
238
|
+
return outcome;
|
|
239
|
+
}
|
|
240
|
+
throw err;
|
|
241
|
+
}
|
|
242
|
+
outcome.api = apiLabel(api);
|
|
243
|
+
const labels = stampLabels(opts.marker);
|
|
244
|
+
for (const folder of plan.folders) {
|
|
245
|
+
checkAborted(opts.signal);
|
|
246
|
+
const r = await ensureFolder(client, folder, labels);
|
|
247
|
+
outcome.applied.push({ kind: GRAFANA_APPLY_KINDS.folder, name: folder.uid, action: r.action, address: r.address });
|
|
248
|
+
}
|
|
249
|
+
for (const panel of plan.libraryPanels) {
|
|
250
|
+
checkAborted(opts.signal);
|
|
251
|
+
const r = await ensureLibraryPanel(client, panel);
|
|
252
|
+
outcome.applied.push({ kind: GRAFANA_APPLY_KINDS.libraryPanel, name: panel.uid, action: r.action, address: r.address });
|
|
253
|
+
}
|
|
254
|
+
for (const dashboard of plan.dashboards) {
|
|
255
|
+
checkAborted(opts.signal);
|
|
256
|
+
const r = await ensureDashboard(client, dashboard, labels);
|
|
257
|
+
outcome.applied.push({ kind: GRAFANA_APPLY_KINDS.dashboard, name: dashboard.uid, action: r.action, address: r.address });
|
|
258
|
+
}
|
|
259
|
+
if (opts.prune)
|
|
260
|
+
await prune(client, plan, api, opts, outcome);
|
|
261
|
+
return outcome;
|
|
262
|
+
}
|
|
263
|
+
async function prune(client, plan, api, opts, outcome) {
|
|
264
|
+
const marker = opts.marker;
|
|
265
|
+
if (plan.libraryPanels.length > 0) {
|
|
266
|
+
outcome.notPrunable.push({ kind: GRAFANA_APPLY_KINDS.libraryPanel, detail: "the library elements API has no labels, so a library panel cannot carry chant's marker" });
|
|
267
|
+
}
|
|
268
|
+
if (!marker) {
|
|
269
|
+
const detail = "no ownership stack: set ownership.stack in chant.config.ts, or pass stack, so the prune can tell this project's resources from another's";
|
|
270
|
+
outcome.notPrunable.push({ kind: GRAFANA_APPLY_KINDS.dashboard, detail }, { kind: GRAFANA_APPLY_KINDS.folder, detail });
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
if (api.kind === "legacy") {
|
|
274
|
+
outcome.notPrunable.push({ kind: GRAFANA_APPLY_KINDS.dashboard, detail: "this Grafana has no dashboard.grafana.app API (Grafana 11), and /api/dashboards has no labels to read the marker from" });
|
|
275
|
+
}
|
|
276
|
+
else {
|
|
277
|
+
const keep = new Set(plan.dashboards.map((d) => d.uid));
|
|
278
|
+
for (const live of await listDashboards(client)) {
|
|
279
|
+
if (keep.has(live.uid) || !carriesMarker(live.labels, marker))
|
|
280
|
+
continue;
|
|
281
|
+
checkAborted(opts.signal);
|
|
282
|
+
const address = dashboardPath(api, client.namespace, live.uid);
|
|
283
|
+
outcome.pruned.push({ kind: GRAFANA_APPLY_KINDS.dashboard, name: live.uid, deleted: await deleteDashboard(client, address), address });
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
const fApi = await folderApi(client);
|
|
287
|
+
if (fApi.kind === "legacy") {
|
|
288
|
+
outcome.notPrunable.push({ kind: GRAFANA_APPLY_KINDS.folder, detail: "this Grafana has no folder.grafana.app API, and /api/folders has no labels to read the marker from" });
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
const keep = new Set(plan.folders.map((f) => f.uid));
|
|
292
|
+
const orphans = (await listFolders(client)).filter((f) => !keep.has(f.uid) && carriesMarker(f.labels, marker));
|
|
293
|
+
for (const folder of childrenFirst(orphans)) {
|
|
294
|
+
checkAborted(opts.signal);
|
|
295
|
+
const held = await folderContentCount(client, folder.uid);
|
|
296
|
+
if (held > 0) {
|
|
297
|
+
outcome.notAttempted.push({
|
|
298
|
+
kind: GRAFANA_APPLY_KINDS.folder,
|
|
299
|
+
name: folder.uid,
|
|
300
|
+
reason: "not-prunable",
|
|
301
|
+
detail: `folder "${folder.title}" is this project's and no longer declared, but still holds ${held} item(s) chant does not prune (a dashboard saved into it, a library panel); it is left`,
|
|
302
|
+
});
|
|
303
|
+
continue;
|
|
304
|
+
}
|
|
305
|
+
outcome.pruned.push({ kind: GRAFANA_APPLY_KINDS.folder, name: folder.uid, deleted: await deleteFolder(client, folder), address: folder.address });
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
/** Parse a stored dashboard JSON file's content; throws with the file name when it is not a JSON object. */
|
|
309
|
+
export function parseDashboardFile(content, file) {
|
|
310
|
+
let parsed;
|
|
311
|
+
try {
|
|
312
|
+
parsed = JSON.parse(content);
|
|
313
|
+
}
|
|
314
|
+
catch (err) {
|
|
315
|
+
throw new Error(`grafana apply: ${file} is not JSON (${err instanceof Error ? err.message : String(err)})`);
|
|
316
|
+
}
|
|
317
|
+
if (!isObject(parsed))
|
|
318
|
+
throw new Error(`grafana apply: ${file} is not a dashboard JSON object`);
|
|
319
|
+
return parsed;
|
|
320
|
+
}
|
package/dist/api/bind.js
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Binding a client for one environment: the config lookup, the target, the
|
|
3
|
+
* transport. Shared by observe, export and (#2948) apply, so all three read
|
|
4
|
+
* and write the same Grafana.
|
|
5
|
+
*/
|
|
6
|
+
import { GrafanaApiError, GrafanaClient, grafanaHttp } from "./client.js";
|
|
7
|
+
import { isUnresolvedTarget, resolveGrafanaTarget } from "../config.js";
|
|
8
|
+
/** A target that could not be resolved, thrown by {@link bindGrafana} and classified by {@link classifyGrafanaFailure}. */
|
|
9
|
+
export class GrafanaBindingError extends Error {
|
|
10
|
+
constructor(unresolved) {
|
|
11
|
+
super(unresolved.detail);
|
|
12
|
+
this.unresolved = unresolved;
|
|
13
|
+
this.name = "GrafanaBindingError";
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
async function loadConfig(cwd) {
|
|
17
|
+
try {
|
|
18
|
+
const { loadChantConfig } = await import("@intentius/chant/config");
|
|
19
|
+
return (await loadChantConfig(cwd)).config;
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
// No project config (an ad-hoc export): the environment variables still bind.
|
|
23
|
+
return undefined;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
/** Resolve the environment's target and return a client for it. Throws {@link GrafanaBindingError} when there is none. */
|
|
27
|
+
export async function bindGrafana(options = {}) {
|
|
28
|
+
const config = options.config ?? (await loadConfig(options.cwd ?? process.cwd()));
|
|
29
|
+
const target = resolveGrafanaTarget({ environment: options.environment, config, env: options.env });
|
|
30
|
+
if (isUnresolvedTarget(target))
|
|
31
|
+
throw new GrafanaBindingError(target);
|
|
32
|
+
return new GrafanaClient(options.http ?? grafanaHttp(target), target);
|
|
33
|
+
}
|
|
34
|
+
/** What a failed bind or read means, in the observation vocabulary. */
|
|
35
|
+
export function classifyGrafanaFailure(err) {
|
|
36
|
+
if (err instanceof GrafanaBindingError)
|
|
37
|
+
return { reason: err.unresolved.reason, detail: err.unresolved.detail };
|
|
38
|
+
if (err instanceof GrafanaApiError) {
|
|
39
|
+
return err.verdict === "refused"
|
|
40
|
+
? { reason: "no-credentials", detail: `${err.message} (the credentials were refused)` }
|
|
41
|
+
: { reason: "read-failed", detail: err.message };
|
|
42
|
+
}
|
|
43
|
+
return { reason: "read-failed", detail: err instanceof Error ? err.message.split("\n")[0] : String(err) };
|
|
44
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Grafana HTTP transport (#2946): one client for observe, export and,
|
|
3
|
+
* later, the API applier (#2948), so all three reach the same instance with
|
|
4
|
+
* the same credentials.
|
|
5
|
+
*
|
|
6
|
+
* `GrafanaHttp` is the seam. The default is `fetch` against a resolved
|
|
7
|
+
* target (./target.ts); a test hands in a function that answers by path, so
|
|
8
|
+
* nothing needs a module mock.
|
|
9
|
+
*
|
|
10
|
+
* Status mapping is in one place ({@link statusVerdict}): a 404 is the only
|
|
11
|
+
* answer that means "not there", 401/403 mean the credentials were refused,
|
|
12
|
+
* and anything else that is not a 2xx is a failed read. A reader that turned
|
|
13
|
+
* a 500 or a refused token into "absent" would have `chant lifecycle plan`
|
|
14
|
+
* propose creating a dashboard that is running.
|
|
15
|
+
*/
|
|
16
|
+
/** The API namespace for a target (see {@link GrafanaTarget.namespace}). */
|
|
17
|
+
export function namespaceOf(target) {
|
|
18
|
+
if (target.namespace)
|
|
19
|
+
return target.namespace;
|
|
20
|
+
const org = target.orgId ?? 1;
|
|
21
|
+
return org === 1 ? "default" : `org-${org}`;
|
|
22
|
+
}
|
|
23
|
+
function authHeader(auth) {
|
|
24
|
+
if (!auth)
|
|
25
|
+
return {};
|
|
26
|
+
if ("token" in auth)
|
|
27
|
+
return { authorization: `Bearer ${auth.token}` };
|
|
28
|
+
return { authorization: `Basic ${Buffer.from(`${auth.user}:${auth.password}`).toString("base64")}` };
|
|
29
|
+
}
|
|
30
|
+
/** The default transport: `fetch` against the target, JSON in and out. */
|
|
31
|
+
export function grafanaHttp(target, fetchImpl = fetch) {
|
|
32
|
+
const base = target.url.replace(/\/+$/, "");
|
|
33
|
+
const headers = {
|
|
34
|
+
accept: "application/json",
|
|
35
|
+
...authHeader(target.auth),
|
|
36
|
+
...(target.orgId !== undefined ? { "x-grafana-org-id": String(target.orgId) } : {}),
|
|
37
|
+
};
|
|
38
|
+
return async (method, path, body) => {
|
|
39
|
+
const res = await fetchImpl(`${base}${path}`, {
|
|
40
|
+
method,
|
|
41
|
+
headers: body === undefined ? headers : { ...headers, "content-type": "application/json" },
|
|
42
|
+
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
|
43
|
+
});
|
|
44
|
+
const text = await res.text();
|
|
45
|
+
let json;
|
|
46
|
+
if (text !== "") {
|
|
47
|
+
try {
|
|
48
|
+
json = JSON.parse(text);
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
json = undefined;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return { status: res.status, json };
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
export function statusVerdict(status) {
|
|
58
|
+
if (status >= 200 && status < 300)
|
|
59
|
+
return "ok";
|
|
60
|
+
if (status === 404)
|
|
61
|
+
return "not-found";
|
|
62
|
+
if (status === 401 || status === 403)
|
|
63
|
+
return "refused";
|
|
64
|
+
return "failed";
|
|
65
|
+
}
|
|
66
|
+
/** A non-2xx answer, with enough to classify it and to say where it came from. */
|
|
67
|
+
export class GrafanaApiError extends Error {
|
|
68
|
+
constructor(status, method, path, body) {
|
|
69
|
+
const message = body && typeof body === "object" && typeof body.message === "string" ? body.message : "";
|
|
70
|
+
super(`${method} ${path} returned ${status}${message ? `: ${message}` : ""}`);
|
|
71
|
+
this.status = status;
|
|
72
|
+
this.method = method;
|
|
73
|
+
this.path = path;
|
|
74
|
+
this.name = "GrafanaApiError";
|
|
75
|
+
this.verdict = statusVerdict(status);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* A bound client: the transport, the target it reaches, and per-client
|
|
80
|
+
* caches (the API version a server serves, folder titles) so N reads do not
|
|
81
|
+
* repeat the same lookup.
|
|
82
|
+
*/
|
|
83
|
+
export class GrafanaClient {
|
|
84
|
+
constructor(http, target) {
|
|
85
|
+
this.http = http;
|
|
86
|
+
this.target = target;
|
|
87
|
+
this.memo = new Map();
|
|
88
|
+
}
|
|
89
|
+
get namespace() {
|
|
90
|
+
return namespaceOf(this.target);
|
|
91
|
+
}
|
|
92
|
+
/** GET, returning the body; undefined on 404; throws {@link GrafanaApiError} on any other non-2xx. */
|
|
93
|
+
async get(path) {
|
|
94
|
+
const res = await this.http("GET", path);
|
|
95
|
+
const verdict = statusVerdict(res.status);
|
|
96
|
+
if (verdict === "ok")
|
|
97
|
+
return res.json;
|
|
98
|
+
if (verdict === "not-found")
|
|
99
|
+
return undefined;
|
|
100
|
+
throw new GrafanaApiError(res.status, "GET", path, res.json);
|
|
101
|
+
}
|
|
102
|
+
/** Run `fn` once per key for this client's lifetime; concurrent callers share the promise. */
|
|
103
|
+
once(key, fn) {
|
|
104
|
+
let p = this.memo.get(key);
|
|
105
|
+
if (!p) {
|
|
106
|
+
p = fn();
|
|
107
|
+
this.memo.set(key, p);
|
|
108
|
+
// A failed lookup is not cached: the next caller asks again.
|
|
109
|
+
p.catch(() => this.memo.delete(key));
|
|
110
|
+
}
|
|
111
|
+
return p;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether what Grafana holds already is what the applier would write
|
|
3
|
+
* (#2948), so a re-apply reports `unchanged` and writes nothing.
|
|
4
|
+
*
|
|
5
|
+
* Grafana does not answer this itself: a `PUT` of identical content still
|
|
6
|
+
* bumps `generation` and `resourceVersion` (measured on 12.4.11 and 13.2.2),
|
|
7
|
+
* so a no-op cannot be told from an update after the fact. And the stored
|
|
8
|
+
* dashboard is not the one sent: Grafana fills in defaults
|
|
9
|
+
* (`fiscalYearStartMonth: 0`, `links: []`, `timepicker: {}`, ...), adds the
|
|
10
|
+
* built-in annotation, drops `null`s and empty `options`, and may migrate
|
|
11
|
+
* `schemaVersion`.
|
|
12
|
+
*
|
|
13
|
+
* So the test is one-sided: every value the applier would send must be in
|
|
14
|
+
* the live object, equal, and anything Grafana added on top is ignored. An
|
|
15
|
+
* array must match element for element, since a panel added in the UI is a
|
|
16
|
+
* change. The noise is the same list the drift hooks carry (#2946):
|
|
17
|
+
*
|
|
18
|
+
* - `null` and empty objects sent are satisfied by an absent key;
|
|
19
|
+
* - the built-in "Annotations & Alerts" annotation is ignored on both sides;
|
|
20
|
+
* - `id`, `version` and `uid` at the top level are identity, not content, and
|
|
21
|
+
* `schemaVersion` is Grafana's to migrate.
|
|
22
|
+
*/
|
|
23
|
+
function isObject(v) {
|
|
24
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
25
|
+
}
|
|
26
|
+
/** Top-level dashboard keys that are identity or Grafana-managed, never content. */
|
|
27
|
+
export const IDENTITY_KEYS = ["id", "uid", "version", "schemaVersion"];
|
|
28
|
+
function isBuiltInAnnotation(v) {
|
|
29
|
+
return isObject(v) && (v.builtIn === 1 || v.builtIn === true);
|
|
30
|
+
}
|
|
31
|
+
function covers(want, live, path) {
|
|
32
|
+
if (want === null || want === undefined)
|
|
33
|
+
return live === null || live === undefined;
|
|
34
|
+
if (Array.isArray(want)) {
|
|
35
|
+
if (!Array.isArray(live))
|
|
36
|
+
return want.length === 0 && live === undefined;
|
|
37
|
+
const annotations = path[path.length - 1] === "list" && path[path.length - 2] === "annotations";
|
|
38
|
+
const w = annotations ? want.filter((a) => !isBuiltInAnnotation(a)) : want;
|
|
39
|
+
const l = annotations ? live.filter((a) => !isBuiltInAnnotation(a)) : live;
|
|
40
|
+
return w.length === l.length && w.every((x, i) => covers(x, l[i], [...path, String(i)]));
|
|
41
|
+
}
|
|
42
|
+
if (isObject(want)) {
|
|
43
|
+
if (live === undefined || live === null)
|
|
44
|
+
return Object.values(want).every((v) => covers(v, undefined, path));
|
|
45
|
+
if (!isObject(live))
|
|
46
|
+
return false;
|
|
47
|
+
return Object.entries(want).every(([k, v]) => covers(v, live[k], [...path, k]));
|
|
48
|
+
}
|
|
49
|
+
return want === live;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* True when `live` already holds everything in `want`. For a dashboard,
|
|
53
|
+
* the top-level identity keys ({@link IDENTITY_KEYS}) are left out of the
|
|
54
|
+
* comparison.
|
|
55
|
+
*/
|
|
56
|
+
export function converged(want, live, opts = {}) {
|
|
57
|
+
if (!opts.dashboard || !isObject(want))
|
|
58
|
+
return covers(want, live, []);
|
|
59
|
+
const content = Object.fromEntries(Object.entries(want).filter(([k]) => !IDENTITY_KEYS.includes(k)));
|
|
60
|
+
return covers(content, live, []);
|
|
61
|
+
}
|