@grafana/sign-plugin 3.3.1 → 3.3.3
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 +14 -0
- package/dist/commands/sign.command.js +24 -2
- package/dist/libs/find-up/src/index.js +22 -0
- package/dist/libs/version/src/index.js +1 -1
- package/dist/utils/manifest.js +23 -50
- package/package.json +5 -3
- package/src/commands/sign.command.test.ts +194 -0
- package/src/commands/sign.command.ts +27 -2
- package/src/utils/getCreatePluginVersion.test.ts +41 -0
- package/src/utils/manifest.test.ts +209 -0
- package/src/utils/manifest.ts +35 -54
- package/src/utils/pluginValidation.test.ts +39 -1
- package/src/utils/tests/fixtures.ts +34 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [3.3.3](https://github.com/grafana/plugin-tools/compare/@grafana/sign-plugin@3.3.2...@grafana/sign-plugin@3.3.3) (2026-07-14)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Bug Fixes
|
|
7
|
+
|
|
8
|
+
* **sign-plugin:** prevent symlink escape check bypass and add test coverage ([#2721](https://github.com/grafana/plugin-tools/issues/2721)) ([4a28227](https://github.com/grafana/plugin-tools/commit/4a2822764abdca815ebe848a3f9678b2b86fa63c))
|
|
9
|
+
|
|
10
|
+
## [3.3.2](https://github.com/grafana/plugin-tools/compare/@grafana/sign-plugin@3.3.1...@grafana/sign-plugin@3.3.2) (2026-07-09)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
### Bug Fixes
|
|
14
|
+
|
|
15
|
+
* replace find-up dependency with native @libs/find-up helper ([#2722](https://github.com/grafana/plugin-tools/issues/2722)) ([d20ff77](https://github.com/grafana/plugin-tools/commit/d20ff7714de762a2d7ce96cf33a37d615e78e77c))
|
|
16
|
+
|
|
3
17
|
## [3.3.1](https://github.com/grafana/plugin-tools/compare/@grafana/sign-plugin@3.3.0...@grafana/sign-plugin@3.3.1) (2026-06-11)
|
|
4
18
|
|
|
5
19
|
|
|
@@ -40,10 +40,11 @@ const sign = async (argv) => {
|
|
|
40
40
|
if (createPluginVersion) {
|
|
41
41
|
manifest.createPlugin = { version: createPluginVersion };
|
|
42
42
|
}
|
|
43
|
-
const
|
|
43
|
+
const token = getSigningToken();
|
|
44
|
+
const signedManifest = await signManifest(manifest, token);
|
|
44
45
|
saveManifest(pluginDistDir, signedManifest);
|
|
45
46
|
output.success({
|
|
46
|
-
title: `Plugin signed
|
|
47
|
+
title: `Plugin signed successfully.`,
|
|
47
48
|
body: [`Signed manifest saved to ${pluginDistDir}.`]
|
|
48
49
|
});
|
|
49
50
|
} catch (err) {
|
|
@@ -56,5 +57,26 @@ const sign = async (argv) => {
|
|
|
56
57
|
process.exit(1);
|
|
57
58
|
}
|
|
58
59
|
};
|
|
60
|
+
function getSigningToken() {
|
|
61
|
+
const GRAFANA_API_KEY = process.env.GRAFANA_API_KEY;
|
|
62
|
+
const GRAFANA_ACCESS_POLICY_TOKEN = process.env.GRAFANA_ACCESS_POLICY_TOKEN;
|
|
63
|
+
const token = GRAFANA_ACCESS_POLICY_TOKEN ? GRAFANA_ACCESS_POLICY_TOKEN : GRAFANA_API_KEY;
|
|
64
|
+
if (!token) {
|
|
65
|
+
output.error({
|
|
66
|
+
title: "Missing GRAFANA_ACCESS_POLICY_TOKEN.",
|
|
67
|
+
body: ["You must create a GRAFANA_ACCESS_POLICY_TOKEN env variable to sign plugins."],
|
|
68
|
+
link: "https://grafana.com/developers/plugin-tools/publish-a-plugin/sign-a-plugin#generate-an-access-policy-token"
|
|
69
|
+
});
|
|
70
|
+
process.exit(1);
|
|
71
|
+
}
|
|
72
|
+
if (GRAFANA_API_KEY) {
|
|
73
|
+
output.warning({
|
|
74
|
+
title: "Usage of GRAFANA_API_KEY is deprecated.",
|
|
75
|
+
body: ["Please migrate to using a GRAFANA_ACCESS_POLICY_TOKEN instead."],
|
|
76
|
+
link: "https://grafana.com/developers/plugin-tools/publish-a-plugin/sign-a-plugin"
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
return token;
|
|
80
|
+
}
|
|
59
81
|
|
|
60
82
|
export { sign };
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { statSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
function findUpSync(name, options = {}) {
|
|
5
|
+
const names = Array.isArray(name) ? name : [name];
|
|
6
|
+
let currentDir = path.resolve(options.cwd ?? process.cwd());
|
|
7
|
+
let previousDir;
|
|
8
|
+
while (currentDir !== previousDir) {
|
|
9
|
+
for (const candidateName of names) {
|
|
10
|
+
const candidatePath = path.join(currentDir, candidateName);
|
|
11
|
+
const stat = statSync(candidatePath, { throwIfNoEntry: false });
|
|
12
|
+
if (stat?.isFile()) {
|
|
13
|
+
return candidatePath;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
previousDir = currentDir;
|
|
17
|
+
currentDir = path.dirname(currentDir);
|
|
18
|
+
}
|
|
19
|
+
return void 0;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export { findUpSync };
|
package/dist/utils/manifest.js
CHANGED
|
@@ -3,8 +3,6 @@ import { readFileSync, writeFileSync } from 'node:fs';
|
|
|
3
3
|
import fs from 'node:fs/promises';
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
import { postData } from './request.js';
|
|
6
|
-
import { output } from './utils.output.js';
|
|
7
|
-
import { styleText } from 'node:util';
|
|
8
6
|
|
|
9
7
|
const MANIFEST_FILE = "MANIFEST.txt";
|
|
10
8
|
async function* walk(dir, baseDir) {
|
|
@@ -16,7 +14,9 @@ async function* walk(dir, baseDir) {
|
|
|
16
14
|
yield path.relative(baseDir, entry);
|
|
17
15
|
} else if (d.isSymbolicLink()) {
|
|
18
16
|
const realPath = await fs.realpath(entry);
|
|
19
|
-
|
|
17
|
+
const relativeToBase = path.relative(baseDir, realPath);
|
|
18
|
+
const isOutsideBaseDir = relativeToBase === ".." || relativeToBase.startsWith(".." + path.sep) || path.isAbsolute(relativeToBase);
|
|
19
|
+
if (isOutsideBaseDir) {
|
|
20
20
|
throw new Error(
|
|
21
21
|
`symbolic link ${path.relative(baseDir, entry)} targets a file outside of the base directory: ${baseDir}`
|
|
22
22
|
);
|
|
@@ -29,65 +29,42 @@ async function* walk(dir, baseDir) {
|
|
|
29
29
|
}
|
|
30
30
|
}
|
|
31
31
|
async function buildManifest(dir) {
|
|
32
|
-
const
|
|
32
|
+
const baseDir = await fs.realpath(dir);
|
|
33
|
+
const pluginJson = JSON.parse(readFileSync(path.join(baseDir, "plugin.json"), { encoding: "utf8" }));
|
|
33
34
|
const manifest = {
|
|
34
35
|
plugin: pluginJson.id,
|
|
35
36
|
version: pluginJson.info.version,
|
|
36
37
|
files: {}
|
|
37
38
|
};
|
|
38
|
-
for await (const filePath of await walk(
|
|
39
|
+
for await (const filePath of await walk(baseDir, baseDir)) {
|
|
39
40
|
if (filePath === MANIFEST_FILE) {
|
|
40
41
|
continue;
|
|
41
42
|
}
|
|
42
43
|
const sanitisedFilePath = filePath.split(path.sep).join(path.posix.sep);
|
|
43
|
-
manifest.files[sanitisedFilePath] = crypto.createHash("sha256").update(readFileSync(path.join(
|
|
44
|
+
manifest.files[sanitisedFilePath] = crypto.createHash("sha256").update(readFileSync(path.join(baseDir, filePath))).digest("hex");
|
|
44
45
|
}
|
|
45
46
|
return manifest;
|
|
46
47
|
}
|
|
47
|
-
async function signManifest(manifest) {
|
|
48
|
-
const GRAFANA_API_KEY = process.env.GRAFANA_API_KEY;
|
|
49
|
-
const GRAFANA_ACCESS_POLICY_TOKEN = process.env.GRAFANA_ACCESS_POLICY_TOKEN;
|
|
50
|
-
if (!GRAFANA_ACCESS_POLICY_TOKEN && !GRAFANA_API_KEY) {
|
|
51
|
-
output.error({
|
|
52
|
-
title: "Missing GRAFANA_ACCESS_POLICY_TOKEN.",
|
|
53
|
-
body: ["You must create a GRAFANA_ACCESS_POLICY_TOKEN env variable to sign plugins."],
|
|
54
|
-
link: "https://grafana.com/developers/plugin-tools/publish-a-plugin/sign-a-plugin#generate-an-access-policy-token"
|
|
55
|
-
});
|
|
56
|
-
process.exit(1);
|
|
57
|
-
}
|
|
58
|
-
if (GRAFANA_API_KEY) {
|
|
59
|
-
output.warning({
|
|
60
|
-
title: "Usage of GRAFANA_API_KEY is deprecated.",
|
|
61
|
-
body: ["Please migrate to using a GRAFANA_ACCESS_POLICY_TOKEN instead."],
|
|
62
|
-
link: "https://grafana.com/developers/plugin-tools/publish-a-plugin/sign-a-plugin"
|
|
63
|
-
});
|
|
64
|
-
}
|
|
48
|
+
async function signManifest(manifest, token) {
|
|
65
49
|
const GRAFANA_COM_URL = process.env.GRAFANA_COM_URL || "https://grafana.com/api";
|
|
66
50
|
const url = GRAFANA_COM_URL + "/plugins/ci/sign";
|
|
67
|
-
const
|
|
51
|
+
const info = await postData(url, manifest, {
|
|
52
|
+
Authorization: "Bearer " + token
|
|
53
|
+
});
|
|
54
|
+
if (info.status !== 200) {
|
|
55
|
+
throw new Error(`Server responded with status code ${info.status} along with: ${formatServerError(info.data)}`);
|
|
56
|
+
}
|
|
57
|
+
return info.data;
|
|
58
|
+
}
|
|
59
|
+
function formatServerError(data) {
|
|
68
60
|
try {
|
|
69
|
-
const
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
if (info.status !== 200) {
|
|
73
|
-
const dataAsArray = Object.entries(JSON.parse(info.data)).map(([key, value]) => `${key}: ${value}`);
|
|
74
|
-
output.error({
|
|
75
|
-
title: "Error signing manifest.",
|
|
76
|
-
body: [
|
|
77
|
-
`Server responded with status code ${styleText(["yellow"], info.status.toString())} along with:`,
|
|
78
|
-
...output.bulletList(dataAsArray)
|
|
79
|
-
]
|
|
80
|
-
});
|
|
81
|
-
process.exit(1);
|
|
61
|
+
const parsed = JSON.parse(data);
|
|
62
|
+
if (parsed === null || typeof parsed !== "object") {
|
|
63
|
+
return String(parsed);
|
|
82
64
|
}
|
|
83
|
-
return
|
|
65
|
+
return Object.entries(parsed).map(([key, value]) => `${key}: ${typeof value === "string" ? value : JSON.stringify(value)}`).join(", ");
|
|
84
66
|
} catch (err) {
|
|
85
|
-
|
|
86
|
-
output.error({
|
|
87
|
-
title: "Error signing manifest.",
|
|
88
|
-
body
|
|
89
|
-
});
|
|
90
|
-
process.exit(1);
|
|
67
|
+
return data;
|
|
91
68
|
}
|
|
92
69
|
}
|
|
93
70
|
function saveManifest(dir, signedManifest) {
|
|
@@ -95,11 +72,7 @@ function saveManifest(dir, signedManifest) {
|
|
|
95
72
|
writeFileSync(path.join(dir, MANIFEST_FILE), signedManifest);
|
|
96
73
|
return true;
|
|
97
74
|
} catch (error) {
|
|
98
|
-
|
|
99
|
-
title: "Error saving manifest",
|
|
100
|
-
body: [`Failed to write signed manifest to ${dir}.`]
|
|
101
|
-
});
|
|
102
|
-
process.exit(1);
|
|
75
|
+
throw new Error(`Failed to write signed manifest to ${dir}.`, { cause: error });
|
|
103
76
|
}
|
|
104
77
|
}
|
|
105
78
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@grafana/sign-plugin",
|
|
3
|
-
"version": "3.3.
|
|
3
|
+
"version": "3.3.3",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
6
|
"directory": "packages/sign-plugin",
|
|
@@ -26,14 +26,16 @@
|
|
|
26
26
|
"typecheck": "tsc --noEmit"
|
|
27
27
|
},
|
|
28
28
|
"dependencies": {
|
|
29
|
-
"find-up": "8.0.0",
|
|
30
29
|
"minimist": "1.2.8",
|
|
31
30
|
"proxy-agent": "8.0.1"
|
|
32
31
|
},
|
|
33
32
|
"devDependencies": {
|
|
33
|
+
"@libs/find-up": "^1.0.0",
|
|
34
34
|
"@libs/output": "^1.0.3",
|
|
35
35
|
"@libs/version": "^1.0.2",
|
|
36
|
-
"@types/minimist": "^1.2.5"
|
|
36
|
+
"@types/minimist": "^1.2.5",
|
|
37
|
+
"@types/tmp": "0.2.6",
|
|
38
|
+
"tmp": "0.2.7"
|
|
37
39
|
},
|
|
38
40
|
"engines": {
|
|
39
41
|
"node": ">=20"
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import minimist from 'minimist';
|
|
2
|
+
import { existsSync, readFileSync, rmSync } from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { MockInstance, vi } from 'vitest';
|
|
5
|
+
import { sign } from './sign.command.js';
|
|
6
|
+
import { ManifestInfo } from '../utils/manifest.js';
|
|
7
|
+
import { createPluginDistDir, createTempDir, writeFiles } from '../utils/tests/fixtures.js';
|
|
8
|
+
|
|
9
|
+
const mocks = vi.hoisted(() => {
|
|
10
|
+
return {
|
|
11
|
+
postData: vi.fn(),
|
|
12
|
+
};
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
vi.mock('../utils/request.js', () => {
|
|
16
|
+
return { postData: mocks.postData };
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
const PROCESS_EXIT_MESSAGE = 'process.exit called';
|
|
20
|
+
|
|
21
|
+
function argvFor(args: Record<string, unknown> = {}): minimist.ParsedArgs {
|
|
22
|
+
return { _: [], ...args } as minimist.ParsedArgs;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function getPostedManifest(): ManifestInfo {
|
|
26
|
+
return mocks.postData.mock.calls[0][1] as ManifestInfo;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe('sign command', () => {
|
|
30
|
+
const tempDirs: string[] = [];
|
|
31
|
+
let stdoutSpy: MockInstance<typeof process.stdout.write>;
|
|
32
|
+
let exitSpy: MockInstance<typeof process.exit>;
|
|
33
|
+
|
|
34
|
+
function pluginDistDir(files: Record<string, string> = {}) {
|
|
35
|
+
const dir = createPluginDistDir(files);
|
|
36
|
+
tempDirs.push(dir);
|
|
37
|
+
return dir;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
beforeEach(() => {
|
|
41
|
+
exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => {
|
|
42
|
+
throw new Error(PROCESS_EXIT_MESSAGE);
|
|
43
|
+
});
|
|
44
|
+
stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
|
|
45
|
+
vi.stubEnv('GRAFANA_ACCESS_POLICY_TOKEN', 'test-token');
|
|
46
|
+
vi.stubEnv('GRAFANA_API_KEY', undefined);
|
|
47
|
+
vi.stubEnv('GRAFANA_COM_URL', undefined);
|
|
48
|
+
mocks.postData.mockReset().mockResolvedValue({ status: 200, data: 'SIGNED-MANIFEST' });
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
afterEach(() => {
|
|
52
|
+
vi.unstubAllEnvs();
|
|
53
|
+
vi.restoreAllMocks();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
afterAll(() => {
|
|
57
|
+
tempDirs.forEach((dir) => rmSync(dir, { recursive: true, force: true }));
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('should exit when the plugin dist directory does not exist', async () => {
|
|
61
|
+
await expect(sign(argvFor({ distDir: '/path/that/does/not/exist' }))).rejects.toThrow(PROCESS_EXIT_MESSAGE);
|
|
62
|
+
|
|
63
|
+
expect(exitSpy).toHaveBeenCalledWith(1);
|
|
64
|
+
expect(mocks.postData).not.toHaveBeenCalled();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('should post the manifest to the Grafana API and save the signed response', async () => {
|
|
68
|
+
const dir = pluginDistDir({ 'module.js': 'export {};' });
|
|
69
|
+
|
|
70
|
+
await sign(argvFor({ distDir: dir }));
|
|
71
|
+
|
|
72
|
+
const [url, manifest, headers] = mocks.postData.mock.calls[0];
|
|
73
|
+
expect(url).toBe('https://grafana.com/api/plugins/ci/sign');
|
|
74
|
+
expect(headers).toEqual({ Authorization: 'Bearer test-token' });
|
|
75
|
+
expect(manifest).toMatchObject({
|
|
76
|
+
plugin: 'grafana-test-app',
|
|
77
|
+
version: '1.0.0',
|
|
78
|
+
signPlugin: { version: expect.any(String) },
|
|
79
|
+
});
|
|
80
|
+
expect(readFileSync(path.join(dir, 'MANIFEST.txt'), 'utf-8')).toBe('SIGNED-MANIFEST');
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('should respect the GRAFANA_COM_URL environment variable', async () => {
|
|
84
|
+
vi.stubEnv('GRAFANA_COM_URL', 'https://grafana-dev.com/api');
|
|
85
|
+
const dir = pluginDistDir();
|
|
86
|
+
|
|
87
|
+
await sign(argvFor({ distDir: dir }));
|
|
88
|
+
|
|
89
|
+
expect(mocks.postData.mock.calls[0][0]).toBe('https://grafana-dev.com/api/plugins/ci/sign');
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('should pass signatureType and rootUrls to the manifest', async () => {
|
|
93
|
+
const dir = pluginDistDir();
|
|
94
|
+
|
|
95
|
+
await sign(
|
|
96
|
+
argvFor({
|
|
97
|
+
distDir: dir,
|
|
98
|
+
signatureType: 'private',
|
|
99
|
+
rootUrls: 'https://example.com/grafana,https://grafana.example.com',
|
|
100
|
+
})
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
expect(getPostedManifest()).toMatchObject({
|
|
104
|
+
signatureType: 'private',
|
|
105
|
+
rootUrls: ['https://example.com/grafana', 'https://grafana.example.com'],
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('should exit without calling the API when a root URL is invalid', async () => {
|
|
110
|
+
const dir = pluginDistDir();
|
|
111
|
+
|
|
112
|
+
await expect(sign(argvFor({ distDir: dir, rootUrls: 'not-a-valid-url' }))).rejects.toThrow(PROCESS_EXIT_MESSAGE);
|
|
113
|
+
|
|
114
|
+
expect(exitSpy).toHaveBeenCalledWith(1);
|
|
115
|
+
expect(mocks.postData).not.toHaveBeenCalled();
|
|
116
|
+
expect(existsSync(path.join(dir, 'MANIFEST.txt'))).toBe(false);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('should exit without calling the API when no token is configured', async () => {
|
|
120
|
+
vi.stubEnv('GRAFANA_ACCESS_POLICY_TOKEN', undefined);
|
|
121
|
+
const dir = pluginDistDir();
|
|
122
|
+
|
|
123
|
+
await expect(sign(argvFor({ distDir: dir }))).rejects.toThrow(PROCESS_EXIT_MESSAGE);
|
|
124
|
+
|
|
125
|
+
expect(exitSpy).toHaveBeenCalledWith(1);
|
|
126
|
+
expect(mocks.postData).not.toHaveBeenCalled();
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('should report an invalid root URL before a missing token', async () => {
|
|
130
|
+
vi.stubEnv('GRAFANA_ACCESS_POLICY_TOKEN', undefined);
|
|
131
|
+
const dir = pluginDistDir();
|
|
132
|
+
|
|
133
|
+
await expect(sign(argvFor({ distDir: dir, rootUrls: 'not-a-valid-url' }))).rejects.toThrow(PROCESS_EXIT_MESSAGE);
|
|
134
|
+
|
|
135
|
+
const stdout = stdoutSpy.mock.calls.map((call) => String(call[0])).join('');
|
|
136
|
+
expect(stdout).toContain('not-a-valid-url is not a valid URL');
|
|
137
|
+
expect(stdout).not.toContain('Missing GRAFANA_ACCESS_POLICY_TOKEN');
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('should prefer GRAFANA_ACCESS_POLICY_TOKEN over GRAFANA_API_KEY but still warn about deprecation', async () => {
|
|
141
|
+
vi.stubEnv('GRAFANA_API_KEY', 'legacy-api-key');
|
|
142
|
+
const dir = pluginDistDir();
|
|
143
|
+
|
|
144
|
+
await sign(argvFor({ distDir: dir }));
|
|
145
|
+
|
|
146
|
+
const stdout = stdoutSpy.mock.calls.map((call) => String(call[0])).join('');
|
|
147
|
+
expect(stdout).toContain('deprecated');
|
|
148
|
+
expect(mocks.postData.mock.calls[0][2]).toEqual({ Authorization: 'Bearer test-token' });
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('should warn about deprecation but proceed when only GRAFANA_API_KEY is set', async () => {
|
|
152
|
+
vi.stubEnv('GRAFANA_ACCESS_POLICY_TOKEN', undefined);
|
|
153
|
+
vi.stubEnv('GRAFANA_API_KEY', 'legacy-api-key');
|
|
154
|
+
const dir = pluginDistDir();
|
|
155
|
+
|
|
156
|
+
await sign(argvFor({ distDir: dir }));
|
|
157
|
+
|
|
158
|
+
const stdout = stdoutSpy.mock.calls.map((call) => String(call[0])).join('');
|
|
159
|
+
expect(stdout).toContain('deprecated');
|
|
160
|
+
expect(mocks.postData.mock.calls[0][2]).toEqual({ Authorization: 'Bearer legacy-api-key' });
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('should exit without saving the manifest when the API responds with an error', async () => {
|
|
164
|
+
mocks.postData.mockResolvedValue({ status: 400, data: JSON.stringify({ message: 'invalid plugin' }) });
|
|
165
|
+
const dir = pluginDistDir();
|
|
166
|
+
|
|
167
|
+
await expect(sign(argvFor({ distDir: dir }))).rejects.toThrow(PROCESS_EXIT_MESSAGE);
|
|
168
|
+
|
|
169
|
+
expect(exitSpy).toHaveBeenCalledWith(1);
|
|
170
|
+
expect(existsSync(path.join(dir, 'MANIFEST.txt'))).toBe(false);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('should exit when the API request fails', async () => {
|
|
174
|
+
mocks.postData.mockRejectedValue(new Error('socket hang up'));
|
|
175
|
+
const dir = pluginDistDir();
|
|
176
|
+
|
|
177
|
+
await expect(sign(argvFor({ distDir: dir }))).rejects.toThrow(PROCESS_EXIT_MESSAGE);
|
|
178
|
+
|
|
179
|
+
expect(exitSpy).toHaveBeenCalledWith(1);
|
|
180
|
+
expect(existsSync(path.join(dir, 'MANIFEST.txt'))).toBe(false);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it('should add the create-plugin version from .config/.cprc.json to the manifest', async () => {
|
|
184
|
+
const cwdDir = createTempDir();
|
|
185
|
+
tempDirs.push(cwdDir);
|
|
186
|
+
writeFiles(cwdDir, { '.config/.cprc.json': JSON.stringify({ version: '5.0.0' }) });
|
|
187
|
+
vi.spyOn(process, 'cwd').mockReturnValue(cwdDir);
|
|
188
|
+
const dir = pluginDistDir();
|
|
189
|
+
|
|
190
|
+
await sign(argvFor({ distDir: dir }));
|
|
191
|
+
|
|
192
|
+
expect(getPostedManifest().createPlugin).toEqual({ version: '5.0.0' });
|
|
193
|
+
});
|
|
194
|
+
});
|
|
@@ -46,11 +46,12 @@ export const sign = async (argv: minimist.ParsedArgs) => {
|
|
|
46
46
|
manifest.createPlugin = { version: createPluginVersion };
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
-
const
|
|
49
|
+
const token = getSigningToken();
|
|
50
|
+
const signedManifest = await signManifest(manifest, token);
|
|
50
51
|
|
|
51
52
|
saveManifest(pluginDistDir, signedManifest);
|
|
52
53
|
output.success({
|
|
53
|
-
title: `Plugin signed
|
|
54
|
+
title: `Plugin signed successfully.`,
|
|
54
55
|
body: [`Signed manifest saved to ${pluginDistDir}.`],
|
|
55
56
|
});
|
|
56
57
|
} catch (err) {
|
|
@@ -63,3 +64,27 @@ export const sign = async (argv: minimist.ParsedArgs) => {
|
|
|
63
64
|
process.exit(1);
|
|
64
65
|
}
|
|
65
66
|
};
|
|
67
|
+
|
|
68
|
+
function getSigningToken(): string {
|
|
69
|
+
const GRAFANA_API_KEY = process.env.GRAFANA_API_KEY;
|
|
70
|
+
const GRAFANA_ACCESS_POLICY_TOKEN = process.env.GRAFANA_ACCESS_POLICY_TOKEN;
|
|
71
|
+
const token = GRAFANA_ACCESS_POLICY_TOKEN ? GRAFANA_ACCESS_POLICY_TOKEN : GRAFANA_API_KEY;
|
|
72
|
+
|
|
73
|
+
if (!token) {
|
|
74
|
+
output.error({
|
|
75
|
+
title: 'Missing GRAFANA_ACCESS_POLICY_TOKEN.',
|
|
76
|
+
body: ['You must create a GRAFANA_ACCESS_POLICY_TOKEN env variable to sign plugins.'],
|
|
77
|
+
link: 'https://grafana.com/developers/plugin-tools/publish-a-plugin/sign-a-plugin#generate-an-access-policy-token',
|
|
78
|
+
});
|
|
79
|
+
process.exit(1);
|
|
80
|
+
}
|
|
81
|
+
if (GRAFANA_API_KEY) {
|
|
82
|
+
output.warning({
|
|
83
|
+
title: 'Usage of GRAFANA_API_KEY is deprecated.',
|
|
84
|
+
body: ['Please migrate to using a GRAFANA_ACCESS_POLICY_TOKEN instead.'],
|
|
85
|
+
link: 'https://grafana.com/developers/plugin-tools/publish-a-plugin/sign-a-plugin',
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return token;
|
|
90
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { rmSync } from 'node:fs';
|
|
2
|
+
import { vi } from 'vitest';
|
|
3
|
+
import { getCreatePluginVersion } from './getCreatePluginVersion.js';
|
|
4
|
+
import { createTempDir, writeFiles } from './tests/fixtures.js';
|
|
5
|
+
|
|
6
|
+
describe('getCreatePluginVersion', () => {
|
|
7
|
+
const tempDirs: string[] = [];
|
|
8
|
+
|
|
9
|
+
function mockCwd(files: Record<string, string> = {}) {
|
|
10
|
+
const dir = createTempDir();
|
|
11
|
+
tempDirs.push(dir);
|
|
12
|
+
writeFiles(dir, files);
|
|
13
|
+
vi.spyOn(process, 'cwd').mockReturnValue(dir);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
afterEach(() => {
|
|
17
|
+
vi.restoreAllMocks();
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
afterAll(() => {
|
|
21
|
+
tempDirs.forEach((dir) => rmSync(dir, { recursive: true, force: true }));
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('should return the version from .config/.cprc.json', () => {
|
|
25
|
+
mockCwd({ '.config/.cprc.json': JSON.stringify({ version: '5.0.0' }) });
|
|
26
|
+
|
|
27
|
+
expect(getCreatePluginVersion()).toBe('5.0.0');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('should return null when .config/.cprc.json does not exist', () => {
|
|
31
|
+
mockCwd();
|
|
32
|
+
|
|
33
|
+
expect(getCreatePluginVersion()).toBeNull();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('should return null when .config/.cprc.json is malformed', () => {
|
|
37
|
+
mockCwd({ '.config/.cprc.json': 'not-valid-json' });
|
|
38
|
+
|
|
39
|
+
expect(getCreatePluginVersion()).toBeNull();
|
|
40
|
+
});
|
|
41
|
+
});
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import { readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { vi } from 'vitest';
|
|
5
|
+
import { buildManifest, ManifestInfo, saveManifest, signManifest } from './manifest.js';
|
|
6
|
+
import { createPluginDistDir, createTempDir, DEFAULT_PLUGIN_JSON, writeFiles } from './tests/fixtures.js';
|
|
7
|
+
|
|
8
|
+
const mocks = vi.hoisted(() => {
|
|
9
|
+
return {
|
|
10
|
+
postData: vi.fn(),
|
|
11
|
+
};
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
vi.mock('./request.js', () => {
|
|
15
|
+
return { postData: mocks.postData };
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
function sha256(content: string) {
|
|
19
|
+
return crypto.createHash('sha256').update(content).digest('hex');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
describe('buildManifest', () => {
|
|
23
|
+
const tempDirs: string[] = [];
|
|
24
|
+
|
|
25
|
+
function pluginDistDir(files: Record<string, string> = {}, pluginJson: object = DEFAULT_PLUGIN_JSON) {
|
|
26
|
+
const dir = createPluginDistDir(files, pluginJson);
|
|
27
|
+
tempDirs.push(dir);
|
|
28
|
+
return dir;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
afterAll(() => {
|
|
32
|
+
tempDirs.forEach((dir) => rmSync(dir, { recursive: true, force: true }));
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('should build a manifest with the plugin id, version and a sha256 hash per file', async () => {
|
|
36
|
+
const dir = pluginDistDir({
|
|
37
|
+
'module.js': 'console.log("hello");',
|
|
38
|
+
'README.md': '# readme',
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
const manifest = await buildManifest(dir);
|
|
42
|
+
|
|
43
|
+
expect(manifest.plugin).toBe('grafana-test-app');
|
|
44
|
+
expect(manifest.version).toBe('1.0.0');
|
|
45
|
+
expect(manifest.files).toEqual({
|
|
46
|
+
'plugin.json': sha256(JSON.stringify(DEFAULT_PLUGIN_JSON)),
|
|
47
|
+
'module.js': sha256('console.log("hello");'),
|
|
48
|
+
'README.md': sha256('# readme'),
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('should include files in nested directories using posix paths', async () => {
|
|
53
|
+
const dir = pluginDistDir({
|
|
54
|
+
'img/logo.svg': '<svg></svg>',
|
|
55
|
+
'nested/deeply/file.txt': 'nested content',
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
const manifest = await buildManifest(dir);
|
|
59
|
+
|
|
60
|
+
expect(manifest.files['img/logo.svg']).toBe(sha256('<svg></svg>'));
|
|
61
|
+
expect(manifest.files['nested/deeply/file.txt']).toBe(sha256('nested content'));
|
|
62
|
+
expect(Object.keys(manifest.files).every((filePath) => !filePath.includes('\\'))).toBe(true);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('should exclude an existing MANIFEST.txt from the manifest', async () => {
|
|
66
|
+
const dir = pluginDistDir({
|
|
67
|
+
'module.js': 'export {};',
|
|
68
|
+
'MANIFEST.txt': 'a previously signed manifest',
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
const manifest = await buildManifest(dir);
|
|
72
|
+
|
|
73
|
+
expect(manifest.files['MANIFEST.txt']).toBeUndefined();
|
|
74
|
+
expect(manifest.files['module.js']).toBeDefined();
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('should include symbolic links that resolve to files inside the plugin directory', async () => {
|
|
78
|
+
const dir = pluginDistDir({
|
|
79
|
+
'module.js': 'export {};',
|
|
80
|
+
});
|
|
81
|
+
symlinkSync(path.join(dir, 'module.js'), path.join(dir, 'module-link.js'));
|
|
82
|
+
|
|
83
|
+
const manifest = await buildManifest(dir);
|
|
84
|
+
|
|
85
|
+
expect(manifest.files['module.js']).toBe(sha256('export {};'));
|
|
86
|
+
expect(manifest.files['module-link.js']).toBe(sha256('export {};'));
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('should throw when a symbolic link targets a file outside the plugin directory', async () => {
|
|
90
|
+
const outsideDir = createTempDir();
|
|
91
|
+
tempDirs.push(outsideDir);
|
|
92
|
+
writeFiles(outsideDir, { 'secret.txt': 'secret' });
|
|
93
|
+
|
|
94
|
+
const dir = pluginDistDir();
|
|
95
|
+
symlinkSync(path.join(outsideDir, 'secret.txt'), path.join(dir, 'secret-link.txt'));
|
|
96
|
+
|
|
97
|
+
await expect(buildManifest(dir)).rejects.toThrow(/targets a file outside of the base directory/);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('should throw when a symbolic link targets a sibling directory sharing the plugin directory prefix', async () => {
|
|
101
|
+
const containerDir = createTempDir();
|
|
102
|
+
tempDirs.push(containerDir);
|
|
103
|
+
const dir = path.join(containerDir, 'plugin');
|
|
104
|
+
const siblingDir = path.join(containerDir, 'plugin-evil');
|
|
105
|
+
writeFiles(dir, { 'plugin.json': JSON.stringify(DEFAULT_PLUGIN_JSON) });
|
|
106
|
+
writeFiles(siblingDir, { 'secret.txt': 'secret' });
|
|
107
|
+
symlinkSync(path.join(siblingDir, 'secret.txt'), path.join(dir, 'secret-link.txt'));
|
|
108
|
+
|
|
109
|
+
await expect(buildManifest(dir)).rejects.toThrow(/targets a file outside of the base directory/);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('should throw when plugin.json is missing', async () => {
|
|
113
|
+
const dir = createTempDir();
|
|
114
|
+
tempDirs.push(dir);
|
|
115
|
+
writeFileSync(path.join(dir, 'module.js'), 'export {};');
|
|
116
|
+
|
|
117
|
+
await expect(buildManifest(dir)).rejects.toThrow();
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
describe('signManifest', () => {
|
|
122
|
+
const manifest: ManifestInfo = {
|
|
123
|
+
plugin: 'grafana-test-app',
|
|
124
|
+
version: '1.0.0',
|
|
125
|
+
files: { 'plugin.json': 'hash' },
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
beforeEach(() => {
|
|
129
|
+
vi.stubEnv('GRAFANA_COM_URL', undefined);
|
|
130
|
+
mocks.postData.mockReset();
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
afterEach(() => {
|
|
134
|
+
vi.unstubAllEnvs();
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('should return the signed manifest when the API responds with status 200', async () => {
|
|
138
|
+
mocks.postData.mockResolvedValue({ status: 200, data: 'SIGNED-MANIFEST' });
|
|
139
|
+
|
|
140
|
+
const signedManifest = await signManifest(manifest, 'test-token');
|
|
141
|
+
|
|
142
|
+
expect(signedManifest).toBe('SIGNED-MANIFEST');
|
|
143
|
+
expect(mocks.postData).toHaveBeenCalledWith('https://grafana.com/api/plugins/ci/sign', manifest, {
|
|
144
|
+
Authorization: 'Bearer test-token',
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('should throw with the status code and server details when the API responds with an error', async () => {
|
|
149
|
+
mocks.postData.mockResolvedValue({ status: 400, data: JSON.stringify({ message: 'invalid plugin' }) });
|
|
150
|
+
|
|
151
|
+
await expect(signManifest(manifest, 'test-token')).rejects.toThrow(
|
|
152
|
+
'Server responded with status code 400 along with: message: invalid plugin'
|
|
153
|
+
);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('should throw with the raw response body when the error response is not JSON', async () => {
|
|
157
|
+
mocks.postData.mockResolvedValue({ status: 500, data: 'Internal Server Error' });
|
|
158
|
+
|
|
159
|
+
await expect(signManifest(manifest, 'test-token')).rejects.toThrow(
|
|
160
|
+
'Server responded with status code 500 along with: Internal Server Error'
|
|
161
|
+
);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('should stringify nested JSON values instead of rendering [object Object]', async () => {
|
|
165
|
+
mocks.postData.mockResolvedValue({ status: 422, data: JSON.stringify({ errors: { rootUrls: 'invalid' } }) });
|
|
166
|
+
|
|
167
|
+
await expect(signManifest(manifest, 'test-token')).rejects.toThrow(
|
|
168
|
+
'Server responded with status code 422 along with: errors: {"rootUrls":"invalid"}'
|
|
169
|
+
);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it('should handle a JSON primitive error body without a dangling message', async () => {
|
|
173
|
+
mocks.postData.mockResolvedValue({ status: 400, data: JSON.stringify('invalid plugin') });
|
|
174
|
+
|
|
175
|
+
await expect(signManifest(manifest, 'test-token')).rejects.toThrow(
|
|
176
|
+
'Server responded with status code 400 along with: invalid plugin'
|
|
177
|
+
);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('should propagate network errors', async () => {
|
|
181
|
+
mocks.postData.mockRejectedValue(new Error('socket hang up'));
|
|
182
|
+
|
|
183
|
+
await expect(signManifest(manifest, 'test-token')).rejects.toThrow('socket hang up');
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
describe('saveManifest', () => {
|
|
188
|
+
const tempDirs: string[] = [];
|
|
189
|
+
|
|
190
|
+
afterAll(() => {
|
|
191
|
+
tempDirs.forEach((dir) => rmSync(dir, { recursive: true, force: true }));
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it('should write the signed manifest to MANIFEST.txt', () => {
|
|
195
|
+
const dir = createTempDir();
|
|
196
|
+
tempDirs.push(dir);
|
|
197
|
+
|
|
198
|
+
const result = saveManifest(dir, 'SIGNED-MANIFEST');
|
|
199
|
+
|
|
200
|
+
expect(result).toBe(true);
|
|
201
|
+
expect(readFileSync(path.join(dir, 'MANIFEST.txt'), 'utf-8')).toBe('SIGNED-MANIFEST');
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it('should throw when the directory is not writable', () => {
|
|
205
|
+
expect(() => saveManifest('/path/that/does/not/exist', 'SIGNED-MANIFEST')).toThrow(
|
|
206
|
+
'Failed to write signed manifest to /path/that/does/not/exist.'
|
|
207
|
+
);
|
|
208
|
+
});
|
|
209
|
+
});
|
package/src/utils/manifest.ts
CHANGED
|
@@ -3,8 +3,6 @@ import { readFileSync, writeFileSync } from 'node:fs';
|
|
|
3
3
|
import fs from 'node:fs/promises';
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
import { postData } from './request.js';
|
|
6
|
-
import { output } from './utils.output.js';
|
|
7
|
-
import { styleText } from 'node:util';
|
|
8
6
|
|
|
9
7
|
const MANIFEST_FILE = 'MANIFEST.txt';
|
|
10
8
|
|
|
@@ -37,7 +35,11 @@ async function* walk(dir: string, baseDir: string): RecursiveWalk {
|
|
|
37
35
|
yield path.relative(baseDir, entry);
|
|
38
36
|
} else if (d.isSymbolicLink()) {
|
|
39
37
|
const realPath = await fs.realpath(entry);
|
|
40
|
-
|
|
38
|
+
// A prefix check would treat sibling paths like <baseDir>-evil as inside the base directory.
|
|
39
|
+
const relativeToBase = path.relative(baseDir, realPath);
|
|
40
|
+
const isOutsideBaseDir =
|
|
41
|
+
relativeToBase === '..' || relativeToBase.startsWith('..' + path.sep) || path.isAbsolute(relativeToBase);
|
|
42
|
+
if (isOutsideBaseDir) {
|
|
41
43
|
throw new Error(
|
|
42
44
|
`symbolic link ${path.relative(baseDir, entry)} targets a file outside of the base directory: ${baseDir}`
|
|
43
45
|
);
|
|
@@ -52,7 +54,11 @@ async function* walk(dir: string, baseDir: string): RecursiveWalk {
|
|
|
52
54
|
}
|
|
53
55
|
|
|
54
56
|
export async function buildManifest(dir: string): Promise<ManifestInfo> {
|
|
55
|
-
|
|
57
|
+
// Canonicalize the base directory once so the symlink-escape check in `walk`
|
|
58
|
+
// compares like-for-like. Without this, a dist path containing symlinks (e.g.
|
|
59
|
+
// macOS `/var` -> `/private/var`) makes internal symlinks look "outside".
|
|
60
|
+
const baseDir = await fs.realpath(dir);
|
|
61
|
+
const pluginJson = JSON.parse(readFileSync(path.join(baseDir, 'plugin.json'), { encoding: 'utf8' }));
|
|
56
62
|
|
|
57
63
|
const manifest = {
|
|
58
64
|
plugin: pluginJson.id,
|
|
@@ -60,7 +66,7 @@ export async function buildManifest(dir: string): Promise<ManifestInfo> {
|
|
|
60
66
|
files: {},
|
|
61
67
|
} as ManifestInfo;
|
|
62
68
|
|
|
63
|
-
for await (const filePath of await walk(
|
|
69
|
+
for await (const filePath of await walk(baseDir, baseDir)) {
|
|
64
70
|
if (filePath === MANIFEST_FILE) {
|
|
65
71
|
continue;
|
|
66
72
|
}
|
|
@@ -72,61 +78,40 @@ export async function buildManifest(dir: string): Promise<ManifestInfo> {
|
|
|
72
78
|
|
|
73
79
|
manifest.files[sanitisedFilePath] = crypto
|
|
74
80
|
.createHash('sha256')
|
|
75
|
-
.update(readFileSync(path.join(
|
|
81
|
+
.update(readFileSync(path.join(baseDir, filePath)))
|
|
76
82
|
.digest('hex');
|
|
77
83
|
}
|
|
78
84
|
|
|
79
85
|
return manifest;
|
|
80
86
|
}
|
|
81
87
|
|
|
82
|
-
export async function signManifest(manifest: ManifestInfo): Promise<string> {
|
|
83
|
-
const GRAFANA_API_KEY = process.env.GRAFANA_API_KEY;
|
|
84
|
-
const GRAFANA_ACCESS_POLICY_TOKEN = process.env.GRAFANA_ACCESS_POLICY_TOKEN;
|
|
85
|
-
|
|
86
|
-
if (!GRAFANA_ACCESS_POLICY_TOKEN && !GRAFANA_API_KEY) {
|
|
87
|
-
output.error({
|
|
88
|
-
title: 'Missing GRAFANA_ACCESS_POLICY_TOKEN.',
|
|
89
|
-
body: ['You must create a GRAFANA_ACCESS_POLICY_TOKEN env variable to sign plugins.'],
|
|
90
|
-
link: 'https://grafana.com/developers/plugin-tools/publish-a-plugin/sign-a-plugin#generate-an-access-policy-token',
|
|
91
|
-
});
|
|
92
|
-
process.exit(1);
|
|
93
|
-
}
|
|
94
|
-
if (GRAFANA_API_KEY) {
|
|
95
|
-
output.warning({
|
|
96
|
-
title: 'Usage of GRAFANA_API_KEY is deprecated.',
|
|
97
|
-
body: ['Please migrate to using a GRAFANA_ACCESS_POLICY_TOKEN instead.'],
|
|
98
|
-
link: 'https://grafana.com/developers/plugin-tools/publish-a-plugin/sign-a-plugin',
|
|
99
|
-
});
|
|
100
|
-
}
|
|
101
|
-
|
|
88
|
+
export async function signManifest(manifest: ManifestInfo, token: string): Promise<string> {
|
|
102
89
|
const GRAFANA_COM_URL = process.env.GRAFANA_COM_URL || 'https://grafana.com/api';
|
|
103
90
|
const url = GRAFANA_COM_URL + '/plugins/ci/sign';
|
|
104
91
|
|
|
105
|
-
const
|
|
92
|
+
const info = await postData(url, manifest, {
|
|
93
|
+
Authorization: 'Bearer ' + token,
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
if (info.status !== 200) {
|
|
97
|
+
throw new Error(`Server responded with status code ${info.status} along with: ${formatServerError(info.data)}`);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return info.data;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function formatServerError(data: string): string {
|
|
106
104
|
try {
|
|
107
|
-
const
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
const dataAsArray = Object.entries(JSON.parse(info.data)).map(([key, value]) => `${key}: ${value}`);
|
|
112
|
-
output.error({
|
|
113
|
-
title: 'Error signing manifest.',
|
|
114
|
-
body: [
|
|
115
|
-
`Server responded with status code ${styleText(['yellow'], info.status.toString())} along with:`,
|
|
116
|
-
...output.bulletList(dataAsArray),
|
|
117
|
-
],
|
|
118
|
-
});
|
|
119
|
-
process.exit(1);
|
|
105
|
+
const parsed = JSON.parse(data);
|
|
106
|
+
// A JSON primitive (string/number/boolean/null) has no entries to map over.
|
|
107
|
+
if (parsed === null || typeof parsed !== 'object') {
|
|
108
|
+
return String(parsed);
|
|
120
109
|
}
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
title: 'Error signing manifest.',
|
|
127
|
-
body,
|
|
128
|
-
});
|
|
129
|
-
process.exit(1);
|
|
110
|
+
return Object.entries(parsed)
|
|
111
|
+
.map(([key, value]) => `${key}: ${typeof value === 'string' ? value : JSON.stringify(value)}`)
|
|
112
|
+
.join(', ');
|
|
113
|
+
} catch (err) {
|
|
114
|
+
return data;
|
|
130
115
|
}
|
|
131
116
|
}
|
|
132
117
|
|
|
@@ -135,10 +120,6 @@ export function saveManifest(dir: string, signedManifest: string) {
|
|
|
135
120
|
writeFileSync(path.join(dir, MANIFEST_FILE), signedManifest);
|
|
136
121
|
return true;
|
|
137
122
|
} catch (error) {
|
|
138
|
-
|
|
139
|
-
title: 'Error saving manifest',
|
|
140
|
-
body: [`Failed to write signed manifest to ${dir}.`],
|
|
141
|
-
});
|
|
142
|
-
process.exit(1);
|
|
123
|
+
throw new Error(`Failed to write signed manifest to ${dir}.`, { cause: error });
|
|
143
124
|
}
|
|
144
125
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { getPluginJson, validatePluginJson } from './pluginValidation.js';
|
|
1
|
+
import { assertRootUrlIsValid, getPluginJson, validatePluginJson } from './pluginValidation.js';
|
|
2
2
|
|
|
3
3
|
describe('pluginValidation', () => {
|
|
4
4
|
describe('plugin.json', () => {
|
|
@@ -11,5 +11,43 @@ describe('pluginValidation', () => {
|
|
|
11
11
|
test('missing "id" field in the plugin.json file', () => {
|
|
12
12
|
expect(() => validatePluginJson({})).toThrow('Plugin id is missing in plugin.json');
|
|
13
13
|
});
|
|
14
|
+
|
|
15
|
+
test('missing "info" node in the plugin.json file', () => {
|
|
16
|
+
expect(() => validatePluginJson({ id: 'grafana-test-app' })).toThrow(
|
|
17
|
+
'Plugin info node is missing in plugin.json'
|
|
18
|
+
);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test('missing "info.version" field in the plugin.json file', () => {
|
|
22
|
+
expect(() => validatePluginJson({ id: 'grafana-test-app', info: {} })).toThrow(
|
|
23
|
+
'Plugin info.version is missing in plugin.json'
|
|
24
|
+
);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test('invalid plugin type', () => {
|
|
28
|
+
expect(() =>
|
|
29
|
+
validatePluginJson({ id: 'grafana-test-app', info: { version: '1.0.0' }, type: 'renderer' })
|
|
30
|
+
).toThrow('Invalid plugin type in plugin.json: renderer');
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test('plugin id not ending with the plugin type', () => {
|
|
34
|
+
expect(() =>
|
|
35
|
+
validatePluginJson({ id: 'grafana-test-datasource', info: { version: '1.0.0' }, type: 'app' })
|
|
36
|
+
).toThrow('[plugin.json] id should end with: -app');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test.each(['app', 'datasource', 'panel'])('valid plugin.json with type %s', (type) => {
|
|
40
|
+
expect(() => validatePluginJson({ id: `grafana-test-${type}`, info: { version: '1.0.0' }, type })).not.toThrow();
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
describe('assertRootUrlIsValid', () => {
|
|
45
|
+
test('valid root URL', () => {
|
|
46
|
+
expect(() => assertRootUrlIsValid('https://example.com/grafana')).not.toThrow();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test('invalid root URL', () => {
|
|
50
|
+
expect(() => assertRootUrlIsValid('not-a-valid-url')).toThrow('not-a-valid-url is not a valid URL');
|
|
51
|
+
});
|
|
14
52
|
});
|
|
15
53
|
});
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { mkdirSync, realpathSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { dirSync } from 'tmp';
|
|
4
|
+
|
|
5
|
+
export const DEFAULT_PLUGIN_JSON = {
|
|
6
|
+
id: 'grafana-test-app',
|
|
7
|
+
type: 'app',
|
|
8
|
+
info: {
|
|
9
|
+
version: '1.0.0',
|
|
10
|
+
},
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
// realpathSync prevents false symlink-escape errors on macOS where the temp dir
|
|
14
|
+
// lives under /var/... which resolves to /private/var/...
|
|
15
|
+
export function createTempDir(): string {
|
|
16
|
+
return realpathSync(dirSync({ prefix: 'sign-plugin-test-', unsafeCleanup: true }).name);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function writeFiles(dir: string, files: Record<string, string>): void {
|
|
20
|
+
for (const [relativePath, content] of Object.entries(files)) {
|
|
21
|
+
const filePath = path.join(dir, relativePath);
|
|
22
|
+
mkdirSync(path.dirname(filePath), { recursive: true });
|
|
23
|
+
writeFileSync(filePath, content);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function createPluginDistDir(files: Record<string, string> = {}, pluginJson: object = DEFAULT_PLUGIN_JSON) {
|
|
28
|
+
const dir = createTempDir();
|
|
29
|
+
writeFiles(dir, {
|
|
30
|
+
'plugin.json': JSON.stringify(pluginJson),
|
|
31
|
+
...files,
|
|
32
|
+
});
|
|
33
|
+
return dir;
|
|
34
|
+
}
|