@@ -13,6 +13,28 @@ import {
1313const defaultNamespace = "default" ;
1414const grafanaDefaultRequestTimeoutMs = 30_000 ;
1515const 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 > ( ) ;
1638const grafanaApiMetadataUrl = "https://grafana.com/docs/grafana/latest/developers/http_api/auth/#service-account-token" ;
1739
1840type 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
154176async 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
163185async 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
172194async 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
182204async 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
223245async 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
232254async 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
241263async 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
251273async 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
436505function folderRequestBody ( input : Record < string , unknown > , fallbackUid ?: string ) : Record < string , unknown > {
0 commit comments