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:test`
|
||||
- `grafana-toolkit plugin:build`
|
||||
- `grafana-toolkit plugin:sign`
|
||||
|
||||
### Create your plugin
|
||||
|
||||
@ -105,6 +106,19 @@ Available options:
|
||||
|
||||
- `--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
|
||||
|
||||
### 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 { buildPackageTask } from './tasks/package.build';
|
||||
import { pluginCreateTask } from './tasks/plugin.create';
|
||||
import { pluginSignTask } from './tasks/plugin.sign';
|
||||
import { bundleManagedTask } from './tasks/plugin/bundle.managed';
|
||||
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
|
||||
.command('plugin:ci-build')
|
||||
.option('--finish', 'move all results to the jobs folder', false)
|
||||
@ -200,11 +214,14 @@ export const run = (includeInternalScripts = false) => {
|
||||
|
||||
program
|
||||
.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')
|
||||
.action(async cmd => {
|
||||
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 fs from 'fs-extra';
|
||||
import { getPackageDetails, getGrafanaVersions, readGitLog } from '../../plugins/utils';
|
||||
import { buildManifest, signManifest, saveManifest } from '../../plugins/manifest';
|
||||
import {
|
||||
getJobFolder,
|
||||
writeJobStats,
|
||||
@ -25,7 +26,8 @@ const rimraf = promisify(rimrafCallback);
|
||||
export interface PluginCIOptions {
|
||||
finish?: boolean;
|
||||
upload?: boolean;
|
||||
signingAdmin?: boolean;
|
||||
signatureType?: string;
|
||||
rootUrls?: string[];
|
||||
maxJestWorkers?: string;
|
||||
}
|
||||
|
||||
@ -107,7 +109,7 @@ export const ciBuildPluginDocsTask = new Task<PluginCIOptions>('Build Plugin Doc
|
||||
* 2. zip it into packages in `~/ci/packages`
|
||||
* 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 ciDir = getCiFolder();
|
||||
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
|
||||
// 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 {
|
||||
const grabplCommandFlags = signingAdmin ? ['build-plugin-manifest', '--signing-admin'] : ['build-plugin-manifest'];
|
||||
await execa('grabpl', [...grabplCommandFlags, distContentDir]);
|
||||
const manifest = await buildManifest(distContentDir);
|
||||
if (signatureType) {
|
||||
manifest.signatureType = signatureType;
|
||||
}
|
||||
if (rootUrls) {
|
||||
manifest.rootUrls = rootUrls;
|
||||
}
|
||||
const signedManifest = await signManifest(manifest);
|
||||
await saveManifest(distContentDir, signedManifest);
|
||||
} catch (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 {
|
||||
// time: number; << 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;
|
||||
version: string;
|
||||
files: Record<string, string>;
|
||||
|
Loading…
Reference in New Issue
Block a user