Merge branch 'master' into scaffolder-each

This commit is contained in:
Alex Eftimie
2023-08-03 09:01:29 +03:00
1397 changed files with 61620 additions and 8645 deletions
@@ -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';