Added DevTools plugin
Signed-off-by: Andre Wanlin <67169551+awanlin@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,252 @@
|
||||
/*
|
||||
* Copyright 2022 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { Config, ConfigReader } from '@backstage/config';
|
||||
import { loadConfigSchema } from '@backstage/config-loader';
|
||||
import {
|
||||
PackageDependency,
|
||||
DevToolsInfo,
|
||||
ExternalDependency,
|
||||
Endpoint,
|
||||
ExternalDependencyStatus,
|
||||
ConfigInfo,
|
||||
} from '@backstage/plugin-devtools-common';
|
||||
|
||||
import { JsonObject } from '@backstage/types';
|
||||
import { Logger } from 'winston';
|
||||
import fetch from 'node-fetch';
|
||||
import { findPaths } from '@backstage/cli-common';
|
||||
import { getPackages } from '@manypkg/get-packages';
|
||||
import ping from 'ping';
|
||||
import os from 'os';
|
||||
import fs from 'fs-extra';
|
||||
import { Lockfile } from '../util/Lockfile';
|
||||
import { memoize } from 'lodash';
|
||||
import { assertError } from '@backstage/errors';
|
||||
|
||||
/** @public */
|
||||
export class DevToolsBackendApi {
|
||||
public constructor(
|
||||
private readonly logger: Logger,
|
||||
private readonly config: Config,
|
||||
) {}
|
||||
|
||||
public async listExternalDependencyDetails(): Promise<ExternalDependency[]> {
|
||||
const result: ExternalDependency[] = [];
|
||||
|
||||
const endpoints = this.config.getOptional<Endpoint[]>(
|
||||
'devTools.externalDependencies.endpoints',
|
||||
);
|
||||
if (!endpoints) {
|
||||
// No external dependency endpoints configured
|
||||
return result;
|
||||
}
|
||||
for (const endpoint of endpoints) {
|
||||
this.logger?.info(
|
||||
`Checking external dependency "${endpoint.name}" at "${endpoint.target}"`,
|
||||
);
|
||||
|
||||
switch (endpoint.type) {
|
||||
case 'ping': {
|
||||
const pingResult = await this.pingExternalDependency(endpoint);
|
||||
result.push(pingResult);
|
||||
break;
|
||||
}
|
||||
case 'fetch': {
|
||||
const fetchResult = await this.fetchExternalDependency(endpoint);
|
||||
result.push(fetchResult);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private async fetchExternalDependency(
|
||||
endpoint: Endpoint,
|
||||
): Promise<ExternalDependency> {
|
||||
let status;
|
||||
let error;
|
||||
|
||||
await fetch(endpoint.target)
|
||||
.then(res => {
|
||||
status =
|
||||
res.status === 200
|
||||
? ExternalDependencyStatus.healthy
|
||||
: ExternalDependencyStatus.unhealthy;
|
||||
this.logger.info(
|
||||
`Fetch for ${endpoint.name} resulted in status code "${res.status}"`,
|
||||
);
|
||||
})
|
||||
.catch((err: Error) => {
|
||||
this.logger.error(`Fetch failed for ${endpoint.name} - ${err.message}`);
|
||||
error = err.message;
|
||||
});
|
||||
|
||||
const result: ExternalDependency = {
|
||||
name: endpoint.name,
|
||||
type: endpoint.type,
|
||||
target: endpoint.target,
|
||||
status: status ?? ExternalDependencyStatus.unhealthy,
|
||||
error: error ?? undefined,
|
||||
};
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private async pingExternalDependency(
|
||||
endpoint: Endpoint,
|
||||
): Promise<ExternalDependency> {
|
||||
const pingResult = await ping.promise.probe(endpoint.target);
|
||||
|
||||
let error;
|
||||
if (
|
||||
pingResult.packetLoss === '100.000' ||
|
||||
pingResult.packetLoss === 'unknown'
|
||||
) {
|
||||
this.logger.error(
|
||||
`Ping failed for ${endpoint.name} - ${pingResult.output}`,
|
||||
);
|
||||
error =
|
||||
pingResult.output === ''
|
||||
? `${endpoint.target} - Unknown`
|
||||
: pingResult.output;
|
||||
}
|
||||
|
||||
this.logger.info(`Ping results for ${endpoint.name}: ${pingResult.output}`);
|
||||
|
||||
const result: ExternalDependency = {
|
||||
name: endpoint.name,
|
||||
type: endpoint.type,
|
||||
target: endpoint.target,
|
||||
status: pingResult.alive
|
||||
? ExternalDependencyStatus.healthy
|
||||
: ExternalDependencyStatus.unhealthy,
|
||||
error: error ?? undefined,
|
||||
};
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public async listConfig(): Promise<ConfigInfo> {
|
||||
/* eslint-disable-next-line no-restricted-syntax */
|
||||
const paths = findPaths(__dirname);
|
||||
|
||||
const { packages } = await getPackages(paths.targetDir);
|
||||
const schemaFunc = async () => {
|
||||
return await loadConfigSchema({
|
||||
dependencies: packages.map(p => p.packageJson.name),
|
||||
});
|
||||
};
|
||||
|
||||
const schemaMemo = memoize(schemaFunc);
|
||||
const schema = await schemaMemo();
|
||||
|
||||
const configInfo: ConfigInfo = {
|
||||
config: undefined,
|
||||
error: undefined,
|
||||
};
|
||||
try {
|
||||
const config = {
|
||||
data: this.config.get() as JsonObject,
|
||||
context: 'inline',
|
||||
};
|
||||
const sanitizedConfigs = schema.process([config], {
|
||||
ignoreSchemaErrors: false,
|
||||
valueTransform: (value, context) =>
|
||||
context.visibility === 'secret' ? '<secret>' : value,
|
||||
});
|
||||
|
||||
const data = ConfigReader.fromConfigs(sanitizedConfigs).get();
|
||||
configInfo.config = data;
|
||||
} catch (error) {
|
||||
assertError(error);
|
||||
// The config is not valid for some reason but we want to be able to see it still
|
||||
const config = {
|
||||
data: this.config.get() as JsonObject,
|
||||
context: 'inline',
|
||||
};
|
||||
const sanitizedConfigs = schema.process([config], {
|
||||
ignoreSchemaErrors: true,
|
||||
valueTransform: (value, context) =>
|
||||
context.visibility === 'secret' ? '<secret>' : value,
|
||||
});
|
||||
|
||||
const data = ConfigReader.fromConfigs(sanitizedConfigs).get();
|
||||
configInfo.config = data;
|
||||
configInfo.error = {
|
||||
name: error.name,
|
||||
message: error.message,
|
||||
messages: error.messages as string[] | undefined,
|
||||
stack: error.stack,
|
||||
};
|
||||
}
|
||||
|
||||
return configInfo;
|
||||
}
|
||||
|
||||
public async listInfo(): Promise<DevToolsInfo> {
|
||||
const operatingSystem = `${os.type} ${os.release} - ${os.platform}/${os.arch}`;
|
||||
const nodeJsVersion = process.version;
|
||||
|
||||
/* eslint-disable-next-line no-restricted-syntax */
|
||||
const paths = findPaths(__dirname);
|
||||
const backstageFile = paths.resolveTargetRoot('backstage.json');
|
||||
let backstageJson = undefined;
|
||||
if (fs.existsSync(backstageFile)) {
|
||||
const buffer = await fs.readFile(backstageFile);
|
||||
backstageJson = JSON.parse(buffer.toString());
|
||||
}
|
||||
|
||||
const lockfilePath = paths.resolveTargetRoot('yarn.lock');
|
||||
const lockfile = await Lockfile.load(lockfilePath);
|
||||
|
||||
const deps = [...lockfile.keys()].filter(n => n.startsWith('@backstage/'));
|
||||
|
||||
const infoDependencies: PackageDependency[] = [];
|
||||
for (const dep of deps) {
|
||||
const versions = new Set(lockfile.get(dep)!.map(i => i.version));
|
||||
const infoDependency: PackageDependency = {
|
||||
name: dep,
|
||||
versions: [...versions].join(', '),
|
||||
};
|
||||
infoDependencies.push(infoDependency);
|
||||
}
|
||||
|
||||
const info: DevToolsInfo = {
|
||||
operatingSystem: operatingSystem ?? 'N/A',
|
||||
nodeJsVersion: nodeJsVersion ?? 'N/A',
|
||||
backstageVersion:
|
||||
backstageJson && backstageJson.version ? backstageJson.version : 'N/A',
|
||||
dependencies: infoDependencies,
|
||||
};
|
||||
|
||||
return info;
|
||||
}
|
||||
}
|
||||
|
||||
export function isValidUrl(url: string): boolean {
|
||||
try {
|
||||
// eslint-disable-next-line no-new
|
||||
new URL(url);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* Copyright 2022 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export { DevToolsBackendApi } from './DevToolsBackendApi';
|
||||
@@ -0,0 +1,18 @@
|
||||
/*
|
||||
* Copyright 2022 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export { DevToolsBackendApi } from './api';
|
||||
export * from './service/router';
|
||||
@@ -0,0 +1,33 @@
|
||||
/*
|
||||
* Copyright 2022 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { getRootLogger } from '@backstage/backend-common';
|
||||
import yn from 'yn';
|
||||
import { startStandaloneServer } from './service/standaloneServer';
|
||||
|
||||
const port = process.env.PLUGIN_PORT ? Number(process.env.PLUGIN_PORT) : 7007;
|
||||
const enableCors = yn(process.env.PLUGIN_CORS, { default: false });
|
||||
const logger = getRootLogger();
|
||||
|
||||
startStandaloneServer({ port, enableCors, logger }).catch(err => {
|
||||
logger.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
process.on('SIGINT', () => {
|
||||
logger.info('CTRL+C pressed; exiting.');
|
||||
process.exit(0);
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
/*
|
||||
* Copyright 2022 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { getVoidLogger } from '@backstage/backend-common';
|
||||
import { ConfigReader } from '@backstage/config';
|
||||
import express from 'express';
|
||||
import request from 'supertest';
|
||||
import { PermissionEvaluator } from '@backstage/plugin-permission-common';
|
||||
import { createRouter } from './router';
|
||||
|
||||
const mockedAuthorize: jest.MockedFunction<PermissionEvaluator['authorize']> =
|
||||
jest.fn();
|
||||
const mockedPermissionQuery: jest.MockedFunction<
|
||||
PermissionEvaluator['authorizeConditional']
|
||||
> = jest.fn();
|
||||
|
||||
const permissionEvaluator: PermissionEvaluator = {
|
||||
authorize: mockedAuthorize,
|
||||
authorizeConditional: mockedPermissionQuery,
|
||||
};
|
||||
|
||||
describe('createRouter', () => {
|
||||
let app: express.Express;
|
||||
|
||||
beforeAll(async () => {
|
||||
const router = await createRouter({
|
||||
logger: getVoidLogger(),
|
||||
config: new ConfigReader({
|
||||
healthCheck: {
|
||||
endpoint: [
|
||||
{
|
||||
name: '',
|
||||
type: '',
|
||||
target: '',
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
permissions: permissionEvaluator,
|
||||
});
|
||||
app = express().use(router);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
describe('GET /health', () => {
|
||||
it('returns ok', async () => {
|
||||
const response = await request(app).get('/health');
|
||||
|
||||
expect(response.status).toEqual(200);
|
||||
expect(response.body).toEqual({ status: 'ok' });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,130 @@
|
||||
/*
|
||||
* Copyright 2022 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import {
|
||||
AuthorizeResult,
|
||||
PermissionEvaluator,
|
||||
} from '@backstage/plugin-permission-common';
|
||||
import {
|
||||
devToolsConfigReadPermission,
|
||||
devToolsExternalDependenciesReadPermission,
|
||||
devToolsInfoReadPermission,
|
||||
} from '@backstage/plugin-devtools-common';
|
||||
|
||||
import { Config } from '@backstage/config';
|
||||
import { DevToolsBackendApi } from '../api';
|
||||
import { Logger } from 'winston';
|
||||
import { NotAllowedError } from '@backstage/errors';
|
||||
import Router from 'express-promise-router';
|
||||
import { errorHandler } from '@backstage/backend-common';
|
||||
import express from 'express';
|
||||
import { getBearerTokenFromAuthorizationHeader } from '@backstage/plugin-auth-node';
|
||||
|
||||
/** @public */
|
||||
export interface RouterOptions {
|
||||
devToolsBackendApi?: DevToolsBackendApi;
|
||||
logger: Logger;
|
||||
config: Config;
|
||||
permissions: PermissionEvaluator;
|
||||
}
|
||||
|
||||
/** @public */
|
||||
export async function createRouter(
|
||||
options: RouterOptions,
|
||||
): Promise<express.Router> {
|
||||
const { logger, config, permissions } = options;
|
||||
|
||||
const devToolsBackendApi =
|
||||
options.devToolsBackendApi || new DevToolsBackendApi(logger, config);
|
||||
|
||||
const router = Router();
|
||||
router.use(express.json());
|
||||
|
||||
router.get('/health', (_req, res) => {
|
||||
res.status(200).json({ status: 'ok' });
|
||||
});
|
||||
|
||||
router.get('/info', async (req, response) => {
|
||||
const token = getBearerTokenFromAuthorizationHeader(
|
||||
req.header('authorization'),
|
||||
);
|
||||
|
||||
const decision = (
|
||||
await permissions.authorize(
|
||||
[{ permission: devToolsInfoReadPermission }],
|
||||
{
|
||||
token,
|
||||
},
|
||||
)
|
||||
)[0];
|
||||
|
||||
if (decision.result === AuthorizeResult.DENY) {
|
||||
throw new NotAllowedError('Unauthorized');
|
||||
}
|
||||
|
||||
const info = await devToolsBackendApi.listInfo();
|
||||
|
||||
response.status(200).json(info);
|
||||
});
|
||||
|
||||
router.get('/config', async (req, response) => {
|
||||
const token = getBearerTokenFromAuthorizationHeader(
|
||||
req.header('authorization'),
|
||||
);
|
||||
|
||||
const decision = (
|
||||
await permissions.authorize(
|
||||
[{ permission: devToolsConfigReadPermission }],
|
||||
{
|
||||
token,
|
||||
},
|
||||
)
|
||||
)[0];
|
||||
|
||||
if (decision.result === AuthorizeResult.DENY) {
|
||||
throw new NotAllowedError('Unauthorized');
|
||||
}
|
||||
|
||||
const configList = await devToolsBackendApi.listConfig();
|
||||
|
||||
response.status(200).json(configList);
|
||||
});
|
||||
|
||||
router.get('/external-dependencies', async (req, response) => {
|
||||
const token = getBearerTokenFromAuthorizationHeader(
|
||||
req.header('authorization'),
|
||||
);
|
||||
|
||||
const decision = (
|
||||
await permissions.authorize(
|
||||
[{ permission: devToolsExternalDependenciesReadPermission }],
|
||||
{
|
||||
token,
|
||||
},
|
||||
)
|
||||
)[0];
|
||||
|
||||
if (decision.result === AuthorizeResult.DENY) {
|
||||
throw new NotAllowedError('Unauthorized');
|
||||
}
|
||||
|
||||
const health = await devToolsBackendApi.listExternalDependencyDetails();
|
||||
|
||||
response.status(200).json(health);
|
||||
});
|
||||
|
||||
router.use(errorHandler());
|
||||
return router;
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
/*
|
||||
* Copyright 2022 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {
|
||||
ServerTokenManager,
|
||||
SingleHostDiscovery,
|
||||
createServiceBuilder,
|
||||
loadBackendConfig,
|
||||
} from '@backstage/backend-common';
|
||||
|
||||
import { Logger } from 'winston';
|
||||
import { Server } from 'http';
|
||||
import { ServerPermissionClient } from '@backstage/plugin-permission-node';
|
||||
import { createRouter } from './router';
|
||||
|
||||
export interface ServerOptions {
|
||||
port: number;
|
||||
enableCors: boolean;
|
||||
logger: Logger;
|
||||
}
|
||||
|
||||
export async function startStandaloneServer(
|
||||
options: ServerOptions,
|
||||
): Promise<Server> {
|
||||
const logger = options.logger.child({ service: 'devtools-backend-backend' });
|
||||
const config = await loadBackendConfig({ logger, argv: process.argv });
|
||||
const discovery = SingleHostDiscovery.fromConfig(config);
|
||||
const tokenManager = ServerTokenManager.fromConfig(config, {
|
||||
logger,
|
||||
});
|
||||
const permissions = ServerPermissionClient.fromConfig(config, {
|
||||
discovery,
|
||||
tokenManager,
|
||||
});
|
||||
logger.debug('Starting application server...');
|
||||
const router = await createRouter({
|
||||
logger,
|
||||
config,
|
||||
permissions,
|
||||
});
|
||||
|
||||
let service = createServiceBuilder(module)
|
||||
.setPort(options.port)
|
||||
.addRouter('/devtools-backend', router);
|
||||
if (options.enableCors) {
|
||||
service = service.enableCors({ origin: 'http://localhost:3000' });
|
||||
}
|
||||
|
||||
return await service.start().catch(err => {
|
||||
logger.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
module.hot?.accept();
|
||||
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* Copyright 2022 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export {};
|
||||
@@ -0,0 +1,317 @@
|
||||
/*
|
||||
* Copyright 2022 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import fs from 'fs-extra';
|
||||
import semver from 'semver';
|
||||
import { parseSyml, stringifySyml } from '@yarnpkg/parsers';
|
||||
import { stringify as legacyStringifyLockfile } from '@yarnpkg/lockfile';
|
||||
|
||||
const ENTRY_PATTERN = /^((?:@[^/]+\/)?[^@/]+)@(.+)$/;
|
||||
|
||||
type LockfileData = {
|
||||
[entry: string]: {
|
||||
version: string;
|
||||
resolved?: string;
|
||||
integrity?: string;
|
||||
dependencies?: { [name: string]: string };
|
||||
};
|
||||
};
|
||||
|
||||
type LockfileQueryEntry = {
|
||||
range: string;
|
||||
version: string;
|
||||
};
|
||||
|
||||
/** Entries that have an invalid version range, for example an npm tag */
|
||||
type AnalyzeResultInvalidRange = {
|
||||
name: string;
|
||||
range: string;
|
||||
};
|
||||
|
||||
/** Entries that can be deduplicated by bumping to an existing higher version */
|
||||
type AnalyzeResultNewVersion = {
|
||||
name: string;
|
||||
range: string;
|
||||
oldVersion: string;
|
||||
newVersion: string;
|
||||
};
|
||||
|
||||
/** Entries that would need a dependency update in package.json to be deduplicated */
|
||||
type AnalyzeResultNewRange = {
|
||||
name: string;
|
||||
oldRange: string;
|
||||
newRange: string;
|
||||
oldVersion: string;
|
||||
newVersion: string;
|
||||
};
|
||||
|
||||
type AnalyzeResult = {
|
||||
invalidRanges: AnalyzeResultInvalidRange[];
|
||||
newVersions: AnalyzeResultNewVersion[];
|
||||
newRanges: AnalyzeResultNewRange[];
|
||||
};
|
||||
|
||||
function parseLockfile(lockfileContents: string) {
|
||||
try {
|
||||
return {
|
||||
object: parseSyml(lockfileContents),
|
||||
type: 'success',
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
object: null,
|
||||
type: err,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// the new yarn header is handled out of band of the parsing
|
||||
// https://github.com/yarnpkg/berry/blob/0c5974f193a9397630e9aee2b3876cca62611149/packages/yarnpkg-core/sources/Project.ts#L1741-L1746
|
||||
const NEW_HEADER = `${[
|
||||
`# This file is generated by running "yarn install" inside your project.\n`,
|
||||
`# Manual changes might be lost - proceed with caution!\n`,
|
||||
].join(``)}\n`;
|
||||
|
||||
function stringifyLockfile(data: LockfileData, legacy: boolean) {
|
||||
return legacy
|
||||
? legacyStringifyLockfile(data)
|
||||
: NEW_HEADER + stringifySyml(data);
|
||||
}
|
||||
// taken from yarn parser package
|
||||
// https://github.com/yarnpkg/berry/blob/0c5974f193a9397630e9aee2b3876cca62611149/packages/yarnpkg-parsers/sources/syml.ts#L136
|
||||
const LEGACY_REGEX = /^(#.*(\r?\n))*?#\s+yarn\s+lockfile\s+v1\r?\n/i;
|
||||
|
||||
// these are special top level yarn keys.
|
||||
// https://github.com/yarnpkg/berry/blob/9bd61fbffb83d0b8166a9cc26bec3a58743aa453/packages/yarnpkg-parsers/sources/syml.ts#L9
|
||||
const SPECIAL_OBJECT_KEYS = [
|
||||
`__metadata`,
|
||||
`version`,
|
||||
`resolution`,
|
||||
`dependencies`,
|
||||
`peerDependencies`,
|
||||
`dependenciesMeta`,
|
||||
`peerDependenciesMeta`,
|
||||
`binaries`,
|
||||
];
|
||||
|
||||
export class Lockfile {
|
||||
static async load(path: string) {
|
||||
const lockfileContents = await fs.readFile(path, 'utf8');
|
||||
const legacy = LEGACY_REGEX.test(lockfileContents);
|
||||
const lockfile = parseLockfile(lockfileContents);
|
||||
if (lockfile.type !== 'success') {
|
||||
throw new Error(`Failed yarn.lock parse with ${lockfile.type}`);
|
||||
}
|
||||
|
||||
const data = lockfile.object as LockfileData;
|
||||
const packages = new Map<string, LockfileQueryEntry[]>();
|
||||
|
||||
for (const [key, value] of Object.entries(data)) {
|
||||
if (SPECIAL_OBJECT_KEYS.includes(key)) continue;
|
||||
|
||||
const [, name, range] = ENTRY_PATTERN.exec(key) ?? [];
|
||||
if (!name) {
|
||||
throw new Error(`Failed to parse yarn.lock entry '${key}'`);
|
||||
}
|
||||
|
||||
let queries = packages.get(name);
|
||||
if (!queries) {
|
||||
queries = [];
|
||||
packages.set(name, queries);
|
||||
}
|
||||
queries.push({ range, version: value.version });
|
||||
}
|
||||
|
||||
return new Lockfile(path, packages, data, legacy);
|
||||
}
|
||||
|
||||
private constructor(
|
||||
private readonly path: string,
|
||||
private readonly packages: Map<string, LockfileQueryEntry[]>,
|
||||
private readonly data: LockfileData,
|
||||
private readonly legacy: boolean = false,
|
||||
) {}
|
||||
|
||||
/** Get the entries for a single package in the lockfile */
|
||||
get(name: string): LockfileQueryEntry[] | undefined {
|
||||
return this.packages.get(name);
|
||||
}
|
||||
|
||||
/** Returns the name of all packages available in the lockfile */
|
||||
keys(): IterableIterator<string> {
|
||||
return this.packages.keys();
|
||||
}
|
||||
|
||||
/** Analyzes the lockfile to identify possible actions and warnings for the entries */
|
||||
analyze(options?: { filter?: (name: string) => boolean }): AnalyzeResult {
|
||||
const { filter } = options ?? {};
|
||||
const result: AnalyzeResult = {
|
||||
invalidRanges: [],
|
||||
newVersions: [],
|
||||
newRanges: [],
|
||||
};
|
||||
|
||||
for (const [name, allEntries] of this.packages) {
|
||||
if (filter && !filter(name)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Get rid of and signal any invalid ranges upfront
|
||||
const invalid = allEntries.filter(e => !semver.validRange(e.range));
|
||||
result.invalidRanges.push(
|
||||
...invalid.map(({ range }) => ({ name, range })),
|
||||
);
|
||||
|
||||
// Grab all valid entries, if there aren't at least 2 different valid ones we're done
|
||||
const entries = allEntries.filter(e => semver.validRange(e.range));
|
||||
if (entries.length < 2) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Find all versions currently in use
|
||||
const versions = Array.from(new Set(entries.map(e => e.version))).sort(
|
||||
(v1, v2) => semver.rcompare(v1, v2),
|
||||
);
|
||||
|
||||
// If we're not using at least 2 different versions we're done
|
||||
if (versions.length < 2) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const acceptedVersions = new Set<string>();
|
||||
for (const { version, range } of entries) {
|
||||
// Finds the highest matching version from the the known versions
|
||||
// TODO(Rugvip): We may want to select the version that satisfies the most ranges rather than the highest one
|
||||
const acceptedVersion = versions.find(v => semver.satisfies(v, range));
|
||||
if (!acceptedVersion) {
|
||||
throw new Error(
|
||||
`No existing version was accepted for range ${range}, searching through ${versions}, for package ${name}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (acceptedVersion !== version) {
|
||||
result.newVersions.push({
|
||||
name,
|
||||
range,
|
||||
newVersion: acceptedVersion,
|
||||
oldVersion: version,
|
||||
});
|
||||
}
|
||||
|
||||
acceptedVersions.add(acceptedVersion);
|
||||
}
|
||||
|
||||
// If all ranges were able to accept the same version, we're done
|
||||
if (acceptedVersions.size === 1) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Find the max version that we may want bump older packages to
|
||||
const maxVersion = Array.from(acceptedVersions).sort(semver.rcompare)[0];
|
||||
// Find all existing ranges that satisfy the new max version, and pick the one that
|
||||
// results in the highest minimum allowed version, usually being the more specific one
|
||||
const maxEntry = entries
|
||||
.filter(e => semver.satisfies(maxVersion, e.range))
|
||||
.map(e => ({ e, min: semver.minVersion(e.range) }))
|
||||
.filter(p => p.min)
|
||||
.sort((a, b) => semver.rcompare(a.min!, b.min!))[0]?.e;
|
||||
if (!maxEntry) {
|
||||
throw new Error(
|
||||
`No entry found that satisfies max version '${maxVersion}'`,
|
||||
);
|
||||
}
|
||||
|
||||
// Find all entries that don't satisfy the max version
|
||||
for (const { version, range } of entries) {
|
||||
if (semver.satisfies(maxVersion, range)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
result.newRanges.push({
|
||||
name,
|
||||
oldRange: range,
|
||||
newRange: maxEntry.range,
|
||||
oldVersion: version,
|
||||
newVersion: maxVersion,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
remove(name: string, range: string): boolean {
|
||||
const query = `${name}@${range}`;
|
||||
const existed = Boolean(this.data[query]);
|
||||
delete this.data[query];
|
||||
|
||||
const newEntries = this.packages.get(name)?.filter(e => e.range !== range);
|
||||
if (newEntries) {
|
||||
this.packages.set(name, newEntries);
|
||||
}
|
||||
|
||||
return existed;
|
||||
}
|
||||
|
||||
/** Modifies the lockfile by bumping packages to the suggested versions */
|
||||
replaceVersions(results: AnalyzeResultNewVersion[]) {
|
||||
for (const { name, range, oldVersion, newVersion } of results) {
|
||||
const query = `${name}@${range}`;
|
||||
|
||||
// Update the backing data
|
||||
const entryData = this.data[query];
|
||||
if (!entryData) {
|
||||
throw new Error(`No entry data for ${query}`);
|
||||
}
|
||||
if (entryData.version !== oldVersion) {
|
||||
throw new Error(
|
||||
`Expected existing version data for ${query} to be ${oldVersion}, was ${entryData.version}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Modifying the data in the entry is not enough, we need to reference an existing version object
|
||||
const matchingEntry = Object.entries(this.data).find(
|
||||
([q, e]) => q.startsWith(`${name}@`) && e.version === newVersion,
|
||||
);
|
||||
if (!matchingEntry) {
|
||||
throw new Error(
|
||||
`No matching entry found for ${name} at version ${newVersion}`,
|
||||
);
|
||||
}
|
||||
this.data[query] = matchingEntry[1];
|
||||
|
||||
// Update our internal data structure
|
||||
const entry = this.packages.get(name)?.find(e => e.range === range);
|
||||
if (!entry) {
|
||||
throw new Error(`No entry data for ${query}`);
|
||||
}
|
||||
if (entry.version !== oldVersion) {
|
||||
throw new Error(
|
||||
`Expected existing version data for ${query} to be ${oldVersion}, was ${entryData.version}`,
|
||||
);
|
||||
}
|
||||
entry.version = newVersion;
|
||||
}
|
||||
}
|
||||
|
||||
async save() {
|
||||
await fs.writeFile(this.path, this.toString(), 'utf8');
|
||||
}
|
||||
|
||||
toString() {
|
||||
return stringifyLockfile(this.data, this.legacy);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user