mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Toolkit: implement plugin signing in grafana-toolkit (#27907)
* implement plugin signing in grafana-toolkit
This commit is contained in:
parent
ad0f071159
commit
4629c44d49
@ -61,6 +61,7 @@ With grafana-toolkit, we give you a CLI that addresses common tasks performed wh
|
|||||||
- `grafana-toolkit plugin:dev`
|
- `grafana-toolkit plugin:dev`
|
||||||
- `grafana-toolkit plugin:test`
|
- `grafana-toolkit plugin:test`
|
||||||
- `grafana-toolkit plugin:build`
|
- `grafana-toolkit plugin:build`
|
||||||
|
- `grafana-toolkit plugin:sign`
|
||||||
|
|
||||||
### Create your plugin
|
### Create your plugin
|
||||||
|
|
||||||
@ -105,6 +106,19 @@ Available options:
|
|||||||
|
|
||||||
- `--coverage` - Reports code coverage after the test step of the build.
|
- `--coverage` - Reports code coverage after the test step of the build.
|
||||||
|
|
||||||
|
### Sign your plugin
|
||||||
|
|
||||||
|
`grafana-toolkit plugin:sign`
|
||||||
|
|
||||||
|
This command creates a signed MANIFEST.txt file which Grafana uses to validate the integrity of the plugin.
|
||||||
|
|
||||||
|
Available options:
|
||||||
|
|
||||||
|
- `--signatureType` - The [type of Signature](https://grafana.com/legal/plugins/) you are generating: `private`, `community` or `commercial`
|
||||||
|
- `--rootUrls` - For private signatures, a list of the Grafana instance URLs that the plugin will be used on
|
||||||
|
|
||||||
|
To generate a signature, you will need to sign up for a free account on https://grafana.com, create an API key with the Plugin Publisher role, and pass that in the `GRAFANA_API_KEY` environment variable.
|
||||||
|
|
||||||
## FAQ
|
## FAQ
|
||||||
|
|
||||||
### Which version of grafana-toolkit should I use?
|
### Which version of grafana-toolkit should I use?
|
||||||
|
@ -18,6 +18,7 @@ import { pluginUpdateTask } from './tasks/plugin.update';
|
|||||||
import { ciBuildPluginDocsTask, ciBuildPluginTask, ciPackagePluginTask, ciPluginReportTask } from './tasks/plugin.ci';
|
import { ciBuildPluginDocsTask, ciBuildPluginTask, ciPackagePluginTask, ciPluginReportTask } from './tasks/plugin.ci';
|
||||||
import { buildPackageTask } from './tasks/package.build';
|
import { buildPackageTask } from './tasks/package.build';
|
||||||
import { pluginCreateTask } from './tasks/plugin.create';
|
import { pluginCreateTask } from './tasks/plugin.create';
|
||||||
|
import { pluginSignTask } from './tasks/plugin.sign';
|
||||||
import { bundleManagedTask } from './tasks/plugin/bundle.managed';
|
import { bundleManagedTask } from './tasks/plugin/bundle.managed';
|
||||||
import { componentCreateTask } from './tasks/component.create';
|
import { componentCreateTask } from './tasks/component.create';
|
||||||
|
|
||||||
@ -179,6 +180,19 @@ export const run = (includeInternalScripts = false) => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
program
|
||||||
|
.command('plugin:sign')
|
||||||
|
.option('--signatureType <type>', 'Signature Type')
|
||||||
|
.option('--rootUrls <urls...>', 'Root URLs')
|
||||||
|
.description('Create a plugin signature')
|
||||||
|
.action(async cmd => {
|
||||||
|
await execTask(pluginSignTask)({
|
||||||
|
signatureType: cmd.signatureType,
|
||||||
|
rootUrls: cmd.rootUrls,
|
||||||
|
silent: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
program
|
program
|
||||||
.command('plugin:ci-build')
|
.command('plugin:ci-build')
|
||||||
.option('--finish', 'move all results to the jobs folder', false)
|
.option('--finish', 'move all results to the jobs folder', false)
|
||||||
@ -200,11 +214,14 @@ export const run = (includeInternalScripts = false) => {
|
|||||||
|
|
||||||
program
|
program
|
||||||
.command('plugin:ci-package')
|
.command('plugin:ci-package')
|
||||||
.option('--signing-admin', 'Use the admin API endpoint for signing the manifest.', false)
|
.option('--signatureType <type>', 'Signature Type')
|
||||||
|
.option('--rootUrls <urls...>', 'Root URLs')
|
||||||
|
.option('--signing-admin', 'Use the admin API endpoint for signing the manifest. (deprecated)', false)
|
||||||
.description('Create a zip packages for the plugin')
|
.description('Create a zip packages for the plugin')
|
||||||
.action(async cmd => {
|
.action(async cmd => {
|
||||||
await execTask(ciPackagePluginTask)({
|
await execTask(ciPackagePluginTask)({
|
||||||
signingAdmin: cmd.signingAdmin,
|
signatureType: cmd.signatureType,
|
||||||
|
rootUrls: cmd.rootUrls,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -8,6 +8,7 @@ import execa = require('execa');
|
|||||||
import path = require('path');
|
import path = require('path');
|
||||||
import fs from 'fs-extra';
|
import fs from 'fs-extra';
|
||||||
import { getPackageDetails, getGrafanaVersions, readGitLog } from '../../plugins/utils';
|
import { getPackageDetails, getGrafanaVersions, readGitLog } from '../../plugins/utils';
|
||||||
|
import { buildManifest, signManifest, saveManifest } from '../../plugins/manifest';
|
||||||
import {
|
import {
|
||||||
getJobFolder,
|
getJobFolder,
|
||||||
writeJobStats,
|
writeJobStats,
|
||||||
@ -25,7 +26,8 @@ const rimraf = promisify(rimrafCallback);
|
|||||||
export interface PluginCIOptions {
|
export interface PluginCIOptions {
|
||||||
finish?: boolean;
|
finish?: boolean;
|
||||||
upload?: boolean;
|
upload?: boolean;
|
||||||
signingAdmin?: boolean;
|
signatureType?: string;
|
||||||
|
rootUrls?: string[];
|
||||||
maxJestWorkers?: string;
|
maxJestWorkers?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -107,7 +109,7 @@ export const ciBuildPluginDocsTask = new Task<PluginCIOptions>('Build Plugin Doc
|
|||||||
* 2. zip it into packages in `~/ci/packages`
|
* 2. zip it into packages in `~/ci/packages`
|
||||||
* 3. prepare grafana environment in: `~/ci/grafana-test-env`
|
* 3. prepare grafana environment in: `~/ci/grafana-test-env`
|
||||||
*/
|
*/
|
||||||
const packagePluginRunner: TaskRunner<PluginCIOptions> = async ({ signingAdmin }) => {
|
const packagePluginRunner: TaskRunner<PluginCIOptions> = async ({ signatureType, rootUrls }) => {
|
||||||
const start = Date.now();
|
const start = Date.now();
|
||||||
const ciDir = getCiFolder();
|
const ciDir = getCiFolder();
|
||||||
const packagesDir = path.resolve(ciDir, 'packages');
|
const packagesDir = path.resolve(ciDir, 'packages');
|
||||||
@ -163,11 +165,16 @@ const packagePluginRunner: TaskRunner<PluginCIOptions> = async ({ signingAdmin }
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Write a MANIFEST.txt file in the dist folder
|
// Write a MANIFEST.txt file in the dist folder
|
||||||
// By using the --signing-admin flag the plugin doesn't need to be in the plugins database to be signed,
|
|
||||||
// however it requires an Admin API key.
|
|
||||||
try {
|
try {
|
||||||
const grabplCommandFlags = signingAdmin ? ['build-plugin-manifest', '--signing-admin'] : ['build-plugin-manifest'];
|
const manifest = await buildManifest(distContentDir);
|
||||||
await execa('grabpl', [...grabplCommandFlags, distContentDir]);
|
if (signatureType) {
|
||||||
|
manifest.signatureType = signatureType;
|
||||||
|
}
|
||||||
|
if (rootUrls) {
|
||||||
|
manifest.rootUrls = rootUrls;
|
||||||
|
}
|
||||||
|
const signedManifest = await signManifest(manifest);
|
||||||
|
await saveManifest(distContentDir, signedManifest);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn(`Error signing manifest: ${distContentDir}`, err);
|
console.warn(`Error signing manifest: ${distContentDir}`, err);
|
||||||
}
|
}
|
||||||
|
38
packages/grafana-toolkit/src/cli/tasks/plugin.sign.ts
Normal file
38
packages/grafana-toolkit/src/cli/tasks/plugin.sign.ts
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
import path from 'path';
|
||||||
|
import { buildManifest, signManifest, saveManifest } from '../../plugins/manifest';
|
||||||
|
import { Task, TaskRunner } from './task';
|
||||||
|
|
||||||
|
interface PluginSignOptions {
|
||||||
|
signatureType?: string;
|
||||||
|
rootUrls?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const pluginSignRunner: TaskRunner<PluginSignOptions> = async ({ signatureType, rootUrls }) => {
|
||||||
|
const distContentDir = path.resolve('dist');
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log('Building manifest...');
|
||||||
|
const manifest = await buildManifest(distContentDir);
|
||||||
|
// console.log(manifest);
|
||||||
|
|
||||||
|
console.log('Signing manifest...');
|
||||||
|
if (signatureType) {
|
||||||
|
manifest.signatureType = signatureType;
|
||||||
|
}
|
||||||
|
if (rootUrls) {
|
||||||
|
manifest.rootUrls = rootUrls;
|
||||||
|
}
|
||||||
|
|
||||||
|
const signedManifest = await signManifest(manifest);
|
||||||
|
// console.log(signedManifest);
|
||||||
|
|
||||||
|
console.log('Saving signed manifest...');
|
||||||
|
await saveManifest(distContentDir, signedManifest);
|
||||||
|
|
||||||
|
console.log('Signed successfully');
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const pluginSignTask = new Task<PluginSignOptions>('plugin:sign task', pluginSignRunner);
|
83
packages/grafana-toolkit/src/plugins/manifest.ts
Normal file
83
packages/grafana-toolkit/src/plugins/manifest.ts
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
import path from 'path';
|
||||||
|
import fs from 'fs';
|
||||||
|
import crypto from 'crypto';
|
||||||
|
import { ManifestInfo } from './types';
|
||||||
|
|
||||||
|
const MANIFEST_FILE = 'MANIFEST.txt';
|
||||||
|
|
||||||
|
async function* walk(dir: string, baseDir: string): AsyncGenerator<string, any, any> {
|
||||||
|
for await (const d of await (fs.promises as any).opendir(dir)) {
|
||||||
|
const entry = path.join(dir, d.name);
|
||||||
|
if (d.isDirectory()) {
|
||||||
|
yield* await walk(entry, baseDir);
|
||||||
|
} else if (d.isFile()) {
|
||||||
|
yield path.relative(baseDir, entry);
|
||||||
|
} else if (d.isSymbolicLink()) {
|
||||||
|
const realPath = fs.realpathSync(entry);
|
||||||
|
if (!realPath.startsWith(baseDir)) {
|
||||||
|
throw new Error(
|
||||||
|
`symbolic link ${path.relative(baseDir, entry)} targets a file outside of the base directory: ${baseDir}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
yield path.relative(baseDir, entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function buildManifest(dir: string): Promise<ManifestInfo> {
|
||||||
|
const pluginJson = JSON.parse(fs.readFileSync(path.join(dir, 'plugin.json'), { encoding: 'utf8' }));
|
||||||
|
|
||||||
|
const manifest = {
|
||||||
|
plugin: pluginJson.id,
|
||||||
|
version: pluginJson.info.version,
|
||||||
|
files: {},
|
||||||
|
} as ManifestInfo;
|
||||||
|
|
||||||
|
for await (const p of await walk(dir, dir)) {
|
||||||
|
if (p === MANIFEST_FILE) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
manifest.files[p] = crypto
|
||||||
|
.createHash('sha256')
|
||||||
|
.update(fs.readFileSync(path.join(dir, p)))
|
||||||
|
.digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
return manifest;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function signManifest(manifest: ManifestInfo): Promise<string> {
|
||||||
|
const GRAFANA_API_KEY = process.env.GRAFANA_API_KEY;
|
||||||
|
if (!GRAFANA_API_KEY) {
|
||||||
|
throw new Error('You must enter a GRAFANA_API_KEY to sign the plugin manifest');
|
||||||
|
}
|
||||||
|
|
||||||
|
const GRAFANA_COM_URL = process.env.GRAFANA_COM_URL || 'https://grafana.com/api';
|
||||||
|
const url = GRAFANA_COM_URL + '/plugins/ci/sign';
|
||||||
|
|
||||||
|
const axios = require('axios');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const info = await axios.post(url, manifest, {
|
||||||
|
headers: { Authorization: 'Bearer ' + GRAFANA_API_KEY },
|
||||||
|
});
|
||||||
|
if (info.status !== 200) {
|
||||||
|
console.warn('Error: ', info);
|
||||||
|
throw new Error('Error signing manifest');
|
||||||
|
}
|
||||||
|
|
||||||
|
return info.data;
|
||||||
|
} catch (err) {
|
||||||
|
if ((err.response && err.response.data) || err.response.data.message) {
|
||||||
|
throw new Error('Error signing manifest: ' + err.response.data.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('Error signing manifest: ' + err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveManifest(dir: string, signedManifest: string): Promise<boolean> {
|
||||||
|
fs.writeFileSync(path.join(dir, MANIFEST_FILE), signedManifest);
|
||||||
|
return true;
|
||||||
|
}
|
@ -91,6 +91,10 @@ export interface GitLogInfo {
|
|||||||
export interface ManifestInfo {
|
export interface ManifestInfo {
|
||||||
// time: number; << filled in by the server
|
// time: number; << filled in by the server
|
||||||
// keyId: string; << filled in by the server
|
// keyId: string; << filled in by the server
|
||||||
|
// signedByOrg: string; << filled in by the server
|
||||||
|
// signedByOrgName: string; << filled in by the server
|
||||||
|
signatureType?: string; // filled in by the server if not specified
|
||||||
|
rootUrls?: string[]; // for private signatures
|
||||||
plugin: string;
|
plugin: string;
|
||||||
version: string;
|
version: string;
|
||||||
files: Record<string, string>;
|
files: Record<string, string>;
|
||||||
|
Loading…
Reference in New Issue
Block a user