Merge branch 'master' into scaffolder-each
This commit is contained in:
@@ -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}`,
|
||||
);
|
||||
}
|
||||
@@ -18,4 +18,9 @@ export {
|
||||
createTemplateAction,
|
||||
type TemplateActionOptions,
|
||||
} 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