Skip to content

Commit 47aedb2

Browse files
committed
fix(grafana): discover App Platform API version instead of hardcoding v1
The nine App Platform-backed actions (dashboard/folder get, create, update, delete and list_folders) hardcoded `v1` in the API group path. That version only exists in Grafana 13.0+, so on every Grafana 12.x release the requests returned a Kubernetes-style 404 ("the server could not find the requested resource"). Discover the served version via `GET /apis/<group>` instead, picking the first match from a preference list and caching the result per baseUrl and group. `v1` stays first so Grafana 13+ keeps using the GA version. Only the v1 lineage is considered: the v2 lineage uses a different dashboard resource schema and is not interchangeable with what normalizeDashboard() expects. If discovery fails the previous `v1` behaviour is kept.
1 parent fe7dd3c commit 47aedb2

1 file changed

Lines changed: 67 additions & 12 deletions

File tree

src/providers/grafana/runtime.ts

Lines changed: 67 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,24 @@ import {
1313
const defaultNamespace = "default";
1414
const grafanaDefaultRequestTimeoutMs = 30_000;
1515
const folderParentAnnotation = "grafana.app/folder";
16+
17+
const grafanaAppApiGroups = {
18+
folders: "folder.grafana.app",
19+
dashboards: "dashboard.grafana.app",
20+
} as const;
21+
22+
// Grafana's App Platform API groups are versioned and the set of served versions
23+
// differs per Grafana release, e.g.
24+
// Grafana 12.1 dashboard.grafana.app -> v1beta1, v0alpha1, v2alpha1
25+
// Grafana 12.4 dashboard.grafana.app -> v1beta1, v0alpha1, v2beta1, v2alpha1
26+
// Grafana 13.0 dashboard.grafana.app -> v1, ...
27+
// Requesting a version the server does not serve returns a Kubernetes-style 404
28+
// ("the server could not find the requested resource"), so the version has to be
29+
// discovered instead of hardcoded. Only versions from the v1 lineage are listed:
30+
// the v2 lineage uses a different resource schema and is not interchangeable here.
31+
const grafanaApiVersionPreference = ["v1", "v1beta1", "v0alpha1"] as const;
32+
33+
const grafanaApiVersionCache = new Map<string, string>();
1634
const grafanaApiMetadataUrl = "https://grafana.com/docs/grafana/latest/developers/http_api/auth/#service-account-token";
1735

1836
type GrafanaRequestPhase = "validate" | "execute";
@@ -134,7 +152,7 @@ async function executeListFolders(input: Record<string, unknown>, context: Grafa
134152
});
135153

136154
const payload = await grafanaRequestJson(
137-
apiPath(input, "folders"),
155+
await apiPath(input, "folders", { ...context, phase: "execute" }),
138156
{ method: "GET", query },
139157
{
140158
...context,
@@ -153,7 +171,7 @@ async function executeListFolders(input: Record<string, unknown>, context: Grafa
153171

154172
async function executeGetFolder(input: Record<string, unknown>, context: GrafanaContext): Promise<unknown> {
155173
const payload = await grafanaRequestJson(
156-
`${apiPath(input, "folders")}/${encodePathSegment(requireString(input.uid, "uid"))}`,
174+
`${await apiPath(input, "folders", { ...context, phase: "execute" })}/${encodePathSegment(requireString(input.uid, "uid"))}`,
157175
{ method: "GET" },
158176
{ ...context, phase: "execute" },
159177
);
@@ -162,7 +180,7 @@ async function executeGetFolder(input: Record<string, unknown>, context: Grafana
162180

163181
async function executeCreateFolder(input: Record<string, unknown>, context: GrafanaContext): Promise<unknown> {
164182
const payload = await grafanaRequestJson(
165-
apiPath(input, "folders"),
183+
await apiPath(input, "folders", { ...context, phase: "execute" }),
166184
{ method: "POST", body: folderRequestBody(input) },
167185
{ ...context, phase: "execute" },
168186
);
@@ -172,7 +190,7 @@ async function executeCreateFolder(input: Record<string, unknown>, context: Graf
172190
async function executeUpdateFolder(input: Record<string, unknown>, context: GrafanaContext): Promise<unknown> {
173191
const uid = requireString(input.uid, "uid");
174192
const payload = await grafanaRequestJson(
175-
`${apiPath(input, "folders")}/${encodePathSegment(uid)}`,
193+
`${await apiPath(input, "folders", { ...context, phase: "execute" })}/${encodePathSegment(uid)}`,
176194
{ method: "PUT", body: folderRequestBody(input, uid) },
177195
{ ...context, phase: "execute" },
178196
);
@@ -181,7 +199,7 @@ async function executeUpdateFolder(input: Record<string, unknown>, context: Graf
181199

182200
async function executeDeleteFolder(input: Record<string, unknown>, context: GrafanaContext): Promise<unknown> {
183201
const payload = await grafanaRequestJson(
184-
`${apiPath(input, "folders")}/${encodePathSegment(requireString(input.uid, "uid"))}`,
202+
`${await apiPath(input, "folders", { ...context, phase: "execute" })}/${encodePathSegment(requireString(input.uid, "uid"))}`,
185203
{ method: "DELETE" },
186204
{ ...context, phase: "execute" },
187205
);
@@ -222,7 +240,7 @@ async function executeSearchDashboards(input: Record<string, unknown>, context:
222240

223241
async function executeGetDashboard(input: Record<string, unknown>, context: GrafanaContext): Promise<unknown> {
224242
const payload = await grafanaRequestJson(
225-
`${apiPath(input, "dashboards")}/${encodePathSegment(requireString(input.uid, "uid"))}`,
243+
`${await apiPath(input, "dashboards", { ...context, phase: "execute" })}/${encodePathSegment(requireString(input.uid, "uid"))}`,
226244
{ method: "GET" },
227245
{ ...context, phase: "execute" },
228246
);
@@ -231,7 +249,7 @@ async function executeGetDashboard(input: Record<string, unknown>, context: Graf
231249

232250
async function executeCreateDashboard(input: Record<string, unknown>, context: GrafanaContext): Promise<unknown> {
233251
const payload = await grafanaRequestJson(
234-
apiPath(input, "dashboards"),
252+
await apiPath(input, "dashboards", { ...context, phase: "execute" }),
235253
{ method: "POST", body: dashboardRequestBody(input) },
236254
{ ...context, phase: "execute" },
237255
);
@@ -241,7 +259,7 @@ async function executeCreateDashboard(input: Record<string, unknown>, context: G
241259
async function executeUpdateDashboard(input: Record<string, unknown>, context: GrafanaContext): Promise<unknown> {
242260
const uid = requireString(input.uid, "uid");
243261
const payload = await grafanaRequestJson(
244-
`${apiPath(input, "dashboards")}/${encodePathSegment(uid)}`,
262+
`${await apiPath(input, "dashboards", { ...context, phase: "execute" })}/${encodePathSegment(uid)}`,
245263
{ method: "PUT", body: dashboardRequestBody(input, uid) },
246264
{ ...context, phase: "execute" },
247265
);
@@ -250,7 +268,7 @@ async function executeUpdateDashboard(input: Record<string, unknown>, context: G
250268

251269
async function executeDeleteDashboard(input: Record<string, unknown>, context: GrafanaContext): Promise<unknown> {
252270
const payload = await grafanaRequestJson(
253-
`${apiPath(input, "dashboards")}/${encodePathSegment(requireString(input.uid, "uid"))}`,
271+
`${await apiPath(input, "dashboards", { ...context, phase: "execute" })}/${encodePathSegment(requireString(input.uid, "uid"))}`,
254272
{ method: "DELETE" },
255273
{ ...context, phase: "execute" },
256274
);
@@ -427,10 +445,47 @@ function extractGrafanaErrorMessage(payload: unknown): string | undefined {
427445
);
428446
}
429447

430-
function apiPath(input: Record<string, unknown>, resource: "folders" | "dashboards"): string {
448+
async function resolveGrafanaApiVersion(
449+
group: string,
450+
context: GrafanaContext & { phase: GrafanaRequestPhase },
451+
): Promise<string> {
452+
const cacheKey = `${context.baseUrl}|${group}`;
453+
const cached = grafanaApiVersionCache.get(cacheKey);
454+
if (cached !== undefined) {
455+
return cached;
456+
}
457+
458+
let resolved: string = grafanaApiVersionPreference[0];
459+
try {
460+
const payload = await grafanaRequestJson(`/apis/${group}`, { method: "GET" }, context);
461+
const record = optionalRecord(payload) ?? {};
462+
const served = new Set(
463+
objectArrayOrEmpty(record.versions)
464+
.map((entry) => optionalString(entry.version))
465+
.filter((version): version is string => version !== undefined),
466+
);
467+
const match = grafanaApiVersionPreference.find((version) => served.has(version));
468+
if (match !== undefined) {
469+
resolved = match;
470+
}
471+
} catch {
472+
// Discovery is best-effort. Falling back to the newest known version keeps the
473+
// previous behaviour for servers that do not expose the discovery endpoint.
474+
}
475+
476+
grafanaApiVersionCache.set(cacheKey, resolved);
477+
return resolved;
478+
}
479+
480+
async function apiPath(
481+
input: Record<string, unknown>,
482+
resource: "folders" | "dashboards",
483+
context: GrafanaContext & { phase: GrafanaRequestPhase },
484+
): Promise<string> {
431485
const namespace = optionalString(input.namespace) ?? defaultNamespace;
432-
const group = resource === "folders" ? "folder.grafana.app/v1" : "dashboard.grafana.app/v1";
433-
return `/apis/${group}/namespaces/${encodePathSegment(namespace)}/${resource}`;
486+
const group = grafanaAppApiGroups[resource];
487+
const version = await resolveGrafanaApiVersion(group, context);
488+
return `/apis/${group}/${version}/namespaces/${encodePathSegment(namespace)}/${resource}`;
434489
}
435490

436491
function folderRequestBody(input: Record<string, unknown>, fallbackUid?: string): Record<string, unknown> {

0 commit comments

Comments
 (0)