Merge branch 'master' into add-examples
Signed-off-by: Brian Fletcher <brian@roadie.io>
This commit is contained in:
@@ -1,5 +1,20 @@
|
||||
# @backstage/plugin-scaffolder-node
|
||||
|
||||
## 0.1.6-next.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 12a8c94eda8d: Add package repository and homepage metadata
|
||||
- d3b31a791eb1: Deprecated `executeShellCommand`, `RunCommandOptions`, and `fetchContents` from `@backstage/plugin-scaffolder-backend`, since they are useful for Scaffolder modules (who should not be importing from the plugin package itself). You should now import these from `@backstage/plugin-scaffolder-backend-node` instead. `RunCommandOptions` was renamed in the Node package as `ExecuteShellCommandOptions`, for consistency.
|
||||
- Updated dependencies
|
||||
- @backstage/backend-common@0.19.2-next.1
|
||||
- @backstage/backend-plugin-api@0.6.0-next.1
|
||||
- @backstage/integration@1.5.1
|
||||
- @backstage/catalog-model@1.4.1
|
||||
- @backstage/errors@1.2.1
|
||||
- @backstage/types@1.1.0
|
||||
- @backstage/plugin-scaffolder-common@1.3.2
|
||||
|
||||
## 0.1.6-next.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -9,7 +9,10 @@ import { ExtensionPoint } from '@backstage/backend-plugin-api';
|
||||
import { JsonObject } from '@backstage/types';
|
||||
import { Logger } from 'winston';
|
||||
import { Schema } from 'jsonschema';
|
||||
import { ScmIntegrations } from '@backstage/integration';
|
||||
import { SpawnOptionsWithoutStdio } from 'child_process';
|
||||
import { TemplateInfo } from '@backstage/plugin-scaffolder-common';
|
||||
import { UrlReader } from '@backstage/backend-common';
|
||||
import { UserEntity } from '@backstage/catalog-model';
|
||||
import { Writable } from 'stream';
|
||||
import { z } from 'zod';
|
||||
@@ -67,6 +70,37 @@ export const createTemplateAction: <
|
||||
>,
|
||||
) => TemplateAction<TActionInput, TActionOutput>;
|
||||
|
||||
// @public
|
||||
export function executeShellCommand(
|
||||
options: ExecuteShellCommandOptions,
|
||||
): Promise<void>;
|
||||
|
||||
// @public
|
||||
export type ExecuteShellCommandOptions = {
|
||||
command: string;
|
||||
args: string[];
|
||||
options?: SpawnOptionsWithoutStdio;
|
||||
logStream?: Writable;
|
||||
};
|
||||
|
||||
// @public
|
||||
export function fetchContents(options: {
|
||||
reader: UrlReader;
|
||||
integrations: ScmIntegrations;
|
||||
baseUrl?: string;
|
||||
fetchUrl?: string;
|
||||
outputPath: string;
|
||||
}): Promise<void>;
|
||||
|
||||
// @public
|
||||
export function fetchFile(options: {
|
||||
reader: UrlReader;
|
||||
integrations: ScmIntegrations;
|
||||
baseUrl?: string;
|
||||
fetchUrl?: string;
|
||||
outputPath: string;
|
||||
}): Promise<void>;
|
||||
|
||||
// @alpha
|
||||
export interface ScaffolderActionsExtensionPoint {
|
||||
// (undocumented)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@backstage/plugin-scaffolder-node",
|
||||
"description": "The plugin-scaffolder-node module for @backstage/plugin-scaffolder-backend",
|
||||
"version": "0.1.6-next.0",
|
||||
"version": "0.1.6-next.1",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"license": "Apache-2.0",
|
||||
@@ -14,6 +14,12 @@
|
||||
"backstage": {
|
||||
"role": "node-library"
|
||||
},
|
||||
"homepage": "https://backstage.io",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/backstage/backstage",
|
||||
"directory": "plugins/scaffolder-node"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "backstage-cli package start",
|
||||
"build": "backstage-cli package build --experimental-type-build",
|
||||
@@ -24,17 +30,22 @@
|
||||
"postpack": "backstage-cli package postpack"
|
||||
},
|
||||
"dependencies": {
|
||||
"@backstage/backend-common": "workspace:^",
|
||||
"@backstage/backend-plugin-api": "workspace:^",
|
||||
"@backstage/catalog-model": "workspace:^",
|
||||
"@backstage/errors": "workspace:^",
|
||||
"@backstage/integration": "workspace:^",
|
||||
"@backstage/plugin-scaffolder-common": "workspace:^",
|
||||
"@backstage/types": "workspace:^",
|
||||
"fs-extra": "10.1.0",
|
||||
"jsonschema": "^1.2.6",
|
||||
"winston": "^3.2.1",
|
||||
"zod": "^3.21.4",
|
||||
"zod-to-json-schema": "^3.20.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@backstage/cli": "workspace:^"
|
||||
"@backstage/cli": "workspace:^",
|
||||
"@backstage/config": "workspace:^"
|
||||
},
|
||||
"files": [
|
||||
"alpha",
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
/*
|
||||
* Copyright 2021 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 { spawn, SpawnOptionsWithoutStdio } from 'child_process';
|
||||
import { PassThrough, Writable } from 'stream';
|
||||
|
||||
/**
|
||||
* Options for {@link executeShellCommand}.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type ExecuteShellCommandOptions = {
|
||||
/** command to run */
|
||||
command: string;
|
||||
/** arguments to pass the command */
|
||||
args: string[];
|
||||
/** options to pass to spawn */
|
||||
options?: SpawnOptionsWithoutStdio;
|
||||
/** stream to capture stdout and stderr output */
|
||||
logStream?: Writable;
|
||||
};
|
||||
|
||||
/**
|
||||
* Run a command in a sub-process, normally a shell command.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export async function executeShellCommand(
|
||||
options: ExecuteShellCommandOptions,
|
||||
): Promise<void> {
|
||||
const {
|
||||
command,
|
||||
args,
|
||||
options: spawnOptions,
|
||||
logStream = new PassThrough(),
|
||||
} = options;
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const process = spawn(command, args, spawnOptions);
|
||||
|
||||
process.stdout.on('data', stream => {
|
||||
logStream.write(stream);
|
||||
});
|
||||
|
||||
process.stderr.on('data', stream => {
|
||||
logStream.write(stream);
|
||||
});
|
||||
|
||||
process.on('error', error => {
|
||||
return reject(error);
|
||||
});
|
||||
|
||||
process.on('close', code => {
|
||||
if (code !== 0) {
|
||||
return reject(
|
||||
new Error(`Command ${command} failed, exit code: ${code}`),
|
||||
);
|
||||
}
|
||||
return resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
/*
|
||||
* Copyright 2021 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.
|
||||
*/
|
||||
|
||||
jest.mock('fs-extra');
|
||||
|
||||
import fs from 'fs-extra';
|
||||
import { resolve as resolvePath } from 'path';
|
||||
import { UrlReader } from '@backstage/backend-common';
|
||||
import { ConfigReader } from '@backstage/config';
|
||||
import { ScmIntegrations } from '@backstage/integration';
|
||||
import { fetchContents, fetchFile } from './fetch';
|
||||
import os from 'os';
|
||||
|
||||
describe('fetchContents helper', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
const integrations = ScmIntegrations.fromConfig(
|
||||
new ConfigReader({
|
||||
integrations: {
|
||||
github: [{ host: 'github.com', token: 'token' }],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const readUrl = jest.fn();
|
||||
const readTree = jest.fn();
|
||||
const reader: UrlReader = {
|
||||
readUrl,
|
||||
readTree,
|
||||
search: jest.fn(),
|
||||
};
|
||||
|
||||
const options = {
|
||||
reader,
|
||||
integrations,
|
||||
outputPath: os.tmpdir(),
|
||||
};
|
||||
|
||||
describe('fetch contents', () => {
|
||||
it('should reject absolute file locations', async () => {
|
||||
await expect(
|
||||
fetchContents({
|
||||
...options,
|
||||
baseUrl: 'file:///some/path',
|
||||
fetchUrl: '/etc/passwd',
|
||||
}),
|
||||
).rejects.toThrow(
|
||||
'Relative path is not allowed to refer to a directory outside its parent',
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject relative file locations that exit the baseUrl', async () => {
|
||||
await expect(
|
||||
fetchContents({
|
||||
...options,
|
||||
baseUrl: 'file:///some/path',
|
||||
fetchUrl: '../test',
|
||||
}),
|
||||
).rejects.toThrow(
|
||||
'Relative path is not allowed to refer to a directory outside its parent',
|
||||
);
|
||||
});
|
||||
|
||||
it('should copy file to outputpath', async () => {
|
||||
await fetchContents({
|
||||
...options,
|
||||
baseUrl: 'file:///some/path',
|
||||
fetchUrl: 'foo',
|
||||
outputPath: 'somepath',
|
||||
});
|
||||
expect(fs.copy).toHaveBeenCalledWith(
|
||||
resolvePath('/some/foo'),
|
||||
'somepath',
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject if no integration matches location', async () => {
|
||||
await expect(
|
||||
fetchContents({
|
||||
...options,
|
||||
baseUrl: 'http://example.com/some/folder',
|
||||
}),
|
||||
).rejects.toThrow(
|
||||
'No integration found for location http://example.com/some/folder',
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject if fetch url is relative and no base url is specified', async () => {
|
||||
await expect(
|
||||
fetchContents({
|
||||
...options,
|
||||
fetchUrl: 'foo',
|
||||
}),
|
||||
).rejects.toThrow(
|
||||
'Failed to fetch, template location could not be determined and the fetch URL is relative, foo',
|
||||
);
|
||||
});
|
||||
|
||||
it('should fetch url contents', async () => {
|
||||
const dirFunction = jest.fn();
|
||||
readTree.mockResolvedValue({
|
||||
dir: dirFunction,
|
||||
});
|
||||
await fetchContents({
|
||||
...options,
|
||||
outputPath: 'foo',
|
||||
fetchUrl: 'https://github.com/backstage/foo',
|
||||
});
|
||||
expect(fs.ensureDir).toHaveBeenCalledWith('foo');
|
||||
expect(dirFunction).toHaveBeenCalledWith({ targetDir: 'foo' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetch file', () => {
|
||||
it('should reject absolute file locations', async () => {
|
||||
await expect(
|
||||
fetchFile({
|
||||
...options,
|
||||
baseUrl: 'file:///some/path',
|
||||
fetchUrl: '/etc/passwd',
|
||||
}),
|
||||
).rejects.toThrow(
|
||||
'Relative path is not allowed to refer to a directory outside its parent',
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject relative file locations that exit the baseUrl', async () => {
|
||||
await expect(
|
||||
fetchFile({
|
||||
...options,
|
||||
baseUrl: 'file:///some/path',
|
||||
fetchUrl: '../test',
|
||||
}),
|
||||
).rejects.toThrow(
|
||||
'Relative path is not allowed to refer to a directory outside its parent',
|
||||
);
|
||||
});
|
||||
|
||||
it('should copy file to outputpath', async () => {
|
||||
await fetchFile({
|
||||
...options,
|
||||
baseUrl: 'file:///some/path',
|
||||
fetchUrl: 'foo',
|
||||
outputPath: 'somepath',
|
||||
});
|
||||
expect(fs.copyFile).toHaveBeenCalledWith(
|
||||
resolvePath('/some/foo'),
|
||||
'somepath',
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject if no integration matches location', async () => {
|
||||
await expect(
|
||||
fetchFile({
|
||||
...options,
|
||||
baseUrl: 'http://example.com/some/folder',
|
||||
}),
|
||||
).rejects.toThrow(
|
||||
'No integration found for location http://example.com/some/folder',
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject if fetch url is relative and no base url is specified', async () => {
|
||||
await expect(
|
||||
fetchFile({
|
||||
...options,
|
||||
fetchUrl: 'foo',
|
||||
}),
|
||||
).rejects.toThrow(
|
||||
'Failed to fetch, template location could not be determined and the fetch URL is relative, foo',
|
||||
);
|
||||
});
|
||||
|
||||
it('should fetch content from url', async () => {
|
||||
readUrl.mockResolvedValue({
|
||||
buffer: () => Buffer.from('test', 'utf8'),
|
||||
});
|
||||
await fetchFile({
|
||||
...options,
|
||||
outputPath: 'foo',
|
||||
fetchUrl: 'https://github.com/backstage/foo',
|
||||
});
|
||||
expect(fs.ensureDir).toHaveBeenCalledWith('.');
|
||||
expect(fs.outputFile).toHaveBeenCalledWith('foo', 'test');
|
||||
});
|
||||
|
||||
it('should fetch content from url into directory', async () => {
|
||||
readUrl.mockResolvedValue({
|
||||
buffer: () => Buffer.from('test', 'utf8'),
|
||||
});
|
||||
await fetchFile({
|
||||
...options,
|
||||
outputPath: 'mydir/foo',
|
||||
fetchUrl: 'https://github.com/backstage/foo',
|
||||
});
|
||||
expect(fs.ensureDir).toHaveBeenCalledWith('mydir');
|
||||
expect(fs.outputFile).toHaveBeenCalledWith('mydir/foo', 'test');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
/*
|
||||
* Copyright 2021 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 { resolveSafeChildPath, UrlReader } from '@backstage/backend-common';
|
||||
import { InputError } from '@backstage/errors';
|
||||
import { ScmIntegrations } from '@backstage/integration';
|
||||
import fs from 'fs-extra';
|
||||
import path from 'path';
|
||||
|
||||
/**
|
||||
* A helper function that reads the contents of a directory from the given URL.
|
||||
* Can be used in your own actions, and also used behind fetch:template and fetch:plain
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export async function fetchContents(options: {
|
||||
reader: UrlReader;
|
||||
integrations: ScmIntegrations;
|
||||
baseUrl?: string;
|
||||
fetchUrl?: string;
|
||||
outputPath: string;
|
||||
}) {
|
||||
const { reader, integrations, baseUrl, fetchUrl = '.', outputPath } = options;
|
||||
|
||||
const fetchUrlIsAbsolute = isFetchUrlAbsolute(fetchUrl);
|
||||
|
||||
// We handle both file locations and url ones
|
||||
if (!fetchUrlIsAbsolute && baseUrl?.startsWith('file://')) {
|
||||
const basePath = baseUrl.slice('file://'.length);
|
||||
const srcDir = resolveSafeChildPath(path.dirname(basePath), fetchUrl);
|
||||
await fs.copy(srcDir, outputPath);
|
||||
} else {
|
||||
const readUrl = getReadUrl(fetchUrl, baseUrl, integrations);
|
||||
|
||||
const res = await reader.readTree(readUrl);
|
||||
await fs.ensureDir(outputPath);
|
||||
await res.dir({ targetDir: outputPath });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A helper function that reads the content of a single file from the given URL.
|
||||
* Can be used in your own actions, and also used behind `fetch:plain:file`
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export async function fetchFile(options: {
|
||||
reader: UrlReader;
|
||||
integrations: ScmIntegrations;
|
||||
baseUrl?: string;
|
||||
fetchUrl?: string;
|
||||
outputPath: string;
|
||||
}) {
|
||||
const { reader, integrations, baseUrl, fetchUrl = '.', outputPath } = options;
|
||||
|
||||
const fetchUrlIsAbsolute = isFetchUrlAbsolute(fetchUrl);
|
||||
|
||||
// We handle both file locations and url ones
|
||||
if (!fetchUrlIsAbsolute && baseUrl?.startsWith('file://')) {
|
||||
const basePath = baseUrl.slice('file://'.length);
|
||||
const src = resolveSafeChildPath(path.dirname(basePath), fetchUrl);
|
||||
await fs.copyFile(src, outputPath);
|
||||
} else {
|
||||
const readUrl = getReadUrl(fetchUrl, baseUrl, integrations);
|
||||
|
||||
const res = await reader.readUrl(readUrl);
|
||||
await fs.ensureDir(path.dirname(outputPath));
|
||||
const buffer = await res.buffer();
|
||||
await fs.outputFile(outputPath, buffer.toString());
|
||||
}
|
||||
}
|
||||
|
||||
function isFetchUrlAbsolute(fetchUrl: string) {
|
||||
let fetchUrlIsAbsolute = false;
|
||||
try {
|
||||
// eslint-disable-next-line no-new
|
||||
new URL(fetchUrl);
|
||||
fetchUrlIsAbsolute = true;
|
||||
} catch {
|
||||
/* ignored */
|
||||
}
|
||||
return fetchUrlIsAbsolute;
|
||||
}
|
||||
|
||||
function getReadUrl(
|
||||
fetchUrl: string,
|
||||
baseUrl: string | undefined,
|
||||
integrations: ScmIntegrations,
|
||||
) {
|
||||
if (isFetchUrlAbsolute(fetchUrl)) {
|
||||
return fetchUrl;
|
||||
} else if (baseUrl) {
|
||||
const integration = integrations.byUrl(baseUrl);
|
||||
if (!integration) {
|
||||
throw new InputError(`No integration found for location ${baseUrl}`);
|
||||
}
|
||||
|
||||
return integration.resolveUrl({
|
||||
url: fetchUrl,
|
||||
base: baseUrl,
|
||||
});
|
||||
}
|
||||
throw new InputError(
|
||||
`Failed to fetch, template location could not be determined and the fetch URL is relative, ${fetchUrl}`,
|
||||
);
|
||||
}
|
||||
@@ -19,4 +19,9 @@ export {
|
||||
type TemplateActionOptions,
|
||||
type TemplateExample,
|
||||
} from './createTemplateAction';
|
||||
export {
|
||||
executeShellCommand,
|
||||
type ExecuteShellCommandOptions,
|
||||
} from './executeShellCommand';
|
||||
export { fetchContents, fetchFile } from './fetch';
|
||||
export { type ActionContext, type TemplateAction } from './types';
|
||||
|
||||
Reference in New Issue
Block a user