K8s/Frontend: Add generic resource server and use it for playlists (#83339)

Co-authored-by: Bogdan Matei <bogdan.matei@grafana.com>
pull/85257/head
Ryan McKinley 1 year ago committed by GitHub
parent 85a646b4dc
commit 45d1766524
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 2
      .github/CODEOWNERS
  2. 81
      public/app/features/apiserver/server.ts
  3. 30
      public/app/features/apiserver/types.test.ts
  4. 135
      public/app/features/apiserver/types.ts
  5. 46
      public/app/features/playlist/api.ts
  6. 32
      public/app/features/scopes/server.ts

@ -403,6 +403,7 @@ playwright.config.ts @grafana/plugins-platform-frontend
/public/app/features/dashboard/ @grafana/dashboards-squad
/public/app/features/dashboard/components/TransformationsEditor/ @grafana/dataviz-squad
/public/app/features/dashboard-scene/ @grafana/dashboards-squad
/public/app/features/scopes/ @grafana/dashboards-squad
/public/app/features/datasources/ @grafana/plugins-platform-frontend @mikkancso
/public/app/features/dimensions/ @grafana/dataviz-squad
/public/app/features/dataframe-import/ @grafana/dataviz-squad
@ -414,6 +415,7 @@ playwright.config.ts @grafana/plugins-platform-frontend
/public/app/features/library-panels/ @grafana/dashboards-squad
/public/app/features/logs/ @grafana/observability-logs
/public/app/features/live/ @grafana/grafana-app-platform-squad
/public/app/features/apiserver/ @grafana/grafana-app-platform-squad
/public/app/features/manage-dashboards/ @grafana/dashboards-squad
/public/app/features/notifications/ @grafana/grafana-frontend-platform
/public/app/features/org/ @grafana/grafana-frontend-platform

@ -0,0 +1,81 @@
import { config, getBackendSrv } from '@grafana/runtime';
import {
ListOptions,
ListOptionsLabelSelector,
MetaStatus,
Resource,
ResourceForCreate,
ResourceList,
ResourceServer,
} from './types';
export interface GroupVersionResource {
group: string;
version: string;
resource: string;
}
export class ScopedResourceServer<T = object, K = string> implements ResourceServer<T, K> {
readonly url: string;
constructor(gvr: GroupVersionResource, namespaced = true) {
const ns = namespaced ? `namespaces/${config.namespace}/` : '';
this.url = `/apis/${gvr.group}/${gvr.version}/${ns}${gvr.resource}`;
}
public async create(obj: ResourceForCreate<T, K>): Promise<void> {
return getBackendSrv().post(this.url, obj);
}
public async get(name: string): Promise<Resource<T, K>> {
return getBackendSrv().get<Resource<T, K>>(`${this.url}/${name}`);
}
public async list(opts?: ListOptions<T> | undefined): Promise<ResourceList<T, K>> {
const finalOpts = opts || {};
finalOpts.labelSelector = this.parseLabelSelector(finalOpts?.labelSelector);
return getBackendSrv().get<ResourceList<T, K>>(this.url, opts);
}
public async update(obj: Resource<T, K>): Promise<Resource<T, K>> {
return getBackendSrv().put<Resource<T, K>>(`${this.url}/${obj.metadata.name}`, obj);
}
public async delete(name: string): Promise<MetaStatus> {
return getBackendSrv().delete<MetaStatus>(`${this.url}/${name}`);
}
private parseLabelSelector<T>(labelSelector: ListOptionsLabelSelector<T> | undefined): string | undefined {
if (!Array.isArray(labelSelector)) {
return labelSelector;
}
return labelSelector
.map((label) => {
const key = String(label.key);
const operator = label.operator;
switch (operator) {
case '=':
case '!=':
return `${key}${operator}${label.value}`;
case 'in':
case 'notin':
return `${key}${operator}(${label.value.join(',')})`;
case '':
case '!':
return `${operator}${key}`;
default:
return null;
}
})
.filter(Boolean)
.join(',');
}
}

@ -0,0 +1,30 @@
import { AnnoKeyCreatedBy, Resource } from './types';
interface MyObjSpec {
value: string;
count: number;
}
describe('simple typescript tests', () => {
const val: Resource<MyObjSpec, 'MyObject'> = {
apiVersion: 'xxx',
kind: 'MyObject',
metadata: {
name: 'A',
resourceVersion: '1',
creationTimestamp: '123',
},
spec: {
value: 'a',
count: 2,
},
};
describe('typescript helper', () => {
it('read and write annotations', () => {
expect(val.metadata.annotations?.[AnnoKeyCreatedBy]).toBeUndefined();
val.metadata.annotations = { 'grafana.app/createdBy': 'me' };
expect(val.metadata.annotations?.[AnnoKeyCreatedBy]).toBe('me');
});
});
});

@ -0,0 +1,135 @@
/**
* This file holds generic kubernetes compatible types.
*
* This is very much a work in progress aiming to simplify common access patterns for k8s resource
* Please update and improve types/utilities while we find a good pattern here!
*
* Once this is more stable and represents a more general pattern, it should be moved to @grafana/data
*
*/
/** The object type and version */
export interface TypeMeta<K = string> {
apiVersion: string;
kind: K;
}
export interface ObjectMeta {
// Name is the unique identifier in k8s -- it maps to the "uid" value in most existing grafana objects
name: string;
// Namespace maps the owner group -- it is typically the org or stackId for most grafana resources
namespace?: string;
// Resource version will increase (not sequentially!) with any change to the saved value
resourceVersion: string;
// The first time this was saved
creationTimestamp: string;
// General resource annotations -- including the common grafana.app values
annotations?: GrafanaAnnotations;
// General application level key+value pairs
labels?: Record<string, string>;
}
export const AnnoKeyCreatedBy = 'grafana.app/createdBy';
export const AnnoKeyUpdatedTimestamp = 'grafana.app/updatedTimestamp';
export const AnnoKeyUpdatedBy = 'grafana.app/updatedBy';
export const AnnoKeyFolder = 'grafana.app/folder';
export const AnnoKeySlug = 'grafana.app/slug';
// Identify where values came from
const AnnoKeyOriginName = 'grafana.app/originName';
const AnnoKeyOriginPath = 'grafana.app/originPath';
const AnnoKeyOriginKey = 'grafana.app/originKey';
const AnnoKeyOriginTimestamp = 'grafana.app/originTimestamp';
type GrafanaAnnotations = {
[AnnoKeyCreatedBy]?: string;
[AnnoKeyUpdatedTimestamp]?: string;
[AnnoKeyUpdatedBy]?: string;
[AnnoKeyFolder]?: string;
[AnnoKeySlug]?: string;
[AnnoKeyOriginName]?: string;
[AnnoKeyOriginPath]?: string;
[AnnoKeyOriginKey]?: string;
[AnnoKeyOriginTimestamp]?: string;
// Any key value
[key: string]: string | undefined;
};
export interface Resource<T = object, K = string> extends TypeMeta<K> {
metadata: ObjectMeta;
spec: T;
}
export interface ResourceForCreate<T = object, K = string> extends Partial<TypeMeta<K>> {
metadata: Partial<ObjectMeta>;
spec: T;
}
export interface ListMeta {
resourceVersion: string;
continue?: string;
remainingItemCount?: number;
}
export interface ResourceList<T, K = string> extends TypeMeta {
metadata: ListMeta;
items: Array<Resource<T, K>>;
}
export type ListOptionsLabelSelector<T = {}> =
| string
| Array<
| {
key: keyof T;
operator: '=' | '!=';
value: string;
}
| {
key: keyof T;
operator: 'in' | 'notin';
value: string[];
}
| {
key: keyof T;
operator: '' | '!';
}
>;
export interface ListOptions<T = {}> {
// continue the list at a given batch
continue?: string;
// Query by labels
// https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/#label-selectors
labelSelector?: ListOptionsLabelSelector<T>;
// Limit the response count
limit?: number;
}
export interface MetaStatus {
// Status of the operation. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#spec-and-status
status: 'Success' | 'Failure';
// A human-readable description of the status of this operation.
message: string;
// Suggested HTTP return code for this status, 0 if not set.
code: number;
// A machine-readable description of why this operation is in the "Failure" status.
reason?: string;
// Extended data associated with the reason
details?: object;
}
export interface ResourceServer<T = object, K = string> {
create(obj: ResourceForCreate<T, K>): Promise<void>;
get(name: string): Promise<Resource<T, K>>;
list(opts?: ListOptions<T>): Promise<ResourceList<T, K>>;
update(obj: ResourceForCreate<T, K>): Promise<Resource<T, K>>;
delete(name: string): Promise<MetaStatus>;
}

@ -8,6 +8,8 @@ import { getGrafanaDatasource } from 'app/plugins/datasource/grafana/datasource'
import { GrafanaQuery, GrafanaQueryType } from 'app/plugins/datasource/grafana/types';
import { dispatch } from 'app/store/store';
import { ScopedResourceServer } from '../apiserver/server';
import { Resource, ResourceForCreate, ResourceServer } from '../apiserver/types';
import { DashboardQueryResult, getGrafanaSearcher, SearchQuery } from '../search/service';
import { Playlist, PlaylistItem, PlaylistAPI } from './types';
@ -36,38 +38,32 @@ class LegacyAPI implements PlaylistAPI {
}
}
interface K8sPlaylistList {
items: K8sPlaylist[];
interface PlaylistSpec {
title: string;
interval: string;
items: PlaylistItem[];
}
interface K8sPlaylist {
apiVersion: string;
kind: 'Playlist';
metadata: {
name: string;
};
spec: {
title: string;
interval: string;
items: PlaylistItem[];
};
}
type K8sPlaylist = Resource<PlaylistSpec>;
class K8sAPI implements PlaylistAPI {
readonly apiVersion = 'playlist.grafana.app/v0alpha1';
readonly url: string;
readonly server: ResourceServer<PlaylistSpec>;
constructor() {
this.url = `/apis/${this.apiVersion}/namespaces/${config.namespace}/playlists`;
this.server = new ScopedResourceServer<PlaylistSpec>({
group: 'playlist.grafana.app',
version: 'v0alpha1',
resource: 'playlists',
});
}
async getAllPlaylist(): Promise<Playlist[]> {
const result = await getBackendSrv().get<K8sPlaylistList>(this.url);
const result = await this.server.list();
return result.items.map(k8sResourceAsPlaylist);
}
async getPlaylist(uid: string): Promise<Playlist> {
const r = await getBackendSrv().get<K8sPlaylist>(this.url + '/' + uid);
const r = await this.server.get(uid);
const p = k8sResourceAsPlaylist(r);
await migrateInternalIDs(p);
return p;
@ -75,22 +71,20 @@ class K8sAPI implements PlaylistAPI {
async createPlaylist(playlist: Playlist): Promise<void> {
const body = this.playlistAsK8sResource(playlist);
await withErrorHandling(() => getBackendSrv().post(this.url, body));
await withErrorHandling(() => this.server.create(body));
}
async updatePlaylist(playlist: Playlist): Promise<void> {
const body = this.playlistAsK8sResource(playlist);
await withErrorHandling(() => getBackendSrv().put(`${this.url}/${playlist.uid}`, body));
await withErrorHandling(() => this.server.update(body).then(() => {}));
}
async deletePlaylist(uid: string): Promise<void> {
await withErrorHandling(() => getBackendSrv().delete(`${this.url}/${uid}`), 'Playlist deleted');
await withErrorHandling(() => this.server.delete(uid).then(() => {}), 'Playlist deleted');
}
playlistAsK8sResource = (playlist: Playlist): K8sPlaylist => {
playlistAsK8sResource = (playlist: Playlist): ResourceForCreate<PlaylistSpec> => {
return {
apiVersion: this.apiVersion,
kind: 'Playlist',
metadata: {
name: playlist.uid, // uid as k8s name
},
@ -104,7 +98,7 @@ class K8sAPI implements PlaylistAPI {
}
// This converts a saved k8s resource into a playlist object
// the main difference is that k8s uses metdata.name as the uid
// the main difference is that k8s uses metadata.name as the uid
// to avoid future confusion, the display name is now called "title"
function k8sResourceAsPlaylist(r: K8sPlaylist): Playlist {
const { spec, metadata } = r;

@ -0,0 +1,32 @@
import { Scope, ScopeDashboard } from '@grafana/data';
import { ScopedResourceServer } from '../apiserver/server';
import { ResourceServer } from '../apiserver/types';
// config.bootData.settings.listDashboardScopesEndpoint || '/apis/scope.grafana.app/v0alpha1/scopedashboards';
// config.bootData.settings.listScopesEndpoint || '/apis/scope.grafana.app/v0alpha1/scopes';
interface ScopeServers {
scopes: ResourceServer<Scope>;
dashboards: ResourceServer<ScopeDashboard>;
}
let instance: ScopeServers | undefined = undefined;
export function getScopeServers() {
if (!instance) {
instance = {
scopes: new ScopedResourceServer<Scope>({
group: 'scope.grafana.app',
version: 'v0alpha1',
resource: 'scopes',
}),
dashboards: new ScopedResourceServer<ScopeDashboard>({
group: 'scope.grafana.app',
version: 'v0alpha1',
resource: 'scopedashboards',
}),
};
}
return instance;
}
Loading…
Cancel
Save