Skip to content

Commit 2871f11

Browse files
authored
fix(grafana): discover App Platform API version instead of hardcoding v1 (#221)
Discover the served Grafana App Platform API version at runtime so dashboard and folder actions work across Grafana 12 and 13. Cache only successful discoveries with a bounded cache, and retry discovery after transient failures. Tests: npm run fix-check; npm test
1 parent affeb69 commit 2871f11

2 files changed

Lines changed: 171 additions & 12 deletions

File tree

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import { describe, expect, it, vi } from "vitest";
2+
3+
interface GrafanaFetchMock {
4+
fetcher: typeof fetch;
5+
urls: string[];
6+
}
7+
8+
function createGrafanaFetchMock(handler: (url: URL) => Response): GrafanaFetchMock {
9+
const urls: string[] = [];
10+
const fetcher = (async (input: RequestInfo | URL): Promise<Response> => {
11+
const url = new URL(input instanceof Request ? input.url : String(input));
12+
urls.push(url.toString());
13+
return handler(url);
14+
}) as typeof fetch;
15+
return { fetcher, urls };
16+
}
17+
18+
function jsonResponse(value: unknown, status = 200): Response {
19+
return new Response(JSON.stringify(value), {
20+
status,
21+
headers: { "content-type": "application/json" },
22+
});
23+
}
24+
25+
describe("Grafana App Platform API version discovery", () => {
26+
it("prefers v1 and caches a successfully discovered version", async () => {
27+
vi.resetModules();
28+
const { grafanaActionHandlers } = await import("./runtime.ts");
29+
let discoveryRequests = 0;
30+
const mock = createGrafanaFetchMock((url) => {
31+
if (url.pathname === "/apis/folder.grafana.app") {
32+
discoveryRequests += 1;
33+
return jsonResponse({
34+
versions: [{ version: "v0alpha1" }, { version: "v1beta1" }, { version: "v1" }],
35+
});
36+
}
37+
return jsonResponse({ items: [], metadata: {} });
38+
});
39+
const context = {
40+
baseUrl: "https://grafana-v1.example",
41+
apiKey: "test-token",
42+
fetcher: mock.fetcher,
43+
};
44+
45+
await grafanaActionHandlers.list_folders({}, context);
46+
await grafanaActionHandlers.list_folders({}, context);
47+
48+
expect(discoveryRequests).toBe(1);
49+
expect(mock.urls).toEqual([
50+
"https://grafana-v1.example/apis/folder.grafana.app",
51+
"https://grafana-v1.example/apis/folder.grafana.app/v1/namespaces/default/folders",
52+
"https://grafana-v1.example/apis/folder.grafana.app/v1/namespaces/default/folders",
53+
]);
54+
});
55+
56+
it("retries discovery after a failure instead of caching the v1 fallback", async () => {
57+
vi.resetModules();
58+
const { grafanaActionHandlers } = await import("./runtime.ts");
59+
let discoveryRequests = 0;
60+
const mock = createGrafanaFetchMock((url) => {
61+
if (url.pathname === "/apis/folder.grafana.app") {
62+
discoveryRequests += 1;
63+
if (discoveryRequests === 1) {
64+
return jsonResponse({ message: "temporary failure" }, 500);
65+
}
66+
return jsonResponse({ versions: [{ version: "v1beta1" }] });
67+
}
68+
if (url.pathname.includes("/v1beta1/")) {
69+
return jsonResponse({ items: [], metadata: {} });
70+
}
71+
return jsonResponse({ message: "unsupported version" }, 404);
72+
});
73+
const context = {
74+
baseUrl: "https://grafana-v1beta1.example",
75+
apiKey: "test-token",
76+
fetcher: mock.fetcher,
77+
};
78+
79+
await expect(grafanaActionHandlers.list_folders({}, context)).rejects.toThrow("unsupported version");
80+
await expect(grafanaActionHandlers.list_folders({}, context)).resolves.toMatchObject({ folders: [] });
81+
82+
expect(discoveryRequests).toBe(2);
83+
expect(mock.urls).toEqual([
84+
"https://grafana-v1beta1.example/apis/folder.grafana.app",
85+
"https://grafana-v1beta1.example/apis/folder.grafana.app/v1/namespaces/default/folders",
86+
"https://grafana-v1beta1.example/apis/folder.grafana.app",
87+
"https://grafana-v1beta1.example/apis/folder.grafana.app/v1beta1/namespaces/default/folders",
88+
]);
89+
});
90+
});

src/providers/grafana/runtime.ts

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

1840
type GrafanaRequestPhase = "validate" | "execute";
@@ -134,7 +156,7 @@ async function executeListFolders(input: Record<string, unknown>, context: Grafa
134156
});
135157

136158
const payload = await grafanaRequestJson(
137-
apiPath(input, "folders"),
159+
await apiPath(input, "folders", { ...context, phase: "execute" }),
138160
{ method: "GET", query },
139161
{
140162
...context,
@@ -153,7 +175,7 @@ async function executeListFolders(input: Record<string, unknown>, context: Grafa
153175

154176
async function executeGetFolder(input: Record<string, unknown>, context: GrafanaContext): Promise<unknown> {
155177
const payload = await grafanaRequestJson(
156-
`${apiPath(input, "folders")}/${encodePathSegment(requireString(input.uid, "uid"))}`,
178+
`${await apiPath(input, "folders", { ...context, phase: "execute" })}/${encodePathSegment(requireString(input.uid, "uid"))}`,
157179
{ method: "GET" },
158180
{ ...context, phase: "execute" },
159181
);
@@ -162,7 +184,7 @@ async function executeGetFolder(input: Record<string, unknown>, context: Grafana
162184

163185
async function executeCreateFolder(input: Record<string, unknown>, context: GrafanaContext): Promise<unknown> {
164186
const payload = await grafanaRequestJson(
165-
apiPath(input, "folders"),
187+
await apiPath(input, "folders", { ...context, phase: "execute" }),
166188
{ method: "POST", body: folderRequestBody(input) },
167189
{ ...context, phase: "execute" },
168190
);
@@ -172,7 +194,7 @@ async function executeCreateFolder(input: Record<string, unknown>, context: Graf
172194
async function executeUpdateFolder(input: Record<string, unknown>, context: GrafanaContext): Promise<unknown> {
173195
const uid = requireString(input.uid, "uid");
174196
const payload = await grafanaRequestJson(
175-
`${apiPath(input, "folders")}/${encodePathSegment(uid)}`,
197+
`${await apiPath(input, "folders", { ...context, phase: "execute" })}/${encodePathSegment(uid)}`,
176198
{ method: "PUT", body: folderRequestBody(input, uid) },
177199
{ ...context, phase: "execute" },
178200
);
@@ -181,7 +203,7 @@ async function executeUpdateFolder(input: Record<string, unknown>, context: Graf
181203

182204
async function executeDeleteFolder(input: Record<string, unknown>, context: GrafanaContext): Promise<unknown> {
183205
const payload = await grafanaRequestJson(
184-
`${apiPath(input, "folders")}/${encodePathSegment(requireString(input.uid, "uid"))}`,
206+
`${await apiPath(input, "folders", { ...context, phase: "execute" })}/${encodePathSegment(requireString(input.uid, "uid"))}`,
185207
{ method: "DELETE" },
186208
{ ...context, phase: "execute" },
187209
);
@@ -222,7 +244,7 @@ async function executeSearchDashboards(input: Record<string, unknown>, context:
222244

223245
async function executeGetDashboard(input: Record<string, unknown>, context: GrafanaContext): Promise<unknown> {
224246
const payload = await grafanaRequestJson(
225-
`${apiPath(input, "dashboards")}/${encodePathSegment(requireString(input.uid, "uid"))}`,
247+
`${await apiPath(input, "dashboards", { ...context, phase: "execute" })}/${encodePathSegment(requireString(input.uid, "uid"))}`,
226248
{ method: "GET" },
227249
{ ...context, phase: "execute" },
228250
);
@@ -231,7 +253,7 @@ async function executeGetDashboard(input: Record<string, unknown>, context: Graf
231253

232254
async function executeCreateDashboard(input: Record<string, unknown>, context: GrafanaContext): Promise<unknown> {
233255
const payload = await grafanaRequestJson(
234-
apiPath(input, "dashboards"),
256+
await apiPath(input, "dashboards", { ...context, phase: "execute" }),
235257
{ method: "POST", body: dashboardRequestBody(input) },
236258
{ ...context, phase: "execute" },
237259
);
@@ -241,7 +263,7 @@ async function executeCreateDashboard(input: Record<string, unknown>, context: G
241263
async function executeUpdateDashboard(input: Record<string, unknown>, context: GrafanaContext): Promise<unknown> {
242264
const uid = requireString(input.uid, "uid");
243265
const payload = await grafanaRequestJson(
244-
`${apiPath(input, "dashboards")}/${encodePathSegment(uid)}`,
266+
`${await apiPath(input, "dashboards", { ...context, phase: "execute" })}/${encodePathSegment(uid)}`,
245267
{ method: "PUT", body: dashboardRequestBody(input, uid) },
246268
{ ...context, phase: "execute" },
247269
);
@@ -250,7 +272,7 @@ async function executeUpdateDashboard(input: Record<string, unknown>, context: G
250272

251273
async function executeDeleteDashboard(input: Record<string, unknown>, context: GrafanaContext): Promise<unknown> {
252274
const payload = await grafanaRequestJson(
253-
`${apiPath(input, "dashboards")}/${encodePathSegment(requireString(input.uid, "uid"))}`,
275+
`${await apiPath(input, "dashboards", { ...context, phase: "execute" })}/${encodePathSegment(requireString(input.uid, "uid"))}`,
254276
{ method: "DELETE" },
255277
{ ...context, phase: "execute" },
256278
);
@@ -427,10 +449,57 @@ function extractGrafanaErrorMessage(payload: unknown): string | undefined {
427449
);
428450
}
429451

430-
function apiPath(input: Record<string, unknown>, resource: "folders" | "dashboards"): string {
452+
async function resolveGrafanaApiVersion(
453+
group: string,
454+
context: GrafanaContext & { phase: GrafanaRequestPhase },
455+
): Promise<string> {
456+
const cacheKey = `${context.baseUrl}|${group}`;
457+
const cached = grafanaApiVersionCache.get(cacheKey);
458+
if (cached !== undefined) {
459+
return cached;
460+
}
461+
462+
try {
463+
const payload = await grafanaRequestJson(`/apis/${group}`, { method: "GET" }, context);
464+
const record = optionalRecord(payload) ?? {};
465+
const served = new Set(
466+
objectArrayOrEmpty(record.versions)
467+
.map((entry) => optionalString(entry.version))
468+
.filter((version): version is string => version !== undefined),
469+
);
470+
const match = grafanaApiVersionPreference.find((version) => served.has(version));
471+
if (match !== undefined) {
472+
cacheGrafanaApiVersion(cacheKey, match);
473+
return match;
474+
}
475+
} catch {
476+
// Discovery is best-effort. Falling back to the newest known version keeps the
477+
// previous behaviour for servers that do not expose the discovery endpoint.
478+
}
479+
480+
return grafanaDefaultApiVersion;
481+
}
482+
483+
function cacheGrafanaApiVersion(cacheKey: string, version: string): void {
484+
grafanaApiVersionCache.delete(cacheKey);
485+
if (grafanaApiVersionCache.size >= grafanaApiVersionCacheMaxEntries) {
486+
const oldestKey = grafanaApiVersionCache.keys().next().value;
487+
if (oldestKey !== undefined) {
488+
grafanaApiVersionCache.delete(oldestKey);
489+
}
490+
}
491+
grafanaApiVersionCache.set(cacheKey, version);
492+
}
493+
494+
async function apiPath(
495+
input: Record<string, unknown>,
496+
resource: GrafanaAppResource,
497+
context: GrafanaContext & { phase: GrafanaRequestPhase },
498+
): Promise<string> {
431499
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}`;
500+
const group = grafanaAppApiGroups[resource];
501+
const version = await resolveGrafanaApiVersion(group, context);
502+
return `/apis/${group}/${version}/namespaces/${encodePathSegment(namespace)}/${resource}`;
434503
}
435504

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

0 commit comments

Comments
 (0)