Merge branch 'master' into add-examples

Signed-off-by: Brian Fletcher <brian@roadie.io>
This commit is contained in:
Brian Fletcher
2023-08-02 10:25:48 +01:00
committed by GitHub
490 changed files with 7007 additions and 1619 deletions
+15
View File
@@ -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
+34
View File
@@ -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)
+13 -2
View File
@@ -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';