@grafana/create-plugin 7.10.1 → 7.12.0
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/CHANGELOG.md +33 -0
- package/dist/codemods/additions/additions.js +5 -0
- package/dist/codemods/additions/scripts/experimental-app-sdk.js +275 -0
- package/dist/codemods/context.js +13 -1
- package/dist/codemods/migrations/migrations.js +6 -0
- package/dist/codemods/migrations/scripts/014-eslint-config-v10.js +35 -0
- package/dist/codemods/runner.js +2 -1
- package/dist/codemods/utils.goMod.js +45 -0
- package/dist/codemods/utils.js +25 -1
- package/dist/commands/add.command.js +9 -4
- package/dist/commands/generate/print-success-message.js +1 -1
- package/dist/utils/utils.templates.js +2 -2
- package/package.json +3 -3
- package/src/codemods/additions/additions.ts +5 -0
- package/src/codemods/additions/scripts/experimental-app-sdk.test.ts +655 -0
- package/src/codemods/additions/scripts/experimental-app-sdk.ts +431 -0
- package/src/codemods/context.test.ts +7 -1
- package/src/codemods/context.ts +23 -1
- package/src/codemods/migrations/migrations.ts +7 -0
- package/src/codemods/migrations/scripts/014-eslint-config-v10.test.ts +94 -0
- package/src/codemods/migrations/scripts/014-eslint-config-v10.ts +40 -0
- package/src/codemods/runner.ts +3 -1
- package/src/codemods/utils.goMod.test.ts +86 -0
- package/src/codemods/utils.goMod.ts +70 -0
- package/src/codemods/utils.test.ts +49 -0
- package/src/codemods/utils.ts +39 -0
- package/src/commands/add.command.ts +10 -5
- package/src/commands/generate/print-success-message.ts +1 -1
- package/src/utils/utils.templates.ts +2 -2
- package/templates/app/src/plugin.json +1 -1
- package/templates/app-sdk/.config/AGENTS/app-sdk.md +99 -0
- package/templates/app-sdk/.config/app-sdk/README.md +126 -0
- package/templates/app-sdk/.config/app-sdk/generate-kinds.mjs +138 -0
- package/templates/app-sdk/.github/workflows/generate-kinds-drift.yml +84 -0
- package/templates/app-sdk/kinds/README.md +3 -0
- package/templates/app-sdk/kinds/config.cue +39 -0
- package/templates/app-sdk/kinds/cue.mod/module.cue +4 -0
- package/templates/app-sdk/kinds/example.cue +32 -0
- package/templates/app-sdk/kinds/manifest.cue +53 -0
- package/templates/app-sdk/pkg/generated/example/v1alpha1/doc.go +11 -0
- package/templates/app-sdk/pkg/generated/manifestdata/doc.go +12 -0
- package/templates/app-sdk/pkg/provider/provider.go +30 -0
- package/templates/common/.config/docker-compose-base.yaml +1 -1
- package/templates/common/.config/eslint.config.mjs +1 -1
- package/templates/common/.config/jest-setup.js +3 -1
- package/templates/common/_package.json +13 -13
- package/templates/datasource/src/plugin.json +1 -1
- package/templates/panel/src/plugin.json +1 -1
- package/templates/scenes-app/src/plugin.json +1 -1
- package/vitest.setup.ts +2 -2
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { Context } from './context.js';
|
|
2
|
+
import { addRequireToGoMod } from './utils.goMod.js';
|
|
3
|
+
|
|
4
|
+
const GO_MOD = `module github.com/my-org/my-plugin
|
|
5
|
+
|
|
6
|
+
go 1.26.3
|
|
7
|
+
|
|
8
|
+
require github.com/grafana/grafana-plugin-sdk-go v0.285.0
|
|
9
|
+
|
|
10
|
+
require (
|
|
11
|
+
github.com/BurntSushi/toml v1.5.0 // indirect
|
|
12
|
+
)
|
|
13
|
+
`;
|
|
14
|
+
|
|
15
|
+
describe('addRequireToGoMod', () => {
|
|
16
|
+
it('inserts a new require line after the last existing one', () => {
|
|
17
|
+
const context = new Context('/virtual');
|
|
18
|
+
context.addFile('go.mod', GO_MOD);
|
|
19
|
+
|
|
20
|
+
addRequireToGoMod(context, 'github.com/grafana/grafana-app-sdk', 'v0.59.1');
|
|
21
|
+
|
|
22
|
+
const goMod = context.getFile('go.mod') ?? '';
|
|
23
|
+
expect(goMod).toContain('require github.com/grafana/grafana-app-sdk v0.59.1');
|
|
24
|
+
// The existing require survives, and the new one is inserted right after it.
|
|
25
|
+
expect(goMod).toContain(
|
|
26
|
+
'require github.com/grafana/grafana-plugin-sdk-go v0.285.0\nrequire github.com/grafana/grafana-app-sdk v0.59.1'
|
|
27
|
+
);
|
|
28
|
+
// The grouped require (...) block is left untouched.
|
|
29
|
+
expect(goMod).toContain('require (\n\tgithub.com/BurntSushi/toml v1.5.0 // indirect\n)');
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('inserts after the go directive when there is no existing require line', () => {
|
|
33
|
+
const context = new Context('/virtual');
|
|
34
|
+
context.addFile('go.mod', 'module github.com/my-org/my-plugin\n\ngo 1.26.3\n');
|
|
35
|
+
|
|
36
|
+
addRequireToGoMod(context, 'github.com/grafana/grafana-app-sdk', 'v0.59.1');
|
|
37
|
+
|
|
38
|
+
const goMod = context.getFile('go.mod') ?? '';
|
|
39
|
+
expect(goMod).toBe(
|
|
40
|
+
'module github.com/my-org/my-plugin\n\ngo 1.26.3\nrequire github.com/grafana/grafana-app-sdk v0.59.1\n'
|
|
41
|
+
);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('does not duplicate an already-present dependency at the same version', () => {
|
|
45
|
+
const context = new Context('/virtual');
|
|
46
|
+
context.addFile('go.mod', GO_MOD);
|
|
47
|
+
addRequireToGoMod(context, 'github.com/grafana/grafana-app-sdk', 'v0.59.1');
|
|
48
|
+
const afterFirst = context.getFile('go.mod');
|
|
49
|
+
|
|
50
|
+
addRequireToGoMod(context, 'github.com/grafana/grafana-app-sdk', 'v0.59.1');
|
|
51
|
+
|
|
52
|
+
expect(context.getFile('go.mod')).toBe(afterFirst);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('bumps the version when a greater one is requested', () => {
|
|
56
|
+
const context = new Context('/virtual');
|
|
57
|
+
context.addFile('go.mod', GO_MOD);
|
|
58
|
+
addRequireToGoMod(context, 'github.com/grafana/grafana-app-sdk', 'v0.59.1');
|
|
59
|
+
|
|
60
|
+
addRequireToGoMod(context, 'github.com/grafana/grafana-app-sdk', 'v0.60.0');
|
|
61
|
+
|
|
62
|
+
const goMod = context.getFile('go.mod') ?? '';
|
|
63
|
+
expect(goMod).toContain('require github.com/grafana/grafana-app-sdk v0.60.0');
|
|
64
|
+
expect(goMod).not.toContain('v0.59.1');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('does not downgrade when a lesser version is requested', () => {
|
|
68
|
+
const context = new Context('/virtual');
|
|
69
|
+
context.addFile('go.mod', GO_MOD);
|
|
70
|
+
addRequireToGoMod(context, 'github.com/grafana/grafana-app-sdk', 'v0.60.0');
|
|
71
|
+
|
|
72
|
+
addRequireToGoMod(context, 'github.com/grafana/grafana-app-sdk', 'v0.59.1');
|
|
73
|
+
|
|
74
|
+
const goMod = context.getFile('go.mod') ?? '';
|
|
75
|
+
expect(goMod).toContain('require github.com/grafana/grafana-app-sdk v0.60.0');
|
|
76
|
+
expect(goMod).not.toContain('v0.59.1');
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('does nothing when go.mod does not exist', () => {
|
|
80
|
+
const context = new Context('/virtual');
|
|
81
|
+
|
|
82
|
+
addRequireToGoMod(context, 'github.com/grafana/grafana-app-sdk', 'v0.59.1');
|
|
83
|
+
|
|
84
|
+
expect(context.doesFileExist('go.mod')).toBe(false);
|
|
85
|
+
});
|
|
86
|
+
});
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type { Context } from './context.js';
|
|
2
|
+
import { isVersionGreater, migrationsDebug } from './utils.js';
|
|
3
|
+
|
|
4
|
+
// Matches a single-line `require <module> <version>` statement (with an optional trailing
|
|
5
|
+
// `// indirect` comment), the shape every codemod-scaffolded backend's go.mod uses.
|
|
6
|
+
function singleLineRequireRegex(modulePath: string): RegExp {
|
|
7
|
+
const escapedModulePath = modulePath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
8
|
+
return new RegExp(`^require ${escapedModulePath} (\\S+)(.*)$`, 'm');
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Adds a `require <modulePath> <version>` line to go.mod, or bumps its version if it's already
|
|
13
|
+
* present with a lower one (using the same version-comparison logic as addDependenciesToPackageJson).
|
|
14
|
+
*
|
|
15
|
+
* Only understands the single-line `require <module> <version>` form that create-plugin's own
|
|
16
|
+
* templates use — it does not parse grouped `require (...)` blocks, `replace`/`exclude` directives,
|
|
17
|
+
* or anything else that would need a real go.mod grammar. A codemod that needs that should shell out
|
|
18
|
+
* to `go mod edit` against the real file on disk instead (see utils.goSdk.ts), outside of Context.
|
|
19
|
+
*/
|
|
20
|
+
export function addRequireToGoMod(context: Context, modulePath: string, version: string, goModPath = 'go.mod') {
|
|
21
|
+
const content = context.getFile(goModPath);
|
|
22
|
+
|
|
23
|
+
if (!content) {
|
|
24
|
+
migrationsDebug(`Could not find ${goModPath}. Skipping the ${modulePath} dependency.`);
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const requireRegex = singleLineRequireRegex(modulePath);
|
|
29
|
+
const match = content.match(requireRegex);
|
|
30
|
+
|
|
31
|
+
if (match) {
|
|
32
|
+
const [, existingVersion] = match;
|
|
33
|
+
if (!isVersionGreater(version, existingVersion, false)) {
|
|
34
|
+
migrationsDebug(`${goModPath} already requires ${modulePath} at ${existingVersion}. Skipping.`);
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const updated = content.replace(requireRegex, `require ${modulePath} ${version}$2`);
|
|
39
|
+
context.updateFile(goModPath, updated);
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const insertAt = findRequireInsertionPoint(content);
|
|
44
|
+
const newRequireLine = `require ${modulePath} ${version}\n`;
|
|
45
|
+
|
|
46
|
+
context.updateFile(goModPath, `${content.slice(0, insertAt)}${newRequireLine}${content.slice(insertAt)}`);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Finds where to insert a new top-level `require ...` line: right after the last existing one, else
|
|
51
|
+
* right after the `go 1.x` directive, else at the end of the file.
|
|
52
|
+
*/
|
|
53
|
+
function findRequireInsertionPoint(content: string): number {
|
|
54
|
+
const requireLines = [...content.matchAll(/^require \S+ \S+.*$/gm)];
|
|
55
|
+
const lastRequireLine = requireLines[requireLines.length - 1];
|
|
56
|
+
|
|
57
|
+
if (lastRequireLine) {
|
|
58
|
+
const lineEnd = content.indexOf('\n', lastRequireLine.index! + lastRequireLine[0].length);
|
|
59
|
+
return lineEnd === -1 ? content.length : lineEnd + 1;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const goDirective = content.match(/^go \d+\.\d+(\.\d+)?.*$/m);
|
|
63
|
+
|
|
64
|
+
if (goDirective) {
|
|
65
|
+
const lineEnd = content.indexOf('\n', goDirective.index! + goDirective[0].length);
|
|
66
|
+
return lineEnd === -1 ? content.length : lineEnd + 1;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return content.length;
|
|
70
|
+
}
|
|
@@ -8,13 +8,25 @@ import {
|
|
|
8
8
|
readJsonFile,
|
|
9
9
|
isVersionGreater,
|
|
10
10
|
printChanges,
|
|
11
|
+
runGoModTidy,
|
|
11
12
|
} from './utils.js';
|
|
12
13
|
import { join } from 'node:path';
|
|
13
14
|
import { mkdir, rm, writeFile } from 'node:fs/promises';
|
|
14
15
|
import { readFileSync } from 'node:fs';
|
|
16
|
+
import { execSync } from 'node:child_process';
|
|
17
|
+
import which from 'which';
|
|
15
18
|
import { output } from '../utils/utils.console.js';
|
|
16
19
|
import { vi } from 'vitest';
|
|
17
20
|
|
|
21
|
+
vi.mock(import('node:child_process'), async (importOriginal) => ({
|
|
22
|
+
...(await importOriginal()),
|
|
23
|
+
execSync: vi.fn(),
|
|
24
|
+
}));
|
|
25
|
+
|
|
26
|
+
vi.mock('which', () => ({
|
|
27
|
+
default: { sync: vi.fn() },
|
|
28
|
+
}));
|
|
29
|
+
|
|
18
30
|
describe('utils', () => {
|
|
19
31
|
const tmpObj = dirSync({ unsafeCleanup: true });
|
|
20
32
|
const tmpDir = join(tmpObj.name, 'cp-test-migration');
|
|
@@ -121,6 +133,43 @@ describe('utils', () => {
|
|
|
121
133
|
});
|
|
122
134
|
});
|
|
123
135
|
|
|
136
|
+
describe('runGoModTidy', () => {
|
|
137
|
+
beforeEach(() => {
|
|
138
|
+
vi.mocked(execSync).mockClear();
|
|
139
|
+
vi.mocked(which.sync).mockReset().mockReturnValue('/usr/local/bin/go');
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('runs `go mod tidy` when go.mod was updated', async () => {
|
|
143
|
+
await writeFile(join(tmpDir, 'go.mod'), 'module example.com/foo\n');
|
|
144
|
+
const context = new Context(tmpDir);
|
|
145
|
+
context.updateFile('go.mod', 'module example.com/foo\n\nrequire example.com/bar v1.0.0\n');
|
|
146
|
+
|
|
147
|
+
runGoModTidy(context);
|
|
148
|
+
|
|
149
|
+
expect(execSync).toHaveBeenCalledWith('go mod tidy', { cwd: tmpDir, stdio: 'inherit' });
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('does nothing when go.mod was not changed', () => {
|
|
153
|
+
const context = new Context(tmpDir);
|
|
154
|
+
context.addFile('other.txt', 'content');
|
|
155
|
+
|
|
156
|
+
runGoModTidy(context);
|
|
157
|
+
|
|
158
|
+
expect(execSync).not.toHaveBeenCalled();
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('does nothing when there is no `go` binary on PATH', async () => {
|
|
162
|
+
vi.mocked(which.sync).mockReturnValue(null as unknown as string);
|
|
163
|
+
await writeFile(join(tmpDir, 'go.mod'), 'module example.com/foo\n');
|
|
164
|
+
const context = new Context(tmpDir);
|
|
165
|
+
context.updateFile('go.mod', 'module example.com/foo\n\nrequire example.com/baz v2.0.0\n');
|
|
166
|
+
|
|
167
|
+
runGoModTidy(context);
|
|
168
|
+
|
|
169
|
+
expect(execSync).not.toHaveBeenCalled();
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
|
|
124
173
|
describe('readJsonFile', () => {
|
|
125
174
|
it('should read a json file', () => {
|
|
126
175
|
const context = new Context(tmpDir);
|
package/src/codemods/utils.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { styleText } from 'node:util';
|
|
|
6
6
|
import { output } from '../utils/utils.console.js';
|
|
7
7
|
import { getPackageManagerSilentInstallCmd, getPackageManagerWithFallback } from '../utils/utils.packageManager.js';
|
|
8
8
|
import { execSync } from 'node:child_process';
|
|
9
|
+
import which from 'which';
|
|
9
10
|
import { clean, coerce, gt, gte } from 'semver';
|
|
10
11
|
import { debug } from '../utils/utils.cli.js';
|
|
11
12
|
import { renderHandlebarsTemplate } from '../utils/utils.handlebars.js';
|
|
@@ -133,6 +134,44 @@ export function installNPMDependencies(context: Context) {
|
|
|
133
134
|
}
|
|
134
135
|
}
|
|
135
136
|
|
|
137
|
+
// Cache the go.mod contents to avoid re-running `go mod tidy` if go.mod hasn't changed
|
|
138
|
+
// (This runs for each codemod used in an update)
|
|
139
|
+
let goModTidyCache: string;
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Runs `go mod tidy` when a codemod has updated go.mod, mirroring installNPMDependencies. Skipped
|
|
143
|
+
* when there is no `go` binary on PATH, since a Go backend is optional for plugins that use this repo's
|
|
144
|
+
* tooling from a machine without a Go toolchain.
|
|
145
|
+
*/
|
|
146
|
+
export function runGoModTidy(context: Context) {
|
|
147
|
+
const hasGoModChanges = Object.entries(context.listChanges()).some(
|
|
148
|
+
([filePath, { changeType }]) => filePath === 'go.mod' && changeType === 'update'
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
if (!hasGoModChanges) {
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const goModContents = context.getFile('go.mod');
|
|
156
|
+
|
|
157
|
+
if (!goModContents) {
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (goModContents === goModTidyCache) {
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (!which.sync('go', { nothrow: true })) {
|
|
166
|
+
additionsDebug('No `go` binary found on PATH. Skipping `go mod tidy`.');
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
goModTidyCache = goModContents;
|
|
171
|
+
output.logSingleLine('Running `go mod tidy`...');
|
|
172
|
+
execSync('go mod tidy', { cwd: context.basePath, stdio: 'inherit' });
|
|
173
|
+
}
|
|
174
|
+
|
|
136
175
|
export function readJsonFile<T extends object = any>(context: Context, path: string): T {
|
|
137
176
|
if (!context.doesFileExist(path)) {
|
|
138
177
|
throw new Error(`Cannot find ${path}`);
|
|
@@ -24,11 +24,16 @@ export const add = async (argv: minimist.ParsedArgs) => {
|
|
|
24
24
|
|
|
25
25
|
// filter out minimist internal properties (_ and $0) before passing to codemod
|
|
26
26
|
const { _, $0, ...codemodOptions } = argv;
|
|
27
|
-
await runCodemod(addition, codemodOptions);
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
27
|
+
const context = await runCodemod(addition, codemodOptions);
|
|
28
|
+
|
|
29
|
+
const message = context.getMessage();
|
|
30
|
+
if (message) {
|
|
31
|
+
output[message.level]({ title: message.title, body: message.body });
|
|
32
|
+
} else {
|
|
33
|
+
output.success({
|
|
34
|
+
title: `Successfully added ${addition.name} to your plugin.`,
|
|
35
|
+
});
|
|
36
|
+
}
|
|
32
37
|
} catch (error) {
|
|
33
38
|
if (error instanceof Error) {
|
|
34
39
|
output.error({
|
|
@@ -39,7 +39,7 @@ export function printGenerateSuccessMessage(answers: TemplateData) {
|
|
|
39
39
|
});
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
-
function getBackendCmd() {
|
|
42
|
+
export function getBackendCmd() {
|
|
43
43
|
const platform = machine();
|
|
44
44
|
if (platform === 'arm64') {
|
|
45
45
|
return output.formatCode('mage -v build:linuxARM64');
|
|
@@ -121,7 +121,7 @@ export function getTemplateData(cliArgs?: GenerateCliArgs): TemplateData {
|
|
|
121
121
|
isNPM: isNPM(packageManagerName),
|
|
122
122
|
version: currentVersion,
|
|
123
123
|
bundleGrafanaUI,
|
|
124
|
-
scenesVersion: '^
|
|
124
|
+
scenesVersion: '^8.17.0',
|
|
125
125
|
useExperimentalRspack: Boolean(features.useExperimentalRspack),
|
|
126
126
|
frontendBundler,
|
|
127
127
|
};
|
|
@@ -145,7 +145,7 @@ export function getTemplateData(cliArgs?: GenerateCliArgs): TemplateData {
|
|
|
145
145
|
isNPM: isNPM(packageManagerName),
|
|
146
146
|
version: currentVersion,
|
|
147
147
|
bundleGrafanaUI,
|
|
148
|
-
scenesVersion: '^
|
|
148
|
+
scenesVersion: '^8.17.0',
|
|
149
149
|
pluginExecutable: pluginJson.executable,
|
|
150
150
|
useExperimentalRspack: Boolean(features.useExperimentalRspack),
|
|
151
151
|
frontendBundler,
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: grafana-app-sdk kind instructions for a grafana plugin
|
|
3
|
+
description: Guides how to work with CUE kinds and generated code in a plugin that uses the grafana-app-sdk
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# grafana-app-sdk kinds
|
|
7
|
+
|
|
8
|
+
This plugin defines its own API resources ("kinds") as [CUE](https://cuelang.org/) schemas using the
|
|
9
|
+
[grafana-app-sdk](https://github.com/grafana/grafana-app-sdk). Code generation turns those schemas into
|
|
10
|
+
TypeScript types and an app manifest that Grafana reads from the plugin bundle.
|
|
11
|
+
|
|
12
|
+
## Critical rules
|
|
13
|
+
|
|
14
|
+
- **`kinds/*.cue` is the source of truth for the API.** To change a resource's shape, edit the CUE and
|
|
15
|
+
regenerate. Never work the other way around.
|
|
16
|
+
- **Never hand-edit generated code.** Everything under the generated directories listed below is
|
|
17
|
+
overwritten on the next run, so edits there are silently lost. They carry a
|
|
18
|
+
`Code generated - EDITING IS FUTILE. DO NOT EDIT.` header.
|
|
19
|
+
- **Regenerate after every change under `kinds/`:**
|
|
20
|
+
```bash
|
|
21
|
+
{{ packageManagerName }} run generate:kinds
|
|
22
|
+
```
|
|
23
|
+
- **Generated code is committed.** Commit the regenerated files alongside the CUE change so schema
|
|
24
|
+
changes are reviewable and a fresh clone builds without running code generation.
|
|
25
|
+
- **Go code generation follows `kinds/config.cue`'s `codegen.goEnabled` setting.** If it's `false`,
|
|
26
|
+
generation emits only TypeScript and the JSON definitions — don't add Go output paths or a Go
|
|
27
|
+
backend to work around a generation problem.
|
|
28
|
+
- **Do not set `GRAFANA_APP_SDK_BIN`.** It overrides the pinned CLI with a local build, and is meant
|
|
29
|
+
for people working on the app-sdk itself. Code generated with it can differ from what `VERSION` in
|
|
30
|
+
`.config/app-sdk/generate-kinds.mjs` produces, so committing it would put the repository out of step. Run
|
|
31
|
+
`generate:kinds` without it.
|
|
32
|
+
- **Do not add code generation to the build.** It is a schema-change-time step, not a build step. The
|
|
33
|
+
frontend build must keep working without a Go toolchain.
|
|
34
|
+
- **Never write authorization/permission checks in app code.** Access is declared in the manifest and
|
|
35
|
+
enforced by Grafana's API server, not by this plugin.
|
|
36
|
+
- **A `Namespaced` kind is folder-scoped by default:** callers need the Stack Role *and* folder access,
|
|
37
|
+
and every write must set the `grafana.app/folder` annotation to a real folder the caller can access —
|
|
38
|
+
this applies to anything your backend writes on a user's behalf too. Set `folderScoped: false` on the
|
|
39
|
+
kind if it shouldn't be gated by folders (writes then skip the annotation).
|
|
40
|
+
- **Every role needs a `roleBindings` entry to be granted to anyone.** Defining a role in
|
|
41
|
+
`kinds/manifest.cue` without binding it to a basic role (`viewer`/`editor`/`admin`) leaves it unused.
|
|
42
|
+
- **Set `appDisplayName` in `kinds/manifest.cue`** so the app's roles show a readable name in Grafana's
|
|
43
|
+
role picker, instead of the raw `appName`.
|
|
44
|
+
- **`admin` currently grants the same actions as `editor`.** Don't design a feature assuming admin has
|
|
45
|
+
extra permissions today.
|
|
46
|
+
|
|
47
|
+
## Layout
|
|
48
|
+
|
|
49
|
+
| Path | What it is |
|
|
50
|
+
| ---- | ---------- |
|
|
51
|
+
| `kinds/manifest.cue` | The app manifest: app name, and the versions and kinds your app serves. |
|
|
52
|
+
| `kinds/*.cue` | One file per kind. Edit these to change a resource's schema. |
|
|
53
|
+
| `kinds/config.cue` | Code generation settings (output paths). Rarely needs changing. |
|
|
54
|
+
| `src/generated/` | Generated TypeScript types. **Do not edit.** Import from here in frontend code. |
|
|
55
|
+
| `src/app-sdk-manifest.json` | Generated app manifest JSON. **Do not edit.** The frontend build's existing JSON copy pattern carries it into the plugin bundle. |
|
|
56
|
+
|
|
57
|
+
## Changing a schema
|
|
58
|
+
|
|
59
|
+
1. Edit the relevant kind in `kinds/`. Add a new kind by creating a file and listing it in the version's
|
|
60
|
+
`kinds` array in `kinds/manifest.cue`.
|
|
61
|
+
2. Run `{{ packageManagerName }} run generate:kinds`.
|
|
62
|
+
3. Update the frontend to match. Because the TypeScript types are generated from the CUE, a schema
|
|
63
|
+
change surfaces as a type error wherever the code is now wrong — fix those rather than casting.
|
|
64
|
+
4. Commit the CUE and the regenerated files together.
|
|
65
|
+
|
|
66
|
+
Common CUE constructs, for reference:
|
|
67
|
+
|
|
68
|
+
```cue
|
|
69
|
+
spec: {
|
|
70
|
+
title: string // required
|
|
71
|
+
owner?: string // optional
|
|
72
|
+
tier: *"gold" | "silver" | "bronze" // enum, defaults to "gold"
|
|
73
|
+
labels: [string]: string // map
|
|
74
|
+
tags: [...string] // list
|
|
75
|
+
}
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
For anything beyond this — subtypes, time types, constraints, multiple versions — consult the app-sdk's
|
|
79
|
+
kind authoring reference at
|
|
80
|
+
https://github.com/grafana/grafana-app-sdk/blob/main/docs/custom-kinds/writing-kinds.md rather than
|
|
81
|
+
guessing at CUE syntax.
|
|
82
|
+
|
|
83
|
+
## Serving the kinds
|
|
84
|
+
|
|
85
|
+
Grafana serves storage and CRUD for the kinds from the manifest bundled in the plugin, behind the
|
|
86
|
+
`appplugins.loadAppManifest` and `appplugins.registerAPIServer` feature toggles. The development server
|
|
87
|
+
enables both. Grafana only reads plugin manifests at startup, so restart it after rebuilding.
|
|
88
|
+
|
|
89
|
+
Resources are served under a Kubernetes-style path:
|
|
90
|
+
|
|
91
|
+
```
|
|
92
|
+
/apis/<group>/<version>/namespaces/<namespace>/<plural>
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
The namespace is deployment-dependent — `default` on single-tenant Grafana, `stacks-<id>` on Grafana
|
|
96
|
+
Cloud. Read it from `config.namespace` in `@grafana/runtime`; never hardcode it.
|
|
97
|
+
|
|
98
|
+
No backend is needed for storage or CRUD — that comes from the manifest alone. Admission (validation
|
|
99
|
+
and mutation), conversion between versions, and custom routes require a backend.
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# Kinds (grafana-app-sdk)
|
|
2
|
+
|
|
3
|
+
The [kinds/](../../kinds) directory declares your app's API as [CUE](https://cuelang.org/) "kinds", which
|
|
4
|
+
[grafana-app-sdk](https://github.com/grafana/grafana-app-sdk) turns into TypeScript types and an app
|
|
5
|
+
manifest.
|
|
6
|
+
|
|
7
|
+
| File | Purpose |
|
|
8
|
+
| ---- | ------- |
|
|
9
|
+
| `manifest.cue` | The app manifest: app name, and the versions/kinds your app serves. |
|
|
10
|
+
| `example.cue` | An example kind. Rename it and edit its `spec` to model your own resource. |
|
|
11
|
+
| `config.cue` | Code generation settings (output paths). You rarely need to change this. |
|
|
12
|
+
| `cue.mod/module.cue` | The CUE module definition. |
|
|
13
|
+
|
|
14
|
+
## Generating code
|
|
15
|
+
|
|
16
|
+
After every change under this directory:
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
{{ packageManagerName }} run generate:kinds
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
That script always runs the `grafana-app-sdk` version set as `VERSION` at the top of
|
|
23
|
+
`.config/app-sdk/generate-kinds.mjs`, reusing a copy in `node_modules/.cache/` or downloading and checksum-verifying one for
|
|
24
|
+
your platform. A `grafana-app-sdk` on your `PATH` is ignored, so everyone on the project generates with
|
|
25
|
+
the same version. To run a local build instead, set `GRAFANA_APP_SDK_BIN` to its path. It writes:
|
|
26
|
+
|
|
27
|
+
| Output | Path |
|
|
28
|
+
| ------ | ---- |
|
|
29
|
+
| TypeScript types | `src/generated/<kind>/<version>/` |
|
|
30
|
+
| App manifest (JSON) | `src/app-sdk-manifest.json` |
|
|
31
|
+
| Go types (backend only) | `pkg/generated/` |
|
|
32
|
+
|
|
33
|
+
Generated code is meant to be committed, so schema changes show up in review and a fresh clone builds
|
|
34
|
+
without running code generation.
|
|
35
|
+
|
|
36
|
+
> **Note:** No Go toolchain is needed to run generate:kinds unless generating Go code.**
|
|
37
|
+
|
|
38
|
+
## How the manifest reaches Grafana
|
|
39
|
+
|
|
40
|
+
The generator writes the manifest straight into `src/app-sdk-manifest.json`, so the frontend build's
|
|
41
|
+
existing `**/*.json` copy pattern (`.config/bundler/copyFiles.ts`) carries it into the plugin bundle as
|
|
42
|
+
`dist/app-sdk-manifest.json` with no dedicated copy step. Grafana reads it when the
|
|
43
|
+
`appplugins.loadAppManifest` and `appplugins.registerAPIServer` feature toggles are enabled — the Docker
|
|
44
|
+
dev server in this repo enables both for you. Note the toggles are experimental and off by default in
|
|
45
|
+
Grafana.
|
|
46
|
+
|
|
47
|
+
With the manifest in place, Grafana serves storage and CRUD for your kinds through its aggregated API
|
|
48
|
+
server, and users can also manage the objects with `kubectl`.
|
|
49
|
+
|
|
50
|
+
## Adding a backend
|
|
51
|
+
|
|
52
|
+
Storage and CRUD come from the manifest alone, so a plugin with no backend still gets them. A backend will
|
|
53
|
+
be required for:
|
|
54
|
+
|
|
55
|
+
- Admission logic (i.e. validation/mutation)
|
|
56
|
+
- Conversion logic (i.e. between API versions)
|
|
57
|
+
- Custom routes and subresource routes
|
|
58
|
+
- Controller logic (informers, watchers, or any other background work)
|
|
59
|
+
|
|
60
|
+
If this plugin has a backend, `pkg/provider/provider.go` is where you wire those up as your app grows.
|
|
61
|
+
|
|
62
|
+
## Authorization & RBAC
|
|
63
|
+
|
|
64
|
+
You don't write permission checks in code. You declare roles and scoping in the manifest, and Grafana's
|
|
65
|
+
aggregated API server enforces them for every caller (kubectl, Terraform, generated clients, your
|
|
66
|
+
frontend). This only applies to requests from users (UI, Service Account tokens) — requests from a
|
|
67
|
+
Service Identity (another internal operator) are less strict.
|
|
68
|
+
|
|
69
|
+
### Folder scoping
|
|
70
|
+
|
|
71
|
+
Namespaced kinds (set in `kinds/*.cue`, see `example.cue`) are **folder-scoped by default**. Access
|
|
72
|
+
requires *both* the right Stack Role (see below) *and* folder access on the resource's
|
|
73
|
+
`grafana.app/folder`. If your kind's data doesn't belong in folders, opt out with
|
|
74
|
+
`folderScoped: false` — access then comes down to the Stack Role alone, and adding a folder annotation
|
|
75
|
+
to a request is rejected.
|
|
76
|
+
|
|
77
|
+
```cue
|
|
78
|
+
examplev1alpha1: exampleKind & {
|
|
79
|
+
// Opt this kind out of folder-scoped access.
|
|
80
|
+
folderScoped: false
|
|
81
|
+
schema: {
|
|
82
|
+
spec: {
|
|
83
|
+
title: string
|
|
84
|
+
description: string
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
### Roles and role bindings
|
|
91
|
+
|
|
92
|
+
Declare custom roles in `kinds/manifest.cue`. A role name follows `<app>:<role>` and grants one of three
|
|
93
|
+
tiers — `viewer` (read), `editor` (read + write), or `admin` (currently identical to `editor` — don't
|
|
94
|
+
design around admin having extra powers yet). Role bindings attach your app's roles to Grafana's basic
|
|
95
|
+
roles (`viewer`, `editor`, `admin`), so every Grafana user inherits the matching app role automatically.
|
|
96
|
+
Set `appDisplayName` so your app shows up with a readable name in the Grafana UI's role picker, instead
|
|
97
|
+
of the raw `appName`.
|
|
98
|
+
|
|
99
|
+
```cue
|
|
100
|
+
manifest: {
|
|
101
|
+
appName: "{{ pluginId }}"
|
|
102
|
+
appDisplayName: "{{ pluginId }}"
|
|
103
|
+
versions: {
|
|
104
|
+
"v1alpha1": v1alpha1
|
|
105
|
+
}
|
|
106
|
+
extraPermissions: {
|
|
107
|
+
accessKinds: []
|
|
108
|
+
}
|
|
109
|
+
// roles your app grants, and which Grafana basic role gets each one by default.
|
|
110
|
+
roles: [
|
|
111
|
+
{
|
|
112
|
+
name: "{{ pluginId }}:editor"
|
|
113
|
+
permissionSet: "editor"
|
|
114
|
+
},
|
|
115
|
+
]
|
|
116
|
+
roleBindings: [
|
|
117
|
+
{
|
|
118
|
+
roleName: "{{ pluginId }}:editor"
|
|
119
|
+
basicRole: "editor"
|
|
120
|
+
},
|
|
121
|
+
]
|
|
122
|
+
}
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
Without a `roleBindings` entry, a role you define is never granted to anyone — add one for every role you
|
|
126
|
+
want users to actually have.
|